diff --git a/.scripts/create-fsd-component.ts b/.scripts/create-fsd-component.ts index 02f69fd..08b60fa 100644 --- a/.scripts/create-fsd-component.ts +++ b/.scripts/create-fsd-component.ts @@ -49,17 +49,22 @@ if (layer === "shared") { const fsdCommand = `fsd ${layer} ${component} ${otherArgs} --segments ${segments} -r src` -try { - runShell(fsdCommand) -} catch (error) { - console.error(`Ошибка выполнения команды: ${(error as Error).message}`) - process.exit(1) +if (layer !== "shared") { + try { + runShell(fsdCommand) + } catch (error) { + console.error(`Ошибка выполнения команды: ${(error as Error).message}`) + process.exit(1) + } +} else { + await mkdir(componentPath, { recursive: true }) + console.log("Пропущен fsd для shared: создаем компонент вручную") } console.log(`Удаление индекс файла: ${componentPath}/index.ts`) try { - await rm(`${componentPath}/index.ts`) + await rm(`${componentPath}/index.ts`, { force: true }) } catch (error) { console.error(`Ошибка выполнения команды: ${(error as Error).message}`) process.exit(1) diff --git a/app/(protected)/projects/page.tsx b/app/(protected)/projects/page.tsx new file mode 100644 index 0000000..dc2825f --- /dev/null +++ b/app/(protected)/projects/page.tsx @@ -0,0 +1,11 @@ +import { JSX } from "react" + +import { ProjectsPage } from "@pages/ProjectsPage" + +export default function Projects(): JSX.Element { + return ( + + + + ) +} diff --git a/src/entities/NavigationDrawer/index.ts b/src/entities/NavigationDrawer/index.ts new file mode 100644 index 0000000..0e328b9 --- /dev/null +++ b/src/entities/NavigationDrawer/index.ts @@ -0,0 +1 @@ +export * from "./ui/NavigationDrawer" diff --git a/src/entities/NavigationDrawer/model/NavigationDrawer.d.ts b/src/entities/NavigationDrawer/model/NavigationDrawer.d.ts new file mode 100644 index 0000000..53e6f82 --- /dev/null +++ b/src/entities/NavigationDrawer/model/NavigationDrawer.d.ts @@ -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 +} diff --git a/src/entities/NavigationDrawer/ui/NavigationDrawer.module.scss b/src/entities/NavigationDrawer/ui/NavigationDrawer.module.scss new file mode 100644 index 0000000..94b74c7 --- /dev/null +++ b/src/entities/NavigationDrawer/ui/NavigationDrawer.module.scss @@ -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; +} diff --git a/src/entities/NavigationDrawer/ui/NavigationDrawer.tsx b/src/entities/NavigationDrawer/ui/NavigationDrawer.tsx new file mode 100644 index 0000000..2e16c6c --- /dev/null +++ b/src/entities/NavigationDrawer/ui/NavigationDrawer.tsx @@ -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 = ({ + buttons, + className, + open, + onClose, + position = "left", + size = 280, + title, +}): JSX.Element => { + return ( + + + {title ? {title} : null} + + {buttons.map(({ label, icon: Icon, path, action }, index) => { + const key = `${label}-${path ?? index}` + + const content = ( + <> + {Icon ? : null} + {label} + > + ) + + const handleClick = (): void => { + action?.() + onClose() + } + + return ( + + {path ? ( + + {content} + + ) : ( + + {content} + + )} + + ) + })} + + + + ) +} diff --git a/src/entities/ProjectCard/index.ts b/src/entities/ProjectCard/index.ts new file mode 100644 index 0000000..ed01eac --- /dev/null +++ b/src/entities/ProjectCard/index.ts @@ -0,0 +1 @@ +export * from "./ui/ProjectCard" diff --git a/src/entities/ProjectCard/model/ProjectCard.d.ts b/src/entities/ProjectCard/model/ProjectCard.d.ts new file mode 100644 index 0000000..21f2272 --- /dev/null +++ b/src/entities/ProjectCard/model/ProjectCard.d.ts @@ -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 +} diff --git a/src/entities/ProjectCard/ui/ProjectCard.module.scss b/src/entities/ProjectCard/ui/ProjectCard.module.scss new file mode 100644 index 0000000..4eabd05 --- /dev/null +++ b/src/entities/ProjectCard/ui/ProjectCard.module.scss @@ -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; +} diff --git a/src/entities/ProjectCard/ui/ProjectCard.tsx b/src/entities/ProjectCard/ui/ProjectCard.tsx new file mode 100644 index 0000000..a978b3e --- /dev/null +++ b/src/entities/ProjectCard/ui/ProjectCard.tsx @@ -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 = ({ + 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 ( + + + {imageUrl ? ( + + ) : ( + + + + )} + + + + + {getStatusLabel()} + + + + {shouldShowProgress && ( + + + + {Math.round(progress)}% + + {displayAction && ( + + {displayAction} + + )} + + )} + + + + + + + {name} + + e.stopPropagation()} + > + + + + + + + + console.log("Edit", project.id)} + > + Изменить + + console.log("Rename", project.id)} + > + Переименовать + + console.log("Delete", project.id)} + > + Удалить + + + + + + + Создано {moment(updated_at).fromNow()} + + + + + ) +} diff --git a/src/entities/UserDropdown/index.ts b/src/entities/UserDropdown/index.ts new file mode 100644 index 0000000..36ed88a --- /dev/null +++ b/src/entities/UserDropdown/index.ts @@ -0,0 +1 @@ +export * from "./ui/UserDropdown" diff --git a/src/entities/UserDropdown/model/UserDropdown.d.ts b/src/entities/UserDropdown/model/UserDropdown.d.ts new file mode 100644 index 0000000..447f0f7 --- /dev/null +++ b/src/entities/UserDropdown/model/UserDropdown.d.ts @@ -0,0 +1,5 @@ +import { UserEntity } from "@shared/store/user/types" + +export interface IUserDropdownProps { + user: UserEntity | null +} diff --git a/src/entities/UserDropdown/model/constants.ts b/src/entities/UserDropdown/model/constants.ts new file mode 100644 index 0000000..e5e3fce --- /dev/null +++ b/src/entities/UserDropdown/model/constants.ts @@ -0,0 +1,16 @@ +export const userDropdownValues = [ + { + label: "Профиль", + acton: "profile", + path: "/profile", + }, + { + label: "Настройки", + acton: "settings", + path: "/settings", + }, + { + label: "Выйти", + acton: "logout", + }, +] diff --git a/src/entities/UserDropdown/ui/UserDropdown.module.scss b/src/entities/UserDropdown/ui/UserDropdown.module.scss new file mode 100644 index 0000000..ebe6dc1 --- /dev/null +++ b/src/entities/UserDropdown/ui/UserDropdown.module.scss @@ -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 ); + } +} \ No newline at end of file diff --git a/src/entities/UserDropdown/ui/UserDropdown.tsx b/src/entities/UserDropdown/ui/UserDropdown.tsx new file mode 100644 index 0000000..f43e03a --- /dev/null +++ b/src/entities/UserDropdown/ui/UserDropdown.tsx @@ -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 = ({ + user, +}): JSX.Element => { + return ( + + + + + + {user?.username} + + + + {userDropdownValues.map((item) => ( + console.log(`${item.acton} selected`)} + > + {item.label} + + ))} + + + + ) +} diff --git a/src/features/CreateProjectModal/api/useCreateProject.ts b/src/features/CreateProjectModal/api/useCreateProject.ts new file mode 100644 index 0000000..1e1df2a --- /dev/null +++ b/src/features/CreateProjectModal/api/useCreateProject.ts @@ -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) + }, + }) +} diff --git a/src/features/CreateProjectModal/index.ts b/src/features/CreateProjectModal/index.ts new file mode 100644 index 0000000..c740fb6 --- /dev/null +++ b/src/features/CreateProjectModal/index.ts @@ -0,0 +1,3 @@ +export { CreateProjectModal } from "./ui/CreateProjectModal" + +export type { ICreateProjectModalProps } from "./model/CreateProjectModal.d" diff --git a/src/features/CreateProjectModal/model/CreateProjectModal.d.ts b/src/features/CreateProjectModal/model/CreateProjectModal.d.ts new file mode 100644 index 0000000..2a5ba0a --- /dev/null +++ b/src/features/CreateProjectModal/model/CreateProjectModal.d.ts @@ -0,0 +1,9 @@ +import type { Dialog } from "@radix-ui/themes" +import type { ComponentProps } from "react" + +export interface ICreateProjectModalProps extends Pick< + ComponentProps, + "open" | "onOpenChange" +> { + onCreated?: () => void | Promise +} diff --git a/src/features/CreateProjectModal/ui/CreateProjectModal.module.scss b/src/features/CreateProjectModal/ui/CreateProjectModal.module.scss new file mode 100644 index 0000000..2ce7526 --- /dev/null +++ b/src/features/CreateProjectModal/ui/CreateProjectModal.module.scss @@ -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; +} diff --git a/src/features/CreateProjectModal/ui/CreateProjectModal.tsx b/src/features/CreateProjectModal/ui/CreateProjectModal.tsx new file mode 100644 index 0000000..a2fc163 --- /dev/null +++ b/src/features/CreateProjectModal/ui/CreateProjectModal.tsx @@ -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({ + 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 ( + + + + + + v.trim().length > 0 || "Введите название проекта", + })} + /> + + + + + + + Язык + ( + + {LANGUAGE_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + )} + /> + + + + Статус + ( + + {STATUS_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + )} + /> + + + + + onOpenChange?.(false)} + > + Отмена + + + Создать + + + + + + ) +} diff --git a/src/pages/LoginPage/ui/LoginPage.tsx b/src/pages/LoginPage/ui/LoginPage.tsx index 14f7e55..62e9b3a 100644 --- a/src/pages/LoginPage/ui/LoginPage.tsx +++ b/src/pages/LoginPage/ui/LoginPage.tsx @@ -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) diff --git a/src/pages/ProjectsPage/index.ts b/src/pages/ProjectsPage/index.ts new file mode 100644 index 0000000..06d9f88 --- /dev/null +++ b/src/pages/ProjectsPage/index.ts @@ -0,0 +1 @@ +export * from "./ui/ProjectsPage" diff --git a/src/pages/ProjectsPage/model/ProjectsPage.d.ts b/src/pages/ProjectsPage/model/ProjectsPage.d.ts new file mode 100644 index 0000000..7c070c2 --- /dev/null +++ b/src/pages/ProjectsPage/model/ProjectsPage.d.ts @@ -0,0 +1,3 @@ +export interface IProjectsPageProps { + message?: string +} diff --git a/src/pages/ProjectsPage/ui/ProjectsPage.module.scss b/src/pages/ProjectsPage/ui/ProjectsPage.module.scss new file mode 100644 index 0000000..a160330 --- /dev/null +++ b/src/pages/ProjectsPage/ui/ProjectsPage.module.scss @@ -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; +} diff --git a/src/pages/ProjectsPage/ui/ProjectsPage.tsx b/src/pages/ProjectsPage/ui/ProjectsPage.tsx new file mode 100644 index 0000000..208fafd --- /dev/null +++ b/src/pages/ProjectsPage/ui/ProjectsPage.tsx @@ -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 ( + + {projectsLoading && } + + + Мои проекты + + Управляйте своими последними проектами + + + + setIsCreateModalOpen(true)} + > + Создать проект + + + + + { + await refetchProjects() + }} + /> + + + + + {projects?.map((project) => ( + + ))} + + + ) +} diff --git a/src/shared/api/server.ts b/src/shared/api/server.ts index d490f6c..f320428 100644 --- a/src/shared/api/server.ts +++ b/src/shared/api/server.ts @@ -21,6 +21,7 @@ export const verifyToken = async (token: string): Promise => { }, }) console.log("Verify token response:", resp) + if (resp.error) return false return true } catch (error) { console.error("Verify token error:", error) diff --git a/src/shared/assets/placeholder.png b/src/shared/assets/placeholder.png new file mode 100644 index 0000000..8e4e617 Binary files /dev/null and b/src/shared/assets/placeholder.png differ diff --git a/src/shared/context/AppProviders.tsx b/src/shared/context/AppProviders.tsx index 9c00650..fe77477 100644 --- a/src/shared/context/AppProviders.tsx +++ b/src/shared/context/AppProviders.tsx @@ -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 ( + {children} diff --git a/src/shared/context/UserSync.tsx b/src/shared/context/UserSync.tsx new file mode 100644 index 0000000..f7ec26e --- /dev/null +++ b/src/shared/context/UserSync.tsx @@ -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(undefined) + + useEffect(() => { + userRef.current = user + }, [user]) + + useEffect(() => { + let isCancelled = false + + const syncUserWithToken = async (): Promise => { + 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 +} diff --git a/src/shared/hooks/useAppDispatch.ts b/src/shared/hooks/useAppDispatch.ts index 94f55ad..c23ece4 100644 --- a/src/shared/hooks/useAppDispatch.ts +++ b/src/shared/hooks/useAppDispatch.ts @@ -2,4 +2,4 @@ import type { AppDispatch } from "@shared/store" import { useDispatch } from "react-redux" -export const useAppDispatch = useDispatch.withTypes() +export const useAppDispatch = (): AppDispatch => useDispatch() diff --git a/src/shared/hooks/useAppSelector.ts b/src/shared/hooks/useAppSelector.ts index b134eb3..9fbfe8a 100644 --- a/src/shared/hooks/useAppSelector.ts +++ b/src/shared/hooks/useAppSelector.ts @@ -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() +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/src/shared/store/appStateStore.ts b/src/shared/store/appState/index.ts similarity index 91% rename from src/shared/store/appStateStore.ts rename to src/shared/store/appState/index.ts index 35d6bdc..4e06f4e 100644 --- a/src/shared/store/appStateStore.ts +++ b/src/shared/store/appState/index.ts @@ -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: "", diff --git a/src/shared/store/appState/types.ts b/src/shared/store/appState/types.ts new file mode 100644 index 0000000..9c30228 --- /dev/null +++ b/src/shared/store/appState/types.ts @@ -0,0 +1,3 @@ +export interface AppState { + currentScreenName: string +} diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 061bcf9..7a776c2 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -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: { diff --git a/src/shared/store/userStore.ts b/src/shared/store/user/index.ts similarity index 77% rename from src/shared/store/userStore.ts rename to src/shared/store/user/index.ts index bc386ef..22e8ada 100644 --- a/src/shared/store/userStore.ts +++ b/src/shared/store/user/index.ts @@ -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, diff --git a/src/shared/store/user/types.ts b/src/shared/store/user/types.ts new file mode 100644 index 0000000..625d7ca --- /dev/null +++ b/src/shared/store/user/types.ts @@ -0,0 +1,7 @@ +import { components } from "@shared/api/__generated__/openapi.types" + +export type UserEntity = components["schemas"]["UserRead"] + +export interface UserState { + user: UserEntity | null +} diff --git a/src/shared/styles/_mixins.scss b/src/shared/styles/_mixins.scss index ac892ac..8a0fd44 100644 --- a/src/shared/styles/_mixins.scss +++ b/src/shared/styles/_mixins.scss @@ -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); +} \ No newline at end of file diff --git a/src/shared/styles/_variables.scss b/src/shared/styles/_variables.scss index 0f4bbee..42195ca 100644 --- a/src/shared/styles/_variables.scss +++ b/src/shared/styles/_variables.scss @@ -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); \ No newline at end of file diff --git a/src/shared/styles/global.scss b/src/shared/styles/global.scss index 48dd043..2e048ef 100644 --- a/src/shared/styles/global.scss +++ b/src/shared/styles/global.scss @@ -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; } diff --git a/src/shared/ui/Avatar/index.ts b/src/shared/ui/Avatar/index.ts new file mode 100644 index 0000000..58d576c --- /dev/null +++ b/src/shared/ui/Avatar/index.ts @@ -0,0 +1 @@ +export * from "./ui/Avatar" diff --git a/src/shared/ui/Avatar/model/Avatar.d.ts b/src/shared/ui/Avatar/model/Avatar.d.ts new file mode 100644 index 0000000..00c42f1 --- /dev/null +++ b/src/shared/ui/Avatar/model/Avatar.d.ts @@ -0,0 +1,6 @@ +export interface IAvatarProps { + size?: "xxxlarge" | "xxlarge" | "xlarge" | "large" | "medium" | "small" + url: string + active?: boolean + variant?: "circle" | "square" +} diff --git a/src/shared/ui/Avatar/ui/Avatar.module.scss b/src/shared/ui/Avatar/ui/Avatar.module.scss new file mode 100644 index 0000000..6b0fa58 --- /dev/null +++ b/src/shared/ui/Avatar/ui/Avatar.module.scss @@ -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; + } +} diff --git a/src/shared/ui/Avatar/ui/Avatar.tsx b/src/shared/ui/Avatar/ui/Avatar.tsx new file mode 100644 index 0000000..6d9e5bb --- /dev/null +++ b/src/shared/ui/Avatar/ui/Avatar.tsx @@ -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 = memo( + ({ + size = "large", + variant = "circle", + url, + active = false, + }): JSX.Element => { + const [loaded, setLoaded] = useState(false) + const [imgURL, setImgURL] = useState(url || avatarPlaceholder.src) + return ( + + {url ? ( + <> + { + e.preventDefault() + setImgURL(avatarPlaceholder.src) + }} + onLoad={() => setLoaded(true)} + alt="avatar" + src={imgURL} + {...avatarProperties[size]} + /> + + > + ) : ( + + )} + + ) + }, +) diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx index 1a6047b..7eb5e82 100644 --- a/src/shared/ui/Button/ui/Button.tsx +++ b/src/shared/ui/Button/ui/Button.tsx @@ -40,6 +40,7 @@ export const Button = forwardRef( size={radixSize} variant={visual.variant} color={visual.color} + style={{ cursor: "pointer", ...props.style }} {...props} > {children} @@ -53,6 +54,7 @@ export const Button = forwardRef( size={radixSize} variant={visual.variant} color={visual.color} + style={{ cursor: "pointer", ...props.style }} {...props} > {children} diff --git a/src/shared/ui/CircularProgress/index.ts b/src/shared/ui/CircularProgress/index.ts new file mode 100644 index 0000000..7c66120 --- /dev/null +++ b/src/shared/ui/CircularProgress/index.ts @@ -0,0 +1 @@ +export * from "./ui/CircularProgress" diff --git a/src/shared/ui/CircularProgress/model/CircularProgress.d.ts b/src/shared/ui/CircularProgress/model/CircularProgress.d.ts new file mode 100644 index 0000000..932716a --- /dev/null +++ b/src/shared/ui/CircularProgress/model/CircularProgress.d.ts @@ -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 +} diff --git a/src/shared/ui/CircularProgress/ui/CircularProgress.module.scss b/src/shared/ui/CircularProgress/ui/CircularProgress.module.scss new file mode 100644 index 0000000..4e0f8cb --- /dev/null +++ b/src/shared/ui/CircularProgress/ui/CircularProgress.module.scss @@ -0,0 +1,3 @@ +.root { + display: block; +} diff --git a/src/shared/ui/CircularProgress/ui/CircularProgress.tsx b/src/shared/ui/CircularProgress/ui/CircularProgress.tsx new file mode 100644 index 0000000..12eddd2 --- /dev/null +++ b/src/shared/ui/CircularProgress/ui/CircularProgress.tsx @@ -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 = ({ + 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 ( + + + + + ) +} diff --git a/src/shared/ui/Dropdown/index.ts b/src/shared/ui/Dropdown/index.ts new file mode 100644 index 0000000..145a56d --- /dev/null +++ b/src/shared/ui/Dropdown/index.ts @@ -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" diff --git a/src/shared/ui/Dropdown/model/Dropdown.d.ts b/src/shared/ui/Dropdown/model/Dropdown.d.ts new file mode 100644 index 0000000..15790a2 --- /dev/null +++ b/src/shared/ui/Dropdown/model/Dropdown.d.ts @@ -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 +} diff --git a/src/shared/ui/Dropdown/ui/Dropdown.module.scss b/src/shared/ui/Dropdown/ui/Dropdown.module.scss new file mode 100644 index 0000000..8f99ebb --- /dev/null +++ b/src/shared/ui/Dropdown/ui/Dropdown.module.scss @@ -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); + } +} diff --git a/src/shared/ui/Dropdown/ui/Dropdown.tsx b/src/shared/ui/Dropdown/ui/Dropdown.tsx new file mode 100644 index 0000000..a981824 --- /dev/null +++ b/src/shared/ui/Dropdown/ui/Dropdown.tsx @@ -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 => ( + {children} +) + +export const DropdownTrigger = forwardRef< + HTMLButtonElement, + IDropdownTriggerProps +>( + ({ children, className, asChild = false, ...props }, ref): JSX.Element => ( + + {children} + + ), +) +DropdownTrigger.displayName = "DropdownTrigger" + +export const DropdownContent = forwardRef< + HTMLDivElement, + IDropdownContentProps +>( + ( + { children, className, sideOffset = 4, align = "end", ...props }, + ref, + ): JSX.Element => ( + + + {children} + + + ), +) +DropdownContent.displayName = "DropdownContent" + +export const DropdownItem = forwardRef( + ({ children, className, ...props }, ref): JSX.Element => ( + + {children} + + ), +) +DropdownItem.displayName = "DropdownItem" + +export const DropdownCheckboxItem = forwardRef< + HTMLDivElement, + IDropdownCheckboxItemProps +>( + ({ children, className, ...props }, ref): JSX.Element => ( + + + ✓ + + {children} + + ), +) +DropdownCheckboxItem.displayName = "DropdownCheckboxItem" + +export const DropdownRadioGroup = ({ + children, + ...props +}: IDropdownRadioGroupProps): JSX.Element => ( + {children} +) + +export const DropdownRadioItem = forwardRef< + HTMLDivElement, + IDropdownRadioItemProps +>( + ({ children, className, ...props }, ref): JSX.Element => ( + + + ● + + {children} + + ), +) +DropdownRadioItem.displayName = "DropdownRadioItem" + +export const DropdownLabel = forwardRef( + ({ children, className, ...props }, ref): JSX.Element => ( + + {children} + + ), +) +DropdownLabel.displayName = "DropdownLabel" + +export const DropdownSeparator = forwardRef< + HTMLDivElement, + IDropdownSeparatorProps +>( + ({ className, ...props }, ref): JSX.Element => ( + + ), +) +DropdownSeparator.displayName = "DropdownSeparator" + +export const DropdownSub = ({ + children, + ...props +}: IDropdownSubProps): JSX.Element => ( + {children} +) + +export const DropdownSubTrigger = forwardRef< + HTMLDivElement, + IDropdownSubTriggerProps +>( + ({ children, className, ...props }, ref): JSX.Element => ( + + {children} + + ), +) +DropdownSubTrigger.displayName = "DropdownSubTrigger" + +export const DropdownSubContent = forwardRef< + HTMLDivElement, + IDropdownSubContentProps +>( + ({ children, className, sideOffset = 2, ...props }, ref): JSX.Element => ( + + + {children} + + + ), +) +DropdownSubContent.displayName = "DropdownSubContent" diff --git a/src/shared/ui/Loader/Loader.d.ts b/src/shared/ui/Loader/Loader.d.ts new file mode 100644 index 0000000..8da3305 --- /dev/null +++ b/src/shared/ui/Loader/Loader.d.ts @@ -0,0 +1,5 @@ +export interface ILoaderProps { + fullscreen?: boolean; + block?: boolean; + description?: string; +} diff --git a/src/shared/ui/Loader/Loader.module.scss b/src/shared/ui/Loader/Loader.module.scss new file mode 100644 index 0000000..3f05987 --- /dev/null +++ b/src/shared/ui/Loader/Loader.module.scss @@ -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); + + } +} diff --git a/src/shared/ui/Loader/Loader.tsx b/src/shared/ui/Loader/Loader.tsx new file mode 100644 index 0000000..7e4346d --- /dev/null +++ b/src/shared/ui/Loader/Loader.tsx @@ -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 = memo( + ({ fullscreen = false, block = false, description }): JSX.Element => { + if (!fullscreen && !block) { + return ( + + + + ) + } + return ( + + + + + + {description || "Загрузка"} + + ) + }, +) diff --git a/src/shared/ui/Loader/index.ts b/src/shared/ui/Loader/index.ts new file mode 100644 index 0000000..80aacd0 --- /dev/null +++ b/src/shared/ui/Loader/index.ts @@ -0,0 +1 @@ +export * from "./Loader" diff --git a/src/shared/ui/TextField/model/TextField.d.ts b/src/shared/ui/TextField/model/TextField.d.ts index b77707e..73a609d 100644 --- a/src/shared/ui/TextField/model/TextField.d.ts +++ b/src/shared/ui/TextField/model/TextField.d.ts @@ -1,10 +1,16 @@ import type { TextField } from "@radix-ui/themes" import type { ComponentProps, ReactNode } from "react" -export interface ITextFieldProps - extends Omit, "color"> { +type TextFieldRootProps = Omit< + ComponentProps, + "children" +> + +export interface ITextFieldProps extends TextFieldRootProps { id: string label?: ReactNode undertitle?: ReactNode error?: boolean + startIcon?: ReactNode + endIcon?: ReactNode } diff --git a/src/shared/ui/TextField/ui/TextField.tsx b/src/shared/ui/TextField/ui/TextField.tsx index 649c33a..e39828d 100644 --- a/src/shared/ui/TextField/ui/TextField.tsx +++ b/src/shared/ui/TextField/ui/TextField.tsx @@ -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( - ({ 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 && ( @@ -15,14 +33,25 @@ export const TextField = forwardRef( )} + id={id} + {...rootProps} + > + {startIcon && ( + {startIcon} + )} + {endIcon && ( + {endIcon} + )} + {undertitle && ( {undertitle} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 6ce798c..11db32a 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -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" diff --git a/src/widgets/Header/ui/Header.module.scss b/src/widgets/Header/ui/Header.module.scss index 6f60394..88c1ac0 100644 --- a/src/widgets/Header/ui/Header.module.scss +++ b/src/widgets/Header/ui/Header.module.scss @@ -1,6 +1,9 @@ .root { padding: 12px 24px; background-color: variables.$color-white; + display: flex; + justify-content: space-between; + align-items: center; } .screenPath { diff --git a/src/widgets/Header/ui/Header.tsx b/src/widgets/Header/ui/Header.tsx index d5c0ed6..2b7d125 100644 --- a/src/widgets/Header/ui/Header.tsx +++ b/src/widgets/Header/ui/Header.tsx @@ -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 = (): JSX.Element => { + const userData = useAppSelector((state) => state.user.user) + + const [isDrawerOpen, setIsDrawerOpen] = useState(false) + return ( - - - - - - - Coffee Project - / - Projects + <> + + + setIsDrawerOpen(true)}> + + + + Coffee Project + / + Projects + - - + + + + + setIsDrawerOpen(false)} + buttons={[ + { + label: "Проекты", + path: "/projects", + }, + ]} + /> + > ) } diff --git a/src/widgets/Projects/ProjectsHeader/index.ts b/src/widgets/Projects/ProjectsHeader/index.ts new file mode 100644 index 0000000..567ff7a --- /dev/null +++ b/src/widgets/Projects/ProjectsHeader/index.ts @@ -0,0 +1 @@ +export * from "./ui/ProjectsHeader" diff --git a/src/widgets/Projects/ProjectsHeader/model/ProjectsHeader.d.ts b/src/widgets/Projects/ProjectsHeader/model/ProjectsHeader.d.ts new file mode 100644 index 0000000..f97acbe --- /dev/null +++ b/src/widgets/Projects/ProjectsHeader/model/ProjectsHeader.d.ts @@ -0,0 +1,3 @@ +export interface IProjectsHeaderProps { + message?: string +} diff --git a/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.module.scss b/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.module.scss new file mode 100644 index 0000000..648d896 --- /dev/null +++ b/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.module.scss @@ -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; +} \ No newline at end of file diff --git a/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.tsx b/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.tsx new file mode 100644 index 0000000..bf994c9 --- /dev/null +++ b/src/widgets/Projects/ProjectsHeader/ui/ProjectsHeader.tsx @@ -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 ( + + } + /> + + + + Все + + + В обработке + + + Завершенные + + + + ) +}