feature: add projects page (2 parts works)

This commit is contained in:
Daniil
2026-01-29 00:57:22 +03:00
parent 3dfb9453ec
commit 2e4820ac91
65 changed files with 2223 additions and 45 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./ui/NavigationDrawer"
@@ -0,0 +1,18 @@
import type { ComponentType } from "react"
export interface NavigationDrawerButton {
label: string
icon?: ComponentType<{ className?: string }>
path?: string
action?: () => void
}
export interface INavigationDrawerProps {
buttons: NavigationDrawerButton[]
className?: string
open: boolean
onClose: () => void
position?: "left" | "right" | "top" | "bottom"
size?: number
title?: string
}
@@ -0,0 +1,75 @@
.drawer {
background: transparent;
}
.root {
display: flex;
flex-direction: column;
min-width: 220px;
height: 100%;
padding: 16px 12px;
background: #ffffff;
border-radius: 12px 12px 0 0;
box-shadow: 0 10px 30px rgba(12, 18, 38, 0.08);
}
.header {
margin-bottom: 12px;
font-weight: 700;
font-size: 16px;
color: #0c1226;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.item {
display: flex;
}
.link,
.button {
display: inline-flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
color: #0c1226;
text-decoration: none;
font-weight: 600;
font-size: 14px;
line-height: 1.4;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.link:hover,
.button:hover,
.link:focus-visible,
.button:focus-visible {
outline: none;
background-color: #f4f6fb;
}
.icon {
display: inline-flex;
width: 18px;
height: 18px;
color: #5a6473;
}
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,77 @@
import type { JSX } from "react"
import { FunctionComponent } from "react"
import Drawer from "react-modern-drawer"
import cs from "classnames"
import Link from "next/link"
import { INavigationDrawerProps } from "../model/NavigationDrawer.d"
import styles from "./NavigationDrawer.module.scss"
import "react-modern-drawer/dist/index.css"
export const NavigationDrawer: FunctionComponent<INavigationDrawerProps> = ({
buttons,
className,
open,
onClose,
position = "left",
size = 280,
title,
}): JSX.Element => {
return (
<Drawer
open={open}
onClose={onClose}
direction={position}
size={size}
className={cs(styles.drawer, className)}
aria-label="Navigation drawer"
duration={200}
>
<nav className={styles.root} data-testid="NavigationDrawer">
{title ? <div className={styles.header}>{title}</div> : null}
<ul className={styles.list}>
{buttons.map(({ label, icon: Icon, path, action }, index) => {
const key = `${label}-${path ?? index}`
const content = (
<>
{Icon ? <Icon className={styles.icon} /> : null}
<span className={styles.label}>{label}</span>
</>
)
const handleClick = (): void => {
action?.()
onClose()
}
return (
<li className={styles.item} key={key}>
{path ? (
<Link
href={path}
className={styles.link}
onClick={handleClick}
>
{content}
</Link>
) : (
<button
type="button"
className={styles.button}
onClick={handleClick}
>
{content}
</button>
)}
</li>
)
})}
</ul>
</nav>
</Drawer>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/ProjectCard"
+20
View File
@@ -0,0 +1,20 @@
import { components } from "@shared/api/__generated__/openapi.types"
export interface IProjectCardProps {
project: components["schemas"]["ProjectRead"]
className?: string
/**
* Progress percentage (0-100)
* @default 0
*/
progress?: number
/**
* Current action name (e.g. "Rendering", "Uploading")
*/
currentAction?: string
/**
* Hero image URL
*/
imageUrl?: string
onClick?: () => void
}
@@ -0,0 +1,235 @@
.root {
@include mixins.flex-column;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
cursor: pointer;
}
.hero {
width: 100%;
height: 180px;
background-color: variables.$purple-50;
position: relative;
overflow: hidden;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: center;
align-items: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
width: 100%;
height: 100%;
@include mixins.flex-center;
background: linear-gradient(135deg, variables.$purple-50 0%, variables.$purple-100 100%);
svg {
width: 48px;
height: 48px;
color: variables.$purple-300;
opacity: 0.5;
}
}
}
.root:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.06);
}
.content {
display: flex;
flex-direction: column;
padding: 16px;
gap: 8px;
flex: 1;
}
.progressCircle {
position: relative;
width: 48px;
height: 48px;
border-radius: 50%;
&::before {
content: "";
position: absolute;
inset: -6px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
z-index: 0;
}
svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
circle {
transition: stroke-dashoffset 0.35s;
transform-origin: 50% 50%;
}
.progressBg {
stroke: variables.$purple-100;
}
.progressValue {
stroke: variables.$purple-400;
&.completed {
stroke: variables.$color-success;
}
}
.percentage {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@include typography.font-caption-m;
font-weight: 600;
color: variables.$color-white;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
z-index: 2;
}
}
.progressOverlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
pointer-events: none;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.08) 0%, rgba(0, 0, 0, 0.18) 100%);
z-index: 1;
}
.actionName {
@include typography.font-caption-m;
font-size: 10px;
color: variables.$color-white;
text-align: center;
white-space: nowrap;
max-width: 80px;
@include mixins.text-ellipsis;
}
.info {
@include mixins.flex-column;
gap: 8px;
flex: 1;
min-width: 0;
}
.infoHeader {
display: flex;
align-items: center;
gap: 8px;
}
.title {
@include typography.font-body-16(600);
color: variables.$text-primary;
@include mixins.text-ellipsis;
margin: 0;
}
.date {
@include typography.font-caption-m;
color: variables.$text-secondary;
}
.status {
margin-top: auto;
display: flex;
align-items: center;
gap: 6px;
@include typography.font-body-s;
font-weight: 500;
&.statusGenerated {
color: variables.$color-success;
}
&.statusProcessing, &.statusRendering, &.statusUploading {
color: variables.$purple-500;
}
&.statusDraft {
color: variables.$text-secondary;
}
&.statusFailed {
color: variables.$color-danger;
}
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: currentColor;
}
.menuTrigger {
margin-left: auto;
background-color: variables.$bg-surface;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
width: 32px;
height: 32px;
@include mixins.flex-center;
color: variables.$text-primary;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover,
&[data-state="open"] {
background-color: variables.$bg-default;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
button {
@include mixins.flex-center;
width: 100%;
height: 100%;
background: none;
border: none;
cursor: pointer;
color: inherit;
padding: 0;
}
}
.statusBadge {
position: absolute;
top: 12px;
left: 12px;
padding: 6px 10px;
border-radius: 12px;
background: variables.$bg-canvas;
@include typography.font-caption-m;
color: variables.$text-primary;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
z-index: 2;
}
+142
View File
@@ -0,0 +1,142 @@
"use client"
import type { IProjectCardProps } from "../model/ProjectCard.d"
import type { JSX } from "react"
import { Image as ImageIcon, MoreHorizontal } from "lucide-react"
import { FunctionComponent } from "react"
import cs from "classnames"
import moment from "moment"
import { Card } from "@shared/ui/Card"
import { CircularProgress } from "@shared/ui/CircularProgress"
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownTrigger,
} from "@shared/ui/Dropdown"
import styles from "./ProjectCard.module.scss"
export const ProjectCard: FunctionComponent<IProjectCardProps> = ({
project,
className,
progress = 0,
currentAction,
imageUrl,
onClick,
}): JSX.Element => {
const { name, updated_at, status } = project
const temporaryStatuses = new Set(["PROCESSING", "RENDERING", "UPLOADING"])
const isCompleted = status === "DONE"
const isProcessing = temporaryStatuses.has(status)
const isDraft = status === "DRAFT"
const isFailed = status === "FAILED"
const shouldShowProgress = isProcessing
// Helper to determine status color/class
const getStatusClass = () => {
if (isCompleted) return styles.statusGenerated
if (isProcessing) return styles.statusProcessing
if (isDraft) return styles.statusDraft
if (isFailed) return styles.statusFailed
return styles.statusDraft
}
const getStatusLabel = () => {
if (isCompleted) return "Завершено"
if (isProcessing) return "В процессе" // Or more specific state
if (isDraft) return "Черновик"
if (isFailed) return "Ошибка"
return status
}
const displayAction = currentAction || (isProcessing ? "В процессе" : "")
return (
<Card className={cs(styles.root, className)} onClick={onClick}>
<div className={styles.hero}>
{imageUrl ? (
<img src={imageUrl} alt={name} loading="lazy" />
) : (
<div className={styles.placeholder}>
<ImageIcon />
</div>
)}
<div className={styles.statusBadge}>
<div className={cs(styles.status, getStatusClass())}>
<span className={styles.statusDot} />
{getStatusLabel()}
</div>
</div>
{shouldShowProgress && (
<div className={styles.progressOverlay}>
<div className={styles.progressCircle}>
<CircularProgress
percentage={Math.min(100, Math.max(0, progress))}
color="var(--purple-500)"
bgClassName={styles.progressBg}
valueClassName={styles.progressValue}
/>
<span className={styles.percentage}>{Math.round(progress)}%</span>
</div>
{displayAction && (
<span className={styles.actionName} title={displayAction}>
{displayAction}
</span>
)}
</div>
)}
</div>
<div className={styles.content}>
<div className={styles.info}>
<div className={styles.infoHeader}>
<h3 className={styles.title} title={name}>
{name}
</h3>
<div
className={styles.menuTrigger}
onClick={(e) => e.stopPropagation()}
>
<Dropdown>
<DropdownTrigger asChild>
<button type="button" aria-label="Project actions">
<MoreHorizontal size={16} />
</button>
</DropdownTrigger>
<DropdownContent align="end">
<DropdownItem
onSelect={() => console.log("Edit", project.id)}
>
Изменить
</DropdownItem>
<DropdownItem
onSelect={() => console.log("Rename", project.id)}
>
Переименовать
</DropdownItem>
<DropdownItem
className="text-red-500"
onSelect={() => console.log("Delete", project.id)}
>
Удалить
</DropdownItem>
</DropdownContent>
</Dropdown>
</div>
</div>
<span className={styles.date}>
Создано {moment(updated_at).fromNow()}
</span>
</div>
</div>
</Card>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/UserDropdown"
+5
View File
@@ -0,0 +1,5 @@
import { UserEntity } from "@shared/store/user/types"
export interface IUserDropdownProps {
user: UserEntity | null
}
@@ -0,0 +1,16 @@
export const userDropdownValues = [
{
label: "Профиль",
acton: "profile",
path: "/profile",
},
{
label: "Настройки",
acton: "settings",
path: "/settings",
},
{
label: "Выйти",
acton: "logout",
},
]
@@ -0,0 +1,25 @@
.root {
display: flex
}
.username {
@include typography.font-body-16(500);
color: variables.$text-primary;
user-select: none;
}
.trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px ;
border-radius: 24px;
cursor: pointer;
transition: background-color 0.15s ease;
flex-shrink: 0;
&:hover {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
}
}
@@ -0,0 +1,44 @@
import type { JSX } from "react"
import { FunctionComponent } from "react"
import { Avatar } from "@shared/ui/Avatar"
import {
Dropdown,
DropdownContent,
DropdownItem,
DropdownSeparator,
DropdownTrigger,
} from "@shared/ui/Dropdown"
import { userDropdownValues } from "../model/constants"
import { IUserDropdownProps } from "../model/UserDropdown.d"
import styles from "./UserDropdown.module.scss"
export const UserDropdown: FunctionComponent<IUserDropdownProps> = ({
user,
}): JSX.Element => {
return (
<div className={styles.root} data-testid="UserDropdown">
<Dropdown>
<DropdownTrigger asChild>
<div className={styles.trigger}>
<Avatar size="small" url={user?.avatar || ""} />
<span className={styles.username}>{user?.username}</span>
</div>
</DropdownTrigger>
<DropdownContent>
{userDropdownValues.map((item) => (
<DropdownItem
key={item.acton}
className={styles.item}
onSelect={() => console.log(`${item.acton} selected`)}
>
{item.label}
</DropdownItem>
))}
</DropdownContent>
</Dropdown>
</div>
)
}
@@ -0,0 +1,25 @@
import type { components } from "@shared/api/__generated__/openapi.types"
import api from "@shared/api"
export type ProjectCreateBody = components["schemas"]["ProjectCreate"]
export type ProjectRead = components["schemas"]["ProjectRead"]
interface IUseCreateProjectParams {
onSuccess?: (project: ProjectRead) => void
onError?: (error: unknown) => void
}
export const useCreateProject = ({
onSuccess,
onError,
}: IUseCreateProjectParams = {}) => {
return api.useMutation("post", "/api/projects/", {
onSuccess: (project) => {
onSuccess?.(project)
},
onError: (error) => {
onError?.(error)
},
})
}
+3
View File
@@ -0,0 +1,3 @@
export { CreateProjectModal } from "./ui/CreateProjectModal"
export type { ICreateProjectModalProps } from "./model/CreateProjectModal.d"
@@ -0,0 +1,9 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps } from "react"
export interface ICreateProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
"open" | "onOpenChange"
> {
onCreated?: () => void | Promise<void>
}
@@ -0,0 +1,25 @@
.root {
min-width: 520px;
}
.fields {
display: grid;
gap: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
font-size: 14px;
font-weight: 500;
}
@@ -0,0 +1,179 @@
"use client"
import type { ProjectCreateBody } from "../api/useCreateProject"
import type { ICreateProjectModalProps } from "../model/CreateProjectModal.d"
import type { JSX } from "react"
import { FunctionComponent, useEffect } from "react"
import { Controller, useForm } from "react-hook-form"
import { Button, Form, Modal, Select, SelectItem, TextField } from "@shared/ui"
import { useCreateProject } from "../api/useCreateProject"
import styles from "./CreateProjectModal.module.scss"
type ProjectStatus = ProjectCreateBody["status"]
interface ICreateProjectFormData {
name: string
description?: string
language: string
folder?: string
status: ProjectStatus
}
const STATUS_OPTIONS: Array<{ value: ProjectStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
{ value: "PROCESSING", label: "Processing" },
{ value: "DONE", label: "Done" },
{ value: "FAILED", label: "Failed" },
]
const LANGUAGE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "auto", label: "Auto" },
{ value: "ru", label: "Russian" },
{ value: "en", label: "English" },
]
export const CreateProjectModal: FunctionComponent<
ICreateProjectModalProps
> = ({ open, onOpenChange, onCreated }): JSX.Element => {
const { control, register, handleSubmit, reset, formState } =
useForm<ICreateProjectFormData>({
defaultValues: {
name: "",
description: "",
folder: "",
language: "auto",
status: "DRAFT",
},
})
const { mutate, isPending } = useCreateProject({
onSuccess: async () => {
await onCreated?.()
onOpenChange?.(false)
},
onError: (error) => {
console.error("Create project failed:", error)
},
})
useEffect(() => {
if (!open) reset()
}, [open, reset])
const onSubmit = (data: ICreateProjectFormData): void => {
const name = data.name.trim()
const description = data.description?.trim()
const folder = data.folder?.trim()
mutate({
body: {
name,
description: description?.length ? description : undefined,
folder: folder?.length ? folder : undefined,
language: data.language,
status: data.status,
},
})
}
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Создать проект"
description="Заполните основные поля проекта"
>
<div className={styles.root} data-testid="CreateProjectModal">
<Form onSubmit={handleSubmit(onSubmit)}>
<div className={styles.fields}>
<TextField
id="project_name"
label="Название"
placeholder="Например: Мой первый проект"
error={Boolean(formState.errors.name)}
undertitle={formState.errors.name?.message}
{...register("name", {
required: "Введите название проекта",
validate: (v) =>
v.trim().length > 0 || "Введите название проекта",
})}
/>
<TextField
id="project_description"
label="Описание"
placeholder="Коротко опишите проект (необязательно)"
{...register("description")}
/>
<TextField
id="project_folder"
label="Папка"
placeholder="Например: /projects/my-project (необязательно)"
{...register("folder")}
/>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Язык</div>
<Controller
name="language"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите язык"
>
{LANGUAGE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
<div className={styles.selectField}>
<div className={styles.selectLabel}>Статус</div>
<Controller
name="status"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выберите статус"
>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</Select>
)}
/>
</div>
</div>
<div className={styles.actions}>
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => onOpenChange?.(false)}
>
Отмена
</Button>
<Button type="submit" variant="primary" disabled={isPending}>
Создать
</Button>
</div>
</Form>
</div>
</Modal>
)
}
+3 -1
View File
@@ -6,6 +6,7 @@ import { FunctionComponent } from "react"
import { useForm } from "react-hook-form"
import Link from "next/link"
import { useRouter } from "next/navigation"
import api from "@shared/api"
import { useCookie } from "@shared/hooks/useCookie"
@@ -26,6 +27,7 @@ interface ILoginFormData {
export const LoginPage: FunctionComponent<
ILoginPageProps
> = (): JSX.Element => {
const router = useRouter()
const [, setAccessTokenCookie] = useCookie(ACCESS_TOKEN_COOKIE)
const [, setRefreshTokenCookie] = useCookie(REFRESH_TOKEN_COOKIE)
@@ -33,7 +35,7 @@ export const LoginPage: FunctionComponent<
onSuccess: ({ access, refresh, user }) => {
setAccessTokenCookie(access)
setRefreshTokenCookie(refresh)
console.log("Login successful:", user)
router.push("/")
},
onError: (error) => {
console.error("Login failed:", error)
+1
View File
@@ -0,0 +1 @@
export * from "./ui/ProjectsPage"
+3
View File
@@ -0,0 +1,3 @@
export interface IProjectsPageProps {
message?: string
}
@@ -0,0 +1,38 @@
.root {
padding: 0 24px;
display: flex;
flex-direction: column;
gap: 32px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.titles {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
@include typography.font-display;
color: variables.$text-primary;
margin: 0;
}
.subtitle {
@include typography.font-body-16(600);
color: variables.$text-primary;
}
.projectList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-top: 24px;
padding-bottom: 40px;
}
@@ -0,0 +1,75 @@
"use client"
import type { JSX } from "react"
import { PlusIcon } from "lucide-react"
import { FunctionComponent, useState } from "react"
import { ProjectCard } from "@entities/ProjectCard"
import { CreateProjectModal } from "@features/CreateProjectModal"
import api from "@shared/api"
import { Button } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader"
import { ProjectsHeader } from "@widgets/Projects/ProjectsHeader/ui/ProjectsHeader"
import { IProjectsPageProps } from "../model/ProjectsPage.d"
import styles from "./ProjectsPage.module.scss"
export const ProjectsPage: FunctionComponent<
IProjectsPageProps
> = (): JSX.Element => {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const {
data: projects,
isLoading: projectsLoading,
refetch: refetchProjects,
} = api.useQuery("get", "/api/projects/")
return (
<div className={styles.root} data-testid="ProjectsPage">
{projectsLoading && <StaticLoader fullscreen />}
<div className={styles.header}>
<div className={styles.titles}>
<h1 className={styles.title}>Мои проекты</h1>
<h4 className={styles.subtitle}>
Управляйте своими последними проектами
</h4>
</div>
<div>
<Button
variant="primary"
size="lg"
onClick={() => setIsCreateModalOpen(true)}
>
<PlusIcon /> Создать проект
</Button>
</div>
</div>
<CreateProjectModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreated={async () => {
await refetchProjects()
}}
/>
<ProjectsHeader />
<div className={styles.projectList}>
{projects?.map((project) => (
<ProjectCard
key={project.id}
project={project}
// Mock random progress for demo since API doesn't provide it yet
progress={project.status === "PROCESSING" ? 45 : 0}
currentAction={
project.status === "PROCESSING" ? "Rendering" : undefined
}
/>
))}
</div>
</div>
)
}
+1
View File
@@ -21,6 +21,7 @@ export const verifyToken = async (token: string): Promise<boolean> => {
},
})
console.log("Verify token response:", resp)
if (resp.error) return false
return true
} catch (error) {
console.error("Verify token error:", error)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+2
View File
@@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from "react-redux"
import { store } from "@shared/store"
import { QueryClientProvider } from "./QueryClientProvider"
import { UserSync } from "./UserSync"
export const AppProviders = ({
children,
@@ -17,6 +18,7 @@ export const AppProviders = ({
return (
<ReduxProvider store={store}>
<QueryClientProvider>
<UserSync />
<Theme accentColor="violet" grayColor="slate" radius="medium">
{children}
</Theme>
+85
View File
@@ -0,0 +1,85 @@
"use client"
import type { JSX } from "react"
import { useEffect, useRef } from "react"
import Cookies from "js-cookie"
import { fetchClient } from "@shared/api"
import { useAppDispatch } from "@shared/hooks/useAppDispatch"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { ACCESS_TOKEN_COOKIE } from "@shared/lib/constants"
import { setUser } from "@shared/store/user"
const TOKEN_POLL_INTERVAL = 2000
export const UserSync = (): JSX.Element | null => {
const dispatch = useAppDispatch()
const user = useAppSelector((state) => state.user.user)
const userRef = useRef(user)
const lastTokenRef = useRef<string | undefined>(undefined)
useEffect(() => {
userRef.current = user
}, [user])
useEffect(() => {
let isCancelled = false
const syncUserWithToken = async (): Promise<void> => {
if (typeof window === "undefined") return
const token = Cookies.get(ACCESS_TOKEN_COOKIE)
if (!token) {
lastTokenRef.current = undefined
if (userRef.current) {
dispatch(setUser(null))
}
return
}
// Skip refetch when token is unchanged and user is already cached
if (lastTokenRef.current === token && userRef.current) return
lastTokenRef.current = token
try {
const response = await fetchClient.GET("/api/users/me/")
if (isCancelled) return
if (response.data) {
dispatch(setUser(response.data))
return
}
} catch (error) {
console.error("Failed to fetch current user:", error)
}
if (!isCancelled) {
dispatch(setUser(null))
}
}
syncUserWithToken()
const intervalId = window.setInterval(
syncUserWithToken,
TOKEN_POLL_INTERVAL,
)
const handleFocus = (): void => {
syncUserWithToken()
}
window.addEventListener("focus", handleFocus)
return () => {
isCancelled = true
window.clearInterval(intervalId)
window.removeEventListener("focus", handleFocus)
}
}, [dispatch])
return null
}
+1 -1
View File
@@ -2,4 +2,4 @@ import type { AppDispatch } from "@shared/store"
import { useDispatch } from "react-redux"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppDispatch = (): AppDispatch => useDispatch<AppDispatch>()
+2 -1
View File
@@ -1,5 +1,6 @@
import type { RootState } from "@shared/store"
import type { TypedUseSelectorHook } from "react-redux"
import { useSelector } from "react-redux"
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
@@ -2,9 +2,7 @@ import type { PayloadAction } from "@reduxjs/toolkit"
import { createSlice } from "@reduxjs/toolkit"
export interface AppState {
currentScreenName: string
}
import { AppState } from "./types"
const initialState: AppState = {
currentScreenName: "",
+3
View File
@@ -0,0 +1,3 @@
export interface AppState {
currentScreenName: string
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { configureStore } from "@reduxjs/toolkit"
import { appStateReducer } from "./appStateStore"
import { userReducer } from "./userStore"
import { appStateReducer } from "./appState"
import { userReducer } from "./user"
export const store = configureStore({
reducer: {
@@ -1,13 +1,8 @@
import type { PayloadAction } from "@reduxjs/toolkit"
import type { components } from "@shared/api/__generated__/openapi.types"
import { createSlice } from "@reduxjs/toolkit"
export type UserEntity = components["schemas"]["UserRead"]
export interface UserState {
user: UserEntity | null
}
import { UserEntity, UserState } from "./types"
const initialState: UserState = {
user: null,
+7
View File
@@ -0,0 +1,7 @@
import { components } from "@shared/api/__generated__/openapi.types"
export type UserEntity = components["schemas"]["UserRead"]
export interface UserState {
user: UserEntity | null
}
+9
View File
@@ -49,3 +49,12 @@
padding: 0;
list-style: none;
}
@mixin transparent-color($color, $transparency: 50%) {
color: color-mix(in srgb, $color $transparency, transparent);
}
@mixin transparent-bg($color, $transparency: 50%) {
background-color: color-mix(in srgb, $color $transparency, transparent);
}
+3
View File
@@ -33,3 +33,6 @@ $header-height: var(--header-height);
$text-primary: var(--text-primary);
$text-secondary: var(--text-secondary);
$bg-default: var(--bg-default);
$bg-surface: var(--bg-surface);
$bg-canvas: var(--bg-canvas);
+9 -4
View File
@@ -50,13 +50,18 @@ body {
--color-danger: #ef4444;
--color-warning: #facc15;
--color-primary: var(--purple-400);
--color-secondary: var(--green-800);
--color-primary: var(--green-800);
--color-secondary: var(--purple-400);
--color-white: #ffffff;
--color-black: #000000;
--text-primary: #0c1226;
--text-secondary: #5a5f73;
--bg-canvas: rgb(246, 245, 250);
--bg-default: rgb(255, 255, 255);
--bg-surface: rgba(245, 245, 245, 1);
--bg-default-invert: rgba(34, 35, 37, 1);
--text-primary: #111827;
--text-secondary: #6b7280;
--header-height: 56px;
}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/Avatar"
+6
View File
@@ -0,0 +1,6 @@
export interface IAvatarProps {
size?: "xxxlarge" | "xxlarge" | "xlarge" | "large" | "medium" | "small"
url: string
active?: boolean
variant?: "circle" | "square"
}
+163
View File
@@ -0,0 +1,163 @@
.root {
position: relative;
display: flex;
flex-shrink: 0;
&.xxxlarge {
width: 128px;
height: 128px;
.icon {
bottom: 13x;
right: 13px;
}
}
&.xxlarge {
width: 96px;
height: 96px;
.icon {
bottom: 8x;
right: 8px;
}
}
&.xlarge {
width: 64px;
height: 64px;
.icon {
bottom: 5px;
right: 5px;
}
}
&.large {
width: 40px;
height: 40px;
.icon {
bottom: 2px;
right: 2px;
}
}
&.medium {
width: 32px;
height: 32px;
.icon {
bottom: 1px;
right: 1px;
}
}
&.small {
width: 24px;
height: 24px;
.icon {
display: none;
}
}
}
.img {
object-fit: cover;
&.xxxlarge {
width: 128px;
height: 128px;
&.square {
border-radius: 16px;
}
}
&.xxlarge {
width: 96px;
height: 96px;
&.square {
border-radius: 16px;
}
}
&.xlarge {
width: 64px;
height: 64px;
&.square {
border-radius: 16px;
}
}
&.small {
width: 24px;
height: 24px;
&.square {
border-radius: 4px;
}
}
&.medium {
width: 32px;
height: 32px;
&.square {
border-radius: 8px;
}
}
&.large {
width: 40px;
height: 40px;
&.square {
border-radius: 8px;
}
}
&.circle {
border-radius: 999px;
}
&.loading {
animation: shimmer 1.6s infinite linear;
background: linear-gradient(
to right,
variables.$color-secondary 8%,
variables.$color-white 18%,
variables.$color-secondary 33%
);
background-size: 800px 104px;
position: absolute;
width: 100%;
height: 100%;
}
}
.icon {
display: none;
position: absolute;
width: 8px;
height: 8px;
border-radius: 999px;
&.active {
display: inline;
background-color: #00ff00;
}
}
@keyframes shimmer {
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { JSX } from "react"
import { FunctionComponent, memo, useState } from "react"
import cs from "classnames"
import Image from "next/image"
import avatarPlaceholder from "@shared/assets/placeholder.png"
import { IAvatarProps } from "../model/Avatar"
import styles from "./Avatar.module.scss"
const avatarProperties = {
xxxlarge: {
width: 1024,
height: 1024,
},
xxlarge: {
width: 512,
height: 512,
},
xlarge: {
width: 512,
height: 512,
},
large: {
width: 256,
height: 256,
},
medium: {
width: 128,
height: 128,
},
small: {
width: 64,
height: 64,
},
}
export const Avatar: FunctionComponent<IAvatarProps> = memo(
({
size = "large",
variant = "circle",
url,
active = false,
}): JSX.Element => {
const [loaded, setLoaded] = useState(false)
const [imgURL, setImgURL] = useState(url || avatarPlaceholder.src)
return (
<div className={cs(styles.root, styles[size])}>
{url ? (
<>
<Image
priority
loading="eager"
className={cs(styles.img, styles[size], styles[variant], {
[styles.loading]: !loaded,
})}
onError={(e) => {
e.preventDefault()
setImgURL(avatarPlaceholder.src)
}}
onLoad={() => setLoaded(true)}
alt="avatar"
src={imgURL}
{...avatarProperties[size]}
/>
<div
className={cs(styles.icon, {
[styles.active]: active,
})}
/>
</>
) : (
<Image
priority
loading="eager"
className={cs(styles.img, styles[size], styles[variant], {
[styles.loading]: !loaded,
})}
alt="avatar"
src={imgURL}
{...avatarProperties[size]}
/>
)}
</div>
)
},
)
+2
View File
@@ -40,6 +40,7 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{children}
@@ -53,6 +54,7 @@ export const Button = forwardRef<HTMLButtonElement, IButtonProps>(
size={radixSize}
variant={visual.variant}
color={visual.color}
style={{ cursor: "pointer", ...props.style }}
{...props}
>
{children}
+1
View File
@@ -0,0 +1 @@
export * from "./ui/CircularProgress"
@@ -0,0 +1,11 @@
export interface ICircularProgressProps {
percentage: number
className?: string
bgClassName?: string
valueClassName?: string
color?: string
size?: number
strokeWidth?: number
strokeLinecap?: "butt" | "round" | "square"
ariaLabel?: string
}
@@ -0,0 +1,3 @@
.root {
display: block;
}
@@ -0,0 +1,66 @@
import type { ICircularProgressProps } from "../model/CircularProgress.d"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import cs from "classnames"
import styles from "./CircularProgress.module.scss"
const clampPercentage = (value: number): number => {
if (Number.isNaN(value)) return 0
return Math.min(100, Math.max(0, value))
}
export const CircularProgress: FunctionComponent<ICircularProgressProps> = ({
percentage,
className,
bgClassName,
valueClassName,
color,
size = 48,
strokeWidth = 4,
strokeLinecap = "round",
ariaLabel,
}): JSX.Element => {
const safePercentage = clampPercentage(percentage)
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (safePercentage / 100) * circumference
const ariaProps = ariaLabel
? { role: "img", "aria-label": ariaLabel }
: { "aria-hidden": true }
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className={cs(styles.root, className)}
data-testid="CircularProgress"
{...ariaProps}
>
<circle
className={bgClassName}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
className={valueClassName}
stroke={color}
strokeWidth={strokeWidth}
fill="transparent"
r={radius}
cx={size / 2}
cy={size / 2}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap={strokeLinecap}
/>
</svg>
)
}
+29
View File
@@ -0,0 +1,29 @@
export {
Dropdown,
DropdownCheckboxItem,
DropdownContent,
DropdownItem,
DropdownLabel,
DropdownRadioGroup,
DropdownRadioItem,
DropdownSeparator,
DropdownSub,
DropdownSubContent,
DropdownSubTrigger,
DropdownTrigger,
} from "./ui/Dropdown"
export type {
IDropdownCheckboxItemProps,
IDropdownContentProps,
IDropdownItemProps,
IDropdownLabelProps,
IDropdownProps,
IDropdownRadioGroupProps,
IDropdownRadioItemProps,
IDropdownSeparatorProps,
IDropdownSubContentProps,
IDropdownSubProps,
IDropdownSubTriggerProps,
IDropdownTriggerProps,
} from "./model/Dropdown.d"
+82
View File
@@ -0,0 +1,82 @@
import type * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import type { ComponentProps, ReactNode } from "react"
export interface IDropdownProps extends ComponentProps<
typeof DropdownMenu.Root
> {
children?: ReactNode
}
export interface IDropdownTriggerProps extends ComponentProps<
typeof DropdownMenu.Trigger
> {
children?: ReactNode
asChild?: boolean
}
export interface IDropdownContentProps extends ComponentProps<
typeof DropdownMenu.Content
> {
children?: ReactNode
sideOffset?: number
align?: "start" | "center" | "end"
}
export interface IDropdownItemProps extends ComponentProps<
typeof DropdownMenu.Item
> {
children?: ReactNode
disabled?: boolean
}
export interface IDropdownCheckboxItemProps extends ComponentProps<
typeof DropdownMenu.CheckboxItem
> {
children?: ReactNode
checked?: boolean
onCheckedChange?: (checked: boolean) => void
}
export interface IDropdownRadioGroupProps extends ComponentProps<
typeof DropdownMenu.RadioGroup
> {
children?: ReactNode
value?: string
onValueChange?: (value: string) => void
}
export interface IDropdownRadioItemProps extends ComponentProps<
typeof DropdownMenu.RadioItem
> {
children?: ReactNode
value: string
}
export interface IDropdownLabelProps extends ComponentProps<
typeof DropdownMenu.Label
> {
children?: ReactNode
}
export interface IDropdownSeparatorProps extends ComponentProps<
typeof DropdownMenu.Separator
> {}
export interface IDropdownSubProps extends ComponentProps<
typeof DropdownMenu.Sub
> {
children?: ReactNode
}
export interface IDropdownSubTriggerProps extends ComponentProps<
typeof DropdownMenu.SubTrigger
> {
children?: ReactNode
}
export interface IDropdownSubContentProps extends ComponentProps<
typeof DropdownMenu.SubContent
> {
children?: ReactNode
sideOffset?: number
}
@@ -0,0 +1,94 @@
@use "@shared/styles/variables" as *;
.trigger {
all: unset;
cursor: pointer;
&:focus-visible {
outline: 2px solid $color-primary;
outline-offset: 2px;
}
}
.content,
.subContent {
z-index: 100;
min-width: 180px;
padding: 8px;
background-color: $color-white;
border: 1px solid $color-primary;
border-radius: 8px;
box-shadow:
0 10px 38px -10px rgb(22 23 24 / 35%),
0 10px 20px -15px rgb(22 23 24 / 20%);
animation: fadeIn 0.15s ease-out;
}
.item,
.checkboxItem,
.radioItem,
.subTrigger {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
font-size: 14px;
color: $text-primary;
cursor: pointer;
border-radius: 4px;
outline: none;
transition: background-color 0.15s ease;
color: variables.$text-primary;
&[data-highlighted] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
}
&[data-disabled] {
color: $text-secondary;
pointer-events: none;
opacity: 0.5;
}
}
.subTrigger {
justify-content: space-between;
&[data-state="open"] {
background-color: color-mix(in srgb, variables.$color-primary 50%, transparent );
}
}
.itemIndicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
color: $color-secondary;
}
.label {
padding: 8px 12px 4px;
font-size: 12px;
font-weight: 500;
color: $text-secondary;
}
.separator {
height: 1px;
margin: 8px 0;
background-color: color-mix(in srgb, variables.$color-primary 20%, transparent);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
+198
View File
@@ -0,0 +1,198 @@
"use client"
import type {
IDropdownCheckboxItemProps,
IDropdownContentProps,
IDropdownItemProps,
IDropdownLabelProps,
IDropdownProps,
IDropdownRadioGroupProps,
IDropdownRadioItemProps,
IDropdownSeparatorProps,
IDropdownSubContentProps,
IDropdownSubProps,
IDropdownSubTriggerProps,
IDropdownTriggerProps,
} from "../model/Dropdown.d"
import type { JSX } from "react"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import { forwardRef } from "react"
import cs from "classnames"
import styles from "./Dropdown.module.scss"
export const Dropdown = ({
children,
...props
}: IDropdownProps): JSX.Element => (
<DropdownMenu.Root {...props}>{children}</DropdownMenu.Root>
)
export const DropdownTrigger = forwardRef<
HTMLButtonElement,
IDropdownTriggerProps
>(
({ children, className, asChild = false, ...props }, ref): JSX.Element => (
<DropdownMenu.Trigger
ref={ref}
className={cs(styles.trigger, className)}
asChild={asChild}
{...props}
>
{children}
</DropdownMenu.Trigger>
),
)
DropdownTrigger.displayName = "DropdownTrigger"
export const DropdownContent = forwardRef<
HTMLDivElement,
IDropdownContentProps
>(
(
{ children, className, sideOffset = 4, align = "end", ...props },
ref,
): JSX.Element => (
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={ref}
className={cs(styles.content, className)}
sideOffset={sideOffset}
align={align}
{...props}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
),
)
DropdownContent.displayName = "DropdownContent"
export const DropdownItem = forwardRef<HTMLDivElement, IDropdownItemProps>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.Item
ref={ref}
className={cs(styles.item, className)}
{...props}
>
{children}
</DropdownMenu.Item>
),
)
DropdownItem.displayName = "DropdownItem"
export const DropdownCheckboxItem = forwardRef<
HTMLDivElement,
IDropdownCheckboxItemProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.CheckboxItem
ref={ref}
className={cs(styles.checkboxItem, className)}
{...props}
>
<DropdownMenu.ItemIndicator className={styles.itemIndicator}>
</DropdownMenu.ItemIndicator>
{children}
</DropdownMenu.CheckboxItem>
),
)
DropdownCheckboxItem.displayName = "DropdownCheckboxItem"
export const DropdownRadioGroup = ({
children,
...props
}: IDropdownRadioGroupProps): JSX.Element => (
<DropdownMenu.RadioGroup {...props}>{children}</DropdownMenu.RadioGroup>
)
export const DropdownRadioItem = forwardRef<
HTMLDivElement,
IDropdownRadioItemProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.RadioItem
ref={ref}
className={cs(styles.radioItem, className)}
{...props}
>
<DropdownMenu.ItemIndicator className={styles.itemIndicator}>
</DropdownMenu.ItemIndicator>
{children}
</DropdownMenu.RadioItem>
),
)
DropdownRadioItem.displayName = "DropdownRadioItem"
export const DropdownLabel = forwardRef<HTMLDivElement, IDropdownLabelProps>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.Label
ref={ref}
className={cs(styles.label, className)}
{...props}
>
{children}
</DropdownMenu.Label>
),
)
DropdownLabel.displayName = "DropdownLabel"
export const DropdownSeparator = forwardRef<
HTMLDivElement,
IDropdownSeparatorProps
>(
({ className, ...props }, ref): JSX.Element => (
<DropdownMenu.Separator
ref={ref}
className={cs(styles.separator, className)}
{...props}
/>
),
)
DropdownSeparator.displayName = "DropdownSeparator"
export const DropdownSub = ({
children,
...props
}: IDropdownSubProps): JSX.Element => (
<DropdownMenu.Sub {...props}>{children}</DropdownMenu.Sub>
)
export const DropdownSubTrigger = forwardRef<
HTMLDivElement,
IDropdownSubTriggerProps
>(
({ children, className, ...props }, ref): JSX.Element => (
<DropdownMenu.SubTrigger
ref={ref}
className={cs(styles.subTrigger, className)}
{...props}
>
{children}
</DropdownMenu.SubTrigger>
),
)
DropdownSubTrigger.displayName = "DropdownSubTrigger"
export const DropdownSubContent = forwardRef<
HTMLDivElement,
IDropdownSubContentProps
>(
({ children, className, sideOffset = 2, ...props }, ref): JSX.Element => (
<DropdownMenu.Portal>
<DropdownMenu.SubContent
ref={ref}
className={cs(styles.subContent, className)}
sideOffset={sideOffset}
{...props}
>
{children}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
),
)
DropdownSubContent.displayName = "DropdownSubContent"
+5
View File
@@ -0,0 +1,5 @@
export interface ILoaderProps {
fullscreen?: boolean;
block?: boolean;
description?: string;
}
+127
View File
@@ -0,0 +1,127 @@
.root {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
width: 100%;
height: 100%;
pointer-events: none;
&.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw !important;
height: 100vh !important;
z-index: 9999;
background-color: variables.$bg-default;
}
&.block {
width: 100%;
height: 100%;
}
@include breakpoints.respond-to(breakpoints.$mobileMax) {
width: 100%;
height: 100%;
}
}
.container {
position: relative;
width: 72px;
height: 72px;
}
.loader {
position: absolute;
top: 0;
left: 0;
border: 2px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 64px;
height: 64px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 1s linear infinite;
&.blue {
border: 4px solid color-mix(in srgb, variables.$color-secondary 50%, transparent);
border-left-color: transparent;
border-top-color: transparent;
animation: spin 2s linear infinite;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.description {
@include typography.font-display;
color: variables.$text-primary;
text-align: center;
&.in {
animation: fadeIn 150ms ease-in-out;
}
&.out {
animation: fadeOut 150ms ease-in-out;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(5px);
}
}
.minLoader {
border: 4px solid color-mix(in srgb, variables.$color-primary 50%, transparent);
width: 40px;
height: 40px;
border-radius: 50%;
border-left-color: transparent;
border-top-color: transparent;
animation: spin 0.5s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { JSX } from "react"
import { FunctionComponent, memo } from "react"
import cs from "classnames"
import { ILoaderProps } from "./Loader.d"
import styles from "./Loader.module.scss"
export const StaticLoader: FunctionComponent<ILoaderProps> = memo(
({ fullscreen = false, block = false, description }): JSX.Element => {
if (!fullscreen && !block) {
return (
<div className={styles.root}>
<div className={styles.minLoader} />
</div>
)
}
return (
<div
className={cs(styles.root, {
[styles.fullscreen]: fullscreen,
[styles.block]: block,
})}
>
<div className={styles.container}>
<div className={styles.loader} />
<div className={cs(styles.loader, styles.blue)} />
</div>
<h4 className={styles.description}>{description || "Загрузка"}</h4>
</div>
)
},
)
+1
View File
@@ -0,0 +1 @@
export * from "./Loader"
+8 -2
View File
@@ -1,10 +1,16 @@
import type { TextField } from "@radix-ui/themes"
import type { ComponentProps, ReactNode } from "react"
export interface ITextFieldProps
extends Omit<ComponentProps<typeof TextField.Root>, "color"> {
type TextFieldRootProps = Omit<
ComponentProps<typeof TextField.Root>,
"children"
>
export interface ITextFieldProps extends TextFieldRootProps {
id: string
label?: ReactNode
undertitle?: ReactNode
error?: boolean
startIcon?: ReactNode
endIcon?: ReactNode
}
+36 -7
View File
@@ -3,11 +3,29 @@
import type { ITextFieldProps } from "../model/TextField.d"
import type { JSX } from "react"
import { Text, TextField as RadixTextField } from "@radix-ui/themes"
import { forwardRef } from "react"
import { TextField as RadixTextField, Text } from "@radix-ui/themes"
export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
({ id, label, undertitle, error, size = "2", ...props }, ref): JSX.Element => (
(
{
id,
label,
undertitle,
error,
size = "2",
variant,
radius,
startIcon,
endIcon,
className,
style,
color,
...rootProps
},
ref,
): JSX.Element => (
<label htmlFor={id}>
{label && (
<Text as="div" size="2" mb="1" weight="medium">
@@ -15,14 +33,25 @@ export const TextField = forwardRef<HTMLInputElement, ITextFieldProps>(
</Text>
)}
<RadixTextField.Root
id={id}
ref={ref}
size={size}
color={error ? "red" : undefined}
variant={variant}
radius={radius}
color={error ? "red" : color}
className={className}
style={style}
ref={ref}
aria-describedby={undertitle ? `${id}-undertitle` : undefined}
aria-invalid={error}
{...props}
/>
id={id}
{...rootProps}
>
{startIcon && (
<RadixTextField.Slot side="left">{startIcon}</RadixTextField.Slot>
)}
{endIcon && (
<RadixTextField.Slot side="right">{endIcon}</RadixTextField.Slot>
)}
</RadixTextField.Root>
{undertitle && (
<Text as="p" size="1" color="gray" mt="1" id={`${id}-undertitle`}>
{undertitle}
+1
View File
@@ -3,6 +3,7 @@ export * from "./Badge"
export * from "./Button"
export * from "./Card"
export * from "./Checkbox"
export * from "./CircularProgress"
export * from "./Form"
export * from "./TextField"
export * from "./Modal"
+3
View File
@@ -1,6 +1,9 @@
.root {
padding: 12px 24px;
background-color: variables.$color-white;
display: flex;
justify-content: space-between;
align-items: center;
}
.screenPath {
+36 -12
View File
@@ -1,26 +1,50 @@
"use client"
import type { JSX } from "react"
import { Menu as MenuIcon } from "lucide-react"
import { FunctionComponent } from "react"
import { FunctionComponent, useState } from "react"
import { NavigationDrawer } from "@entities/NavigationDrawer"
import { UserDropdown } from "@entities/UserDropdown"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button } from "@shared/ui"
import { IHeaderProps } from "../model/Header.d"
import styles from "./Header.module.scss"
export const Header: FunctionComponent<IHeaderProps> = (): JSX.Element => {
const userData = useAppSelector((state) => state.user.user)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
return (
<div className={styles.root} data-testid="Header">
<div className={styles.start}>
<Button variant="icon">
<MenuIcon size={24} />
</Button>
<div className={styles.screenPath}>
<h1 className={styles.brandTitle}>Coffee Project</h1>
<span className={styles.separator}>/</span>
<h3 className={styles.screenName}>Projects</h3>
<>
<header className={styles.root} data-testid="Header">
<div className={styles.start}>
<Button variant="icon" onClick={() => setIsDrawerOpen(true)}>
<MenuIcon size={24} />
</Button>
<div className={styles.screenPath}>
<h1 className={styles.brandTitle}>Coffee Project</h1>
<span className={styles.separator}>/</span>
<h3 className={styles.screenName}>Projects</h3>
</div>
</div>
</div>
</div>
<div className={styles.end}>
<UserDropdown user={userData} />
</div>
</header>
<NavigationDrawer
open={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
buttons={[
{
label: "Проекты",
path: "/projects",
},
]}
/>
</>
)
}
@@ -0,0 +1 @@
export * from "./ui/ProjectsHeader"
@@ -0,0 +1,3 @@
export interface IProjectsHeaderProps {
message?: string
}
@@ -0,0 +1,17 @@
.root {
display: flex;
gap: 16px;
align-items: center;
}
.separator {
display: flex;
height: 100%;
min-height: 24px;
}
.filters {
display: flex;
gap: 8px;
}
@@ -0,0 +1,38 @@
import type { JSX } from "react"
import { SearchIcon } from "lucide-react"
import { FunctionComponent } from "react"
import { Separator } from "@radix-ui/themes"
import { Button, TextField } from "@shared/ui"
import { IProjectsHeaderProps } from "../model/ProjectsHeader.d"
import styles from "./ProjectsHeader.module.scss"
export const ProjectsHeader: FunctionComponent<
IProjectsHeaderProps
> = (): JSX.Element => {
return (
<div className={styles.root} data-testid="ProjectsHeader">
<TextField
id="project-search-field"
placeholder="Поиск проектов..."
size={"2"}
startIcon={<SearchIcon size={16} />}
/>
<Separator orientation={"vertical"} className={styles.separator} />
<div className={styles.filters}>
<Button variant="outline" size="md">
Все
</Button>
<Button variant="outline" size="md">
В обработке
</Button>
<Button variant="outline" size="md">
Завершенные
</Button>
</div>
</div>
)
}