├── .prettierrc.json ├── src ├── features │ ├── auth │ │ ├── index.tsx │ │ ├── login.page.tsx │ │ ├── register.page.tsx │ │ ├── model │ │ │ ├── use-login.ts │ │ │ └── use-register.ts │ │ └── ui │ │ │ ├── auth-layout.tsx │ │ │ ├── login-form.tsx │ │ │ └── register-form.tsx │ ├── board-templates │ │ ├── index.ts │ │ ├── use-templates-modal.ts │ │ ├── templates-modal.tsx │ │ ├── templates-gallery.tsx │ │ └── template-card.tsx │ ├── board │ │ └── board.page.tsx │ ├── boards-list │ │ ├── ui │ │ │ ├── boards-search-input.tsx │ │ │ ├── view-mode-toggle.tsx │ │ │ ├── boards-favorite-toggle.tsx │ │ │ ├── boards-sort-select.tsx │ │ │ ├── boards-sidebar.tsx │ │ │ ├── boards-list-card.tsx │ │ │ ├── boards-list-item.tsx │ │ │ └── boards-list-layout.tsx │ │ ├── model │ │ │ ├── use-boards-filters.ts │ │ │ ├── use-delete-board.tsx │ │ │ ├── use-create-board.ts │ │ │ ├── use-update-favorite.ts │ │ │ ├── use-recent-groups.ts │ │ │ └── use-boards-list.tsx │ │ ├── compose │ │ │ ├── board-card.tsx │ │ │ └── board-item.tsx │ │ ├── boards-list-favorite.page.tsx │ │ ├── boards-list-recent.page.tsx │ │ └── boards-list.page.tsx │ └── header │ │ └── index.tsx ├── shared │ ├── model │ │ ├── config.ts │ │ ├── routes.ts │ │ └── session.ts │ ├── api │ │ ├── query-client.ts │ │ ├── schema │ │ │ ├── index.ts │ │ │ ├── shared │ │ │ │ └── responses.yaml │ │ │ ├── main.yaml │ │ │ ├── endpoints │ │ │ │ ├── auth.yaml │ │ │ │ └── boards.yaml │ │ │ └── generated.ts │ │ ├── mocks │ │ │ ├── index.ts │ │ │ ├── browser.ts │ │ │ ├── http.ts │ │ │ ├── session.ts │ │ │ └── handlers │ │ │ │ ├── auth.ts │ │ │ │ └── boards.ts │ │ └── instance.ts │ ├── lib │ │ ├── css.ts │ │ └── react.ts │ ├── env.d.ts │ └── ui │ │ └── kit │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── switch.tsx │ │ ├── scroll-area.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx └── app │ ├── app.tsx │ ├── providers.tsx │ ├── main.tsx │ ├── protected-route.tsx │ ├── router.tsx │ └── index.css ├── .env.development ├── tsconfig.json ├── vite.config.ts ├── .gitignore ├── index.html ├── components.json ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── public ├── vite.svg └── mockServiceWorker.js ├── eslint.boundaries.js ├── README.md ├── package.json └── bundle.yaml /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/features/auth/index.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=/api -------------------------------------------------------------------------------- /src/shared/model/config.ts: -------------------------------------------------------------------------------- 1 | export const CONFIG = { 2 | API_BASE_URL: import.meta.env.VITE_API_BASE_URL, 3 | }; 4 | -------------------------------------------------------------------------------- /src/shared/api/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const queryClient = new QueryClient(); 4 | -------------------------------------------------------------------------------- /src/shared/api/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { paths, components } from "./generated"; 2 | 3 | export type ApiPaths = paths; 4 | export type ApiSchemas = components["schemas"]; 5 | -------------------------------------------------------------------------------- /src/shared/lib/css.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/features/board-templates/index.ts: -------------------------------------------------------------------------------- 1 | export { TemplatesGallery } from "./templates-gallery"; 2 | export { TemplatesModal } from "./templates-modal"; 3 | export { useTemplatesModal } from "./use-templates-modal"; 4 | -------------------------------------------------------------------------------- /src/app/app.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | 3 | export function App() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_API_BASE_URL: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /src/shared/api/mocks/index.ts: -------------------------------------------------------------------------------- 1 | export async function enableMocking() { 2 | if (import.meta.env.PROD) { 3 | return; 4 | } 5 | 6 | const { worker } = await import("@/shared/api/mocks/browser"); 7 | return worker.start(); 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/api/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from "msw/browser"; 2 | import { boardsHandlers } from "./handlers/boards"; 3 | import { authHandlers } from "./handlers/auth"; 4 | 5 | export const worker = setupWorker(...authHandlers, ...boardsHandlers); 6 | -------------------------------------------------------------------------------- /src/shared/api/mocks/http.ts: -------------------------------------------------------------------------------- 1 | import { createOpenApiHttp } from "openapi-msw"; 2 | import { CONFIG } from "@/shared/model/config"; 3 | import { ApiPaths } from "../schema"; 4 | 5 | export const http = createOpenApiHttp({ 6 | baseUrl: CONFIG.API_BASE_URL, 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | import { queryClient } from "@/shared/api/query-client"; 2 | import { QueryClientProvider } from "@tanstack/react-query"; 3 | 4 | export function Providers({ children }: { children: React.ReactNode }) { 5 | return ( 6 | {children} 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tsconfigPaths(), tailwindcss()], 9 | }); 10 | -------------------------------------------------------------------------------- /src/features/board/board.page.tsx: -------------------------------------------------------------------------------- 1 | import { PathParams, ROUTES } from "@/shared/model/routes"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | function BoardPage() { 5 | const params = useParams(); 6 | return
Board page {params.boardId}
; 7 | } 8 | 9 | export const Component = BoardPage; 10 | -------------------------------------------------------------------------------- /src/shared/ui/kit/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/features/board-templates/use-templates-modal.ts: -------------------------------------------------------------------------------- 1 | import { createGStore } from "create-gstore"; 2 | import { useState } from "react"; 3 | 4 | export const useTemplatesModal = createGStore(() => { 5 | const [isOpen, setIsOpen] = useState(false); 6 | 7 | const open = () => setIsOpen(true); 8 | const close = () => setIsOpen(false); 9 | 10 | return { 11 | isOpen, 12 | open, 13 | close, 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/shared/lib/react.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useEffect } from "react"; 3 | 4 | export function useDebouncedValue(value: T, delay: number) { 5 | const [debouncedValue, setDebouncedValue] = useState(value); 6 | 7 | useEffect(() => { 8 | const timer = setTimeout(() => setDebouncedValue(value), delay); 9 | 10 | return () => clearTimeout(timer); 11 | }, [value, delay]); 12 | 13 | return debouncedValue; 14 | } 15 | -------------------------------------------------------------------------------- /src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | import { RouterProvider } from "react-router-dom"; 5 | import { router } from "./router"; 6 | import { enableMocking } from "@/shared/api/mocks"; 7 | 8 | enableMocking().then(() => { 9 | createRoot(document.getElementById("root")!).render( 10 | 11 | 12 | , 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-search-input.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/shared/ui/kit/input"; 2 | 3 | interface BoardsSearchInputProps { 4 | value: string; 5 | onChange: (value: string) => void; 6 | } 7 | 8 | export function BoardsSearchInput({ value, onChange }: BoardsSearchInputProps) { 9 | return ( 10 | onChange(e.target.value)} 15 | className="w-full" 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/shared/model/routes.ts: -------------------------------------------------------------------------------- 1 | import "react-router-dom"; 2 | 3 | export const ROUTES = { 4 | HOME: "/", 5 | LOGIN: "/login", 6 | REGISTER: "/register", 7 | BOARDS: "/boards", 8 | BOARD: "/boards/:boardId", 9 | FAVORITE_BOARDS: "/boards/favorite", 10 | RECENT_BOARDS: "/boards/recent", 11 | } as const; 12 | 13 | export type PathParams = { 14 | [ROUTES.BOARD]: { 15 | boardId: string; 16 | }; 17 | }; 18 | 19 | declare module "react-router-dom" { 20 | interface Register { 21 | params: PathParams; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/shared/ui", 15 | "utils": "@/shared/lib/css", 16 | "ui": "@/shared/ui/kit", 17 | "lib": "@/shared/lib", 18 | "hooks": "@/shared/lib/react" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-boards-filters.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export type BoardsSortOption = 4 | | "createdAt" 5 | | "updatedAt" 6 | | "lastOpenedAt" 7 | | "name"; 8 | 9 | export type BoardsFilters = { 10 | search: string; 11 | sort: BoardsSortOption; 12 | }; 13 | 14 | export function useBoardsFilters() { 15 | const [search, setSearch] = useState(""); 16 | const [sort, setSort] = useState("lastOpenedAt"); 17 | 18 | return { 19 | search, 20 | sort, 21 | setSearch, 22 | setSort, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/features/auth/login.page.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Link } from "react-router-dom"; 3 | import { AuthLayout } from "./ui/auth-layout"; 4 | import { LoginForm } from "./ui/login-form"; 5 | 6 | function LoginPage() { 7 | return ( 8 | } 12 | footerText={ 13 | <> 14 | Нет аккаунта? Зарегистрироваться 15 | 16 | } 17 | /> 18 | ); 19 | } 20 | 21 | export const Component = LoginPage; 22 | -------------------------------------------------------------------------------- /src/features/auth/register.page.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Link } from "react-router-dom"; 3 | import { AuthLayout } from "./ui/auth-layout"; 4 | import { RegisterForm } from "./ui/register-form"; 5 | 6 | function RegisterPage() { 7 | return ( 8 | } 12 | footerText={ 13 | <> 14 | Уже есть аккаунт? Войти 15 | 16 | } 17 | /> 18 | ); 19 | } 20 | 21 | export const Component = RegisterPage; 22 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/api/schema/shared/responses.yaml: -------------------------------------------------------------------------------- 1 | UnauthorizedError: 2 | description: Unauthorized 3 | content: 4 | application/json: 5 | schema: 6 | $ref: "#/schemas/Error" 7 | 8 | NotFoundError: 9 | description: Resource not found 10 | content: 11 | application/json: 12 | schema: 13 | $ref: "#/schemas/Error" 14 | 15 | BadRequestError: 16 | description: Bad request 17 | content: 18 | application/json: 19 | schema: 20 | $ref: "#/schemas/Error" 21 | 22 | schemas: 23 | Error: 24 | type: object 25 | required: 26 | - message 27 | - code 28 | properties: 29 | message: 30 | type: string 31 | code: 32 | type: string 33 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/view-mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/kit/tabs"; 2 | import { ImagesIcon, ListIcon } from "lucide-react"; 3 | 4 | export type ViewMode = "list" | "cards"; 5 | export function ViewModeToggle({ 6 | value, 7 | onChange, 8 | }: { 9 | value: ViewMode; 10 | onChange: (value: ViewMode) => void; 11 | }) { 12 | return ( 13 | onChange(e as ViewMode)}> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/shared/ui/kit/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/shared/lib/css" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /src/app/protected-route.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Outlet, redirect } from "react-router-dom"; 3 | import { useSession } from "@/shared/model/session"; 4 | import { Navigate } from "react-router-dom"; 5 | import { enableMocking } from "@/shared/api/mocks"; 6 | 7 | export function ProtectedRoute() { 8 | const { session } = useSession(); 9 | 10 | if (!session) { 11 | return ; 12 | } 13 | 14 | return ; 15 | } 16 | 17 | export async function protectedLoader() { 18 | await enableMocking(); 19 | 20 | const token = await useSession.getState().refreshToken(); 21 | 22 | if (!token) { 23 | return redirect(ROUTES.LOGIN); 24 | } 25 | 26 | return null; 27 | } 28 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-favorite-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/shared/lib/css"; 2 | import { StarIcon } from "lucide-react"; 3 | 4 | interface BoardsFavoriteToggleProps { 5 | isFavorite: boolean; 6 | onToggle: () => void; 7 | className?: string; 8 | } 9 | 10 | export function BoardsFavoriteToggle({ 11 | isFavorite, 12 | onToggle, 13 | className, 14 | }: BoardsFavoriteToggleProps) { 15 | return ( 16 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["src/*"] 27 | } 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-delete-board.tsx: -------------------------------------------------------------------------------- 1 | import { rqClient } from "@/shared/api/instance"; 2 | import { useQueryClient } from "@tanstack/react-query"; 3 | 4 | export function useDeleteBoard() { 5 | const queryClient = useQueryClient(); 6 | const deleteBoardMutation = rqClient.useMutation( 7 | "delete", 8 | "/boards/{boardId}", 9 | { 10 | onSettled: async () => { 11 | await queryClient.invalidateQueries( 12 | rqClient.queryOptions("get", "/boards"), 13 | ); 14 | }, 15 | }, 16 | ); 17 | 18 | return { 19 | deleteBoard: (boardId: string) => 20 | deleteBoardMutation.mutate({ 21 | params: { path: { boardId } }, 22 | }), 23 | getIsPending: (boardId: string) => 24 | deleteBoardMutation.isPending && 25 | deleteBoardMutation.variables?.params?.path?.boardId === boardId, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/features/board-templates/templates-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/shared/ui/kit/dialog"; 8 | import { TemplatesGallery } from "./templates-gallery"; 9 | import { useTemplatesModal } from "./use-templates-modal"; 10 | 11 | export function TemplatesModal() { 12 | const { isOpen, close } = useTemplatesModal(); 13 | 14 | return ( 15 | 16 | 17 | 18 | Выберите шаблон 19 | 20 | Выберите шаблон для создания новой доски 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-create-board.ts: -------------------------------------------------------------------------------- 1 | import { rqClient } from "@/shared/api/instance"; 2 | import { ROUTES } from "@/shared/model/routes"; 3 | 4 | import { useQueryClient } from "@tanstack/react-query"; 5 | import { href, useNavigate } from "react-router-dom"; 6 | 7 | export function useCreateBoard() { 8 | const navigate = useNavigate(); 9 | const queryClient = useQueryClient(); 10 | 11 | const createBoardMutation = rqClient.useMutation("post", "/boards", { 12 | onSettled: async () => { 13 | await queryClient.invalidateQueries( 14 | rqClient.queryOptions("get", "/boards"), 15 | ); 16 | }, 17 | onSuccess: (data) => { 18 | navigate(href(ROUTES.BOARD, { boardId: data.id })); 19 | }, 20 | }); 21 | 22 | return { 23 | isPending: createBoardMutation.isPending, 24 | createBoard: () => createBoardMutation.mutate({}), 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | import { eslintBoundariesConfig } from "./eslint.boundaries.js"; 7 | 8 | export default tseslint.config( 9 | { ignores: ["dist"] }, 10 | { 11 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 12 | files: ["**/*.{ts,tsx}"], 13 | languageOptions: { 14 | ecmaVersion: 2020, 15 | globals: globals.browser, 16 | }, 17 | plugins: { 18 | "react-hooks": reactHooks, 19 | "react-refresh": reactRefresh, 20 | }, 21 | rules: { 22 | ...reactHooks.configs.recommended.rules, 23 | "react-refresh/only-export-components": [ 24 | "warn", 25 | { allowConstantExport: true }, 26 | ], 27 | }, 28 | }, 29 | eslintBoundariesConfig, 30 | ); 31 | -------------------------------------------------------------------------------- /src/features/auth/model/use-login.ts: -------------------------------------------------------------------------------- 1 | import { publicRqClient } from "@/shared/api/instance"; 2 | import { ApiSchemas } from "@/shared/api/schema"; 3 | import { ROUTES } from "@/shared/model/routes"; 4 | import { useSession } from "@/shared/model/session"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | export function useLogin() { 8 | const navigate = useNavigate(); 9 | 10 | const session = useSession(); 11 | const loginMutation = publicRqClient.useMutation("post", "/auth/login", { 12 | onSuccess(data) { 13 | session.login(data.accessToken); 14 | navigate(ROUTES.HOME); 15 | }, 16 | }); 17 | 18 | const login = (data: ApiSchemas["LoginRequest"]) => { 19 | loginMutation.mutate({ body: data }); 20 | }; 21 | 22 | const errorMessage = loginMutation.isError 23 | ? loginMutation.error.message 24 | : undefined; 25 | 26 | return { 27 | login, 28 | isPending: loginMutation.isPending, 29 | errorMessage, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/features/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "@/shared/model/session"; 2 | import { Button } from "@/shared/ui/kit/button"; 3 | 4 | export function AppHeader() { 5 | const { session, logout } = useSession(); 6 | 7 | if (!session) { 8 | return null; 9 | } 10 | 11 | return ( 12 |
13 |
14 |
Miro Copy
15 | 16 |
17 | {session.email} 18 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/features/auth/model/use-register.ts: -------------------------------------------------------------------------------- 1 | import { publicRqClient } from "@/shared/api/instance"; 2 | import { ApiSchemas } from "@/shared/api/schema"; 3 | import { ROUTES } from "@/shared/model/routes"; 4 | import { useSession } from "@/shared/model/session"; 5 | import { useNavigate } from "react-router-dom"; 6 | 7 | export function useRegister() { 8 | const navigate = useNavigate(); 9 | 10 | const session = useSession(); 11 | const registerMutation = publicRqClient.useMutation( 12 | "post", 13 | "/auth/register", 14 | { 15 | onSuccess(data) { 16 | session.login(data.accessToken); 17 | navigate(ROUTES.HOME); 18 | }, 19 | }, 20 | ); 21 | 22 | const register = (data: ApiSchemas["RegisterRequest"]) => { 23 | registerMutation.mutate({ body: data }); 24 | }; 25 | 26 | const errorMessage = registerMutation.isError 27 | ? registerMutation.error.message 28 | : undefined; 29 | 30 | return { 31 | register, 32 | isPending: registerMutation.isPending, 33 | errorMessage, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/features/auth/ui/auth-layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardHeader, 4 | CardTitle, 5 | CardDescription, 6 | CardContent, 7 | CardFooter, 8 | } from "@/shared/ui/kit/card"; 9 | import React from "react"; 10 | 11 | export function AuthLayout({ 12 | form, 13 | title, 14 | description, 15 | footerText, 16 | }: { 17 | form: React.ReactNode; 18 | title: React.ReactNode; 19 | description: React.ReactNode; 20 | footerText: React.ReactNode; 21 | }) { 22 | return ( 23 |
24 | 25 | 26 | {title} 27 | {description} 28 | 29 | {form} 30 | 31 |

32 | {footerText} 33 |

34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/shared/ui/kit/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/shared/lib/css" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-sort-select.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/shared/ui/kit/select"; 8 | 9 | export type BoardsSortOption = 10 | | "createdAt" 11 | | "updatedAt" 12 | | "lastOpenedAt" 13 | | "name"; 14 | 15 | interface BoardsSortSelectProps { 16 | value: BoardsSortOption; 17 | onValueChange: (value: BoardsSortOption) => void; 18 | } 19 | 20 | export function BoardsSortSelect({ 21 | value, 22 | onValueChange, 23 | }: BoardsSortSelectProps) { 24 | return ( 25 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/features/boards-list/compose/board-card.tsx: -------------------------------------------------------------------------------- 1 | import { ApiSchemas } from "@/shared/api/schema"; 2 | import { BoardsFavoriteToggle } from "../ui/boards-favorite-toggle"; 3 | import { BoardsListCard } from "../ui/boards-list-card"; 4 | import { Button } from "@/shared/ui/kit/button"; 5 | import { useUpdateFavorite } from "../model/use-update-favorite"; 6 | import { useDeleteBoard } from "../model/use-delete-board"; 7 | 8 | export function BoardCard({ board }: { board: ApiSchemas["Board"] }) { 9 | const deleteBoard = useDeleteBoard(); 10 | const updateFavorite = useUpdateFavorite(); 11 | 12 | return ( 13 | updateFavorite.toggle(board)} 20 | /> 21 | } 22 | bottomActions={ 23 | 30 | } 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/shared/api/schema/main.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: Miro-like Collaborative Whiteboard API 4 | description: API for a collaborative whiteboard application similar to Miro 5 | version: 1.0.0 6 | 7 | components: 8 | securitySchemes: 9 | bearerAuth: 10 | type: http 11 | scheme: bearer 12 | bearerFormat: JWT 13 | 14 | 15 | paths: 16 | 17 | /auth/login: 18 | post: 19 | $ref: "./endpoints/auth.yaml#/login" 20 | 21 | /auth/register: 22 | post: 23 | $ref: "./endpoints/auth.yaml#/register" 24 | 25 | /auth/refresh: 26 | post: 27 | $ref: "./endpoints/auth.yaml#/refresh" 28 | 29 | /boards: 30 | get: 31 | $ref: "./endpoints/boards.yaml#/getAllBoards" 32 | 33 | post: 34 | $ref: "./endpoints/boards.yaml#/createBoard" 35 | 36 | /boards/{boardId}: 37 | get: 38 | $ref: "./endpoints/boards.yaml#/getBoardById" 39 | 40 | delete: 41 | $ref: "./endpoints/boards.yaml#/deleteBoard" 42 | 43 | /boards/{boardId}/favorite: 44 | put: 45 | $ref: "./endpoints/boards.yaml#/updateBoardFavorite" 46 | 47 | /boards/{boardId}/rename: 48 | put: 49 | $ref: "./endpoints/boards.yaml#/renameBoard" 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/features/boards-list/compose/board-item.tsx: -------------------------------------------------------------------------------- 1 | import { ApiSchemas } from "@/shared/api/schema"; 2 | import { BoardsFavoriteToggle } from "../ui/boards-favorite-toggle"; 3 | import { BoardsListItem } from "../ui/boards-list-item"; 4 | import { DropdownMenuItem } from "@/shared/ui/kit/dropdown-menu"; 5 | import { useDeleteBoard } from "../model/use-delete-board"; 6 | import { useUpdateFavorite } from "../model/use-update-favorite"; 7 | 8 | export function BoardItem({ board }: { board: ApiSchemas["Board"] }) { 9 | const deleteBoard = useDeleteBoard(); 10 | const updateFavorite = useUpdateFavorite(); 11 | 12 | return ( 13 | updateFavorite.toggle(board)} 20 | /> 21 | } 22 | menuActions={ 23 | deleteBoard.deleteBoard(board.id)} 27 | > 28 | Удалить 29 | 30 | } 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/features/board-templates/templates-gallery.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollArea } from "@/shared/ui/kit/scroll-area"; 2 | import { TemplateCard } from "./template-card"; 3 | 4 | const templates = [ 5 | { 6 | id: "1", 7 | name: "Template 1", 8 | description: "Template 1 description", 9 | thumbnail: "https://via.placeholder.com/150", 10 | }, 11 | { 12 | id: "2", 13 | name: "Template 2", 14 | description: "Template 2 description", 15 | thumbnail: "https://via.placeholder.com/150", 16 | }, 17 | { 18 | id: "3", 19 | name: "Template 3", 20 | description: "Template 3 description", 21 | thumbnail: "https://via.placeholder.com/150", 22 | }, 23 | { 24 | id: "4", 25 | name: "Template 4", 26 | description: "Template 4 description", 27 | thumbnail: "https://via.placeholder.com/150", 28 | }, 29 | ]; 30 | 31 | export function TemplatesGallery({ className }: { className?: string }) { 32 | return ( 33 | 34 |
35 | {templates.map((template) => ( 36 | {}} 40 | /> 41 | ))} 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/shared/ui/kit/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitive from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/shared/lib/css" 7 | 8 | function Switch({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | 27 | 28 | ) 29 | } 30 | 31 | export { Switch } 32 | -------------------------------------------------------------------------------- /src/shared/api/instance.ts: -------------------------------------------------------------------------------- 1 | import createFetchClient from "openapi-fetch"; 2 | import createClient from "openapi-react-query"; // generated by openapi-typescript 3 | import { CONFIG } from "@/shared/model/config"; 4 | import { ApiPaths, ApiSchemas } from "./schema"; 5 | import { useSession } from "../model/session"; 6 | 7 | export const fetchClient = createFetchClient({ 8 | baseUrl: CONFIG.API_BASE_URL, 9 | }); 10 | export const rqClient = createClient(fetchClient); 11 | 12 | export const publicFetchClient = createFetchClient({ 13 | baseUrl: CONFIG.API_BASE_URL, 14 | }); 15 | export const publicRqClient = createClient(publicFetchClient); 16 | 17 | fetchClient.use({ 18 | async onRequest({ request }) { 19 | const token = await useSession.getState().refreshToken(); 20 | 21 | if (token) { 22 | request.headers.set("Authorization", `Bearer ${token}`); 23 | } else { 24 | return new Response( 25 | JSON.stringify({ 26 | code: "NOT_AUTHOIZED", 27 | message: "You are not authorized to access this resource", 28 | } as ApiSchemas["Error"]), 29 | { 30 | status: 401, 31 | headers: { 32 | "Content-Type": "application/json", 33 | }, 34 | }, 35 | ); 36 | } 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-update-favorite.ts: -------------------------------------------------------------------------------- 1 | import { rqClient } from "@/shared/api/instance"; 2 | import { useQueryClient } from "@tanstack/react-query"; 3 | import { startTransition, useOptimistic } from "react"; 4 | 5 | export function useUpdateFavorite() { 6 | const queryClient = useQueryClient(); 7 | 8 | const [favorite, setFavorite] = useOptimistic>({}); 9 | 10 | const updateFavoriteMutation = rqClient.useMutation( 11 | "put", 12 | "/boards/{boardId}/favorite", 13 | { 14 | onSettled: async () => { 15 | await queryClient.invalidateQueries( 16 | rqClient.queryOptions("get", "/boards"), 17 | ); 18 | }, 19 | }, 20 | ); 21 | 22 | const toggle = (board: { id: string; isFavorite: boolean }) => { 23 | startTransition(async () => { 24 | setFavorite((prev) => ({ 25 | ...prev, 26 | [board.id]: !board.isFavorite, 27 | })); 28 | await updateFavoriteMutation.mutateAsync({ 29 | params: { path: { boardId: board.id } }, 30 | body: { isFavorite: !board.isFavorite }, 31 | }); 32 | }); 33 | }; 34 | 35 | const isOptimisticFavorite = (board: { id: string; isFavorite: boolean }) => 36 | favorite[board.id] ?? board.isFavorite; 37 | 38 | return { 39 | toggle, 40 | isOptimisticFavorite, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Button } from "@/shared/ui/kit/button"; 3 | import { Link } from "react-router-dom"; 4 | import { LayoutGridIcon, StarIcon, ClockIcon } from "lucide-react"; 5 | import { cn } from "@/shared/lib/css"; 6 | 7 | interface BoardsSidebarProps { 8 | className?: string; 9 | } 10 | 11 | export function BoardsSidebar({ className }: BoardsSidebarProps) { 12 | return ( 13 |
14 |
15 |
Навигация
16 | 22 | 28 | 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/features/board-templates/template-card.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/shared/ui/kit/button"; 2 | import { PlusIcon } from "lucide-react"; 3 | import { cn } from "@/shared/lib/css"; 4 | 5 | interface Template { 6 | id: string; 7 | name: string; 8 | description: string; 9 | thumbnail: string; 10 | } 11 | 12 | interface TemplateCardProps { 13 | template: Template; 14 | onSelect: (template: Template) => void; 15 | className?: string; 16 | } 17 | 18 | export function TemplateCard({ 19 | template, 20 | onSelect, 21 | className, 22 | }: TemplateCardProps) { 23 | return ( 24 |
onSelect(template)} 30 | > 31 |
32 | {template.name} 37 |
38 |

{template.name}

39 |

{template.description}

40 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-recent-groups.ts: -------------------------------------------------------------------------------- 1 | import { ApiSchemas } from "@/shared/api/schema"; 2 | 3 | type BoardsGroup = { 4 | title: string; 5 | items: ApiSchemas["Board"][]; 6 | }; 7 | 8 | export function useRecentGroups(boards: ApiSchemas["Board"][]): BoardsGroup[] { 9 | const today = new Date(); 10 | today.setHours(0, 0, 0, 0); 11 | 12 | const yesterday = new Date(today); 13 | yesterday.setDate(yesterday.getDate() - 1); 14 | 15 | const lastMonth = new Date(today); 16 | lastMonth.setMonth(lastMonth.getMonth() - 1); 17 | 18 | const groups = boards.reduce((acc, board) => { 19 | const lastOpenedAt = new Date(board.lastOpenedAt); 20 | lastOpenedAt.setHours(0, 0, 0, 0); 21 | 22 | let groupTitle: string; 23 | if (lastOpenedAt.getTime() === today.getTime()) { 24 | groupTitle = "Сегодня"; 25 | } else if (lastOpenedAt.getTime() === yesterday.getTime()) { 26 | groupTitle = "Вчера"; 27 | } else if (lastOpenedAt >= lastMonth) { 28 | groupTitle = "Прошлый месяц"; 29 | } else { 30 | groupTitle = "Другое"; 31 | } 32 | 33 | const group = acc.find((g) => g.title === groupTitle); 34 | if (group) { 35 | group.items.push(board); 36 | } else { 37 | acc.push({ title: groupTitle, items: [board] }); 38 | } 39 | 40 | return acc; 41 | }, []); 42 | 43 | // Сортируем группы в нужном порядке 44 | const groupOrder = ["Сегодня", "Вчера", "Прошлый месяц", "Другое"]; 45 | return groupOrder 46 | .map((title) => groups.find((g) => g.title === title)) 47 | .filter((group): group is BoardsGroup => group !== undefined); 48 | } 49 | -------------------------------------------------------------------------------- /src/shared/api/mocks/session.ts: -------------------------------------------------------------------------------- 1 | import { SignJWT, jwtVerify } from "jose"; 2 | import { HttpResponse } from "msw"; 3 | 4 | type Session = { 5 | userId: string; 6 | email: string; 7 | }; 8 | 9 | const JWT_SECRET = new TextEncoder().encode("your-secret-key"); 10 | const ACCESS_TOKEN_EXPIRY = "3s"; 11 | const REFRESH_TOKEN_EXPIRY = "7d"; 12 | 13 | export function createRefreshTokenCookie(refreshToken: string) { 14 | return `refreshToken=${refreshToken}; Max-Age=604800`; 15 | } 16 | 17 | export async function generateTokens(session: Session) { 18 | const accessToken = await new SignJWT(session) 19 | .setProtectedHeader({ alg: "HS256" }) 20 | .setIssuedAt() 21 | .setExpirationTime(ACCESS_TOKEN_EXPIRY) 22 | .sign(JWT_SECRET); 23 | 24 | const refreshToken = await new SignJWT(session) 25 | .setProtectedHeader({ alg: "HS256" }) 26 | .setIssuedAt() 27 | .setExpirationTime(REFRESH_TOKEN_EXPIRY) 28 | .sign(JWT_SECRET); 29 | 30 | return { accessToken, refreshToken }; 31 | } 32 | 33 | export async function verifyToken(token: string): Promise { 34 | const { payload } = await jwtVerify(token, JWT_SECRET); 35 | return payload as Session; 36 | } 37 | 38 | export async function verifyTokenOrThrow(request: Request): Promise { 39 | const token = request.headers.get("Authorization")?.split(" ")[1]; 40 | const session = token ? await verifyToken(token).catch(() => null) : null; 41 | if (!session) { 42 | throw HttpResponse.json( 43 | { 44 | message: "Invalid token", 45 | code: "INVALID_TOKEN", 46 | }, 47 | { status: 401 }, 48 | ); 49 | } 50 | return session; 51 | } 52 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-list-card.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Button } from "@/shared/ui/kit/button"; 3 | import { Card, CardFooter, CardHeader } from "@/shared/ui/kit/card"; 4 | import { Link, href } from "react-router-dom"; 5 | 6 | interface BoardsListCardProps { 7 | board: { 8 | id: string; 9 | name: string; 10 | createdAt: string; 11 | lastOpenedAt: string; 12 | }; 13 | rightTopActions?: React.ReactNode; 14 | bottomActions?: React.ReactNode; 15 | } 16 | 17 | export function BoardsListCard({ 18 | board, 19 | bottomActions, 20 | rightTopActions, 21 | }: BoardsListCardProps) { 22 | return ( 23 | 24 | {
{rightTopActions}
} 25 | 26 |
27 | 36 |
37 | Создано: {new Date(board.createdAt).toLocaleDateString()} 38 |
39 |
40 | Последнее открытие:{" "} 41 | {new Date(board.lastOpenedAt).toLocaleDateString()} 42 |
43 |
44 |
45 | {bottomActions && {bottomActions}} 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/app/router.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "../shared/model/routes"; 2 | import { createBrowserRouter, redirect } from "react-router-dom"; 3 | import { App } from "./app"; 4 | import { Providers } from "./providers"; 5 | import { protectedLoader, ProtectedRoute } from "./protected-route"; 6 | import { AppHeader } from "@/features/header"; 7 | 8 | export const router = createBrowserRouter([ 9 | { 10 | element: ( 11 | 12 | 13 | 14 | ), 15 | children: [ 16 | { 17 | loader: protectedLoader, 18 | element: ( 19 | <> 20 | 21 | 22 | 23 | ), 24 | children: [ 25 | { 26 | path: ROUTES.BOARDS, 27 | lazy: () => import("@/features/boards-list/boards-list.page"), 28 | }, 29 | { 30 | path: ROUTES.FAVORITE_BOARDS, 31 | lazy: () => 32 | import("@/features/boards-list/boards-list-favorite.page"), 33 | }, 34 | { 35 | path: ROUTES.RECENT_BOARDS, 36 | lazy: () => 37 | import("@/features/boards-list/boards-list-recent.page"), 38 | }, 39 | { 40 | path: ROUTES.BOARD, 41 | lazy: () => import("@/features/board/board.page"), 42 | }, 43 | ], 44 | }, 45 | 46 | { 47 | path: ROUTES.LOGIN, 48 | lazy: () => import("@/features/auth/login.page"), 49 | }, 50 | { 51 | path: ROUTES.REGISTER, 52 | lazy: () => import("@/features/auth/register.page"), 53 | }, 54 | { 55 | path: ROUTES.HOME, 56 | loader: () => redirect(ROUTES.BOARDS), 57 | }, 58 | ], 59 | }, 60 | ]); 61 | -------------------------------------------------------------------------------- /eslint.boundaries.js: -------------------------------------------------------------------------------- 1 | import boundaries from "eslint-plugin-boundaries"; 2 | 3 | export const eslintBoundariesConfig = { 4 | plugins: { 5 | boundaries, 6 | }, 7 | settings: { 8 | "import/resolver": { 9 | typescript: { 10 | alwaysTryTypes: true, 11 | }, 12 | }, 13 | 14 | "boundaries/elements": [ 15 | { 16 | type: "app", 17 | pattern: "./src/app", 18 | }, 19 | { 20 | type: "features", 21 | pattern: "./src/features/*", 22 | }, 23 | { 24 | type: "shared", 25 | pattern: "./src/shared", 26 | }, 27 | ], 28 | }, 29 | rules: { 30 | "boundaries/element-types": [ 31 | 2, 32 | { 33 | default: "allow", 34 | rules: [ 35 | { 36 | from: "shared", 37 | disallow: ["app", "features"], 38 | message: 39 | "Модуль нижележащего слоя (${file.type}) не может импортировать модуль вышележащего слоя (${dependency.type})", 40 | }, 41 | { 42 | from: "features", 43 | disallow: ["app"], 44 | message: 45 | "Модуль нижележащего слоя (${file.type}) не может импортировать модуль вышележащего слоя (${dependency.type})", 46 | }, 47 | ], 48 | }, 49 | ], 50 | "boundaries/entry-point": [ 51 | 2, 52 | { 53 | default: "disallow", 54 | message: 55 | "Модуль (${file.type}) должен импортироваться через public API. Прямой импорт из ${dependency.source} запрещен", 56 | 57 | rules: [ 58 | { 59 | target: ["shared", "app"], 60 | allow: "**", 61 | }, 62 | { 63 | target: ["features"], 64 | allow: ["index.(ts|tsx)", "*.page.tsx"], 65 | }, 66 | ], 67 | }, 68 | ], 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/shared/ui/kit/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/shared/lib/css" 5 | 6 | function ScrollArea({ 7 | className, 8 | children, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | 21 | {children} 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function ScrollBar({ 30 | className, 31 | orientation = "vertical", 32 | ...props 33 | }: React.ComponentProps) { 34 | return ( 35 | 48 | 52 | 53 | ) 54 | } 55 | 56 | export { ScrollArea, ScrollBar } 57 | -------------------------------------------------------------------------------- /src/shared/model/session.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { jwtDecode } from "jwt-decode"; 3 | import { createGStore } from "create-gstore"; 4 | import { publicFetchClient } from "../api/instance"; 5 | 6 | type Session = { 7 | userId: string; 8 | email: string; 9 | exp: number; 10 | iat: number; 11 | }; 12 | 13 | const TOKEN_KEY = "token"; 14 | 15 | let refreshTokenPromise: Promise | null = null; 16 | 17 | export const useSession = createGStore(() => { 18 | const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY)); 19 | 20 | const login = (token: string) => { 21 | localStorage.setItem(TOKEN_KEY, token); 22 | setToken(token); 23 | }; 24 | 25 | const logout = () => { 26 | localStorage.removeItem(TOKEN_KEY); 27 | setToken(null); 28 | }; 29 | 30 | const session = token ? jwtDecode(token) : null; 31 | 32 | const refreshToken = async () => { 33 | if (!token) { 34 | return null; 35 | } 36 | 37 | const session = jwtDecode(token); 38 | 39 | if (session.exp < Date.now() / 1000) { 40 | if (!refreshTokenPromise) { 41 | refreshTokenPromise = publicFetchClient 42 | .POST("/auth/refresh") 43 | .then((r) => r.data?.accessToken ?? null) 44 | .then((newToken) => { 45 | if (newToken) { 46 | login(newToken); 47 | return newToken; 48 | } else { 49 | logout(); 50 | return null; 51 | } 52 | }) 53 | .finally(() => { 54 | refreshTokenPromise = null; 55 | }); 56 | } 57 | 58 | const newToken = await refreshTokenPromise; 59 | 60 | if (newToken) { 61 | return newToken; 62 | } else { 63 | return null; 64 | } 65 | } 66 | 67 | return token; 68 | }; 69 | 70 | return { refreshToken, login, logout, session }; 71 | }); 72 | -------------------------------------------------------------------------------- /src/features/boards-list/model/use-boards-list.tsx: -------------------------------------------------------------------------------- 1 | import { rqClient } from "@/shared/api/instance"; 2 | import { keepPreviousData } from "@tanstack/query-core"; 3 | import { RefCallback, useCallback } from "react"; 4 | 5 | type UseBoardsListParams = { 6 | limit?: number; 7 | isFavorite?: boolean; 8 | search?: string; 9 | sort?: "createdAt" | "updatedAt" | "lastOpenedAt" | "name"; 10 | }; 11 | 12 | export function useBoardsList({ 13 | limit = 20, 14 | isFavorite, 15 | search, 16 | sort, 17 | }: UseBoardsListParams) { 18 | const { fetchNextPage, data, isFetchingNextPage, isPending, hasNextPage } = 19 | rqClient.useInfiniteQuery( 20 | "get", 21 | "/boards", 22 | { 23 | params: { 24 | query: { 25 | page: 1, 26 | limit, 27 | isFavorite, 28 | search, 29 | sort, 30 | }, 31 | }, 32 | }, 33 | { 34 | initialPageParam: 1, 35 | pageParamName: "page", 36 | getNextPageParam: (lastPage, _, lastPageParams) => 37 | Number(lastPageParams) < lastPage.totalPages 38 | ? Number(lastPageParams) + 1 39 | : null, 40 | 41 | placeholderData: keepPreviousData, 42 | }, 43 | ); 44 | 45 | const cursorRef: RefCallback = useCallback( 46 | (el) => { 47 | const observer = new IntersectionObserver( 48 | (entries) => { 49 | if (entries[0].isIntersecting) { 50 | fetchNextPage(); 51 | } 52 | }, 53 | { threshold: 0.5 }, 54 | ); 55 | 56 | if (el) { 57 | observer.observe(el); 58 | 59 | return () => { 60 | observer.disconnect(); 61 | }; 62 | } 63 | }, 64 | [fetchNextPage], 65 | ); 66 | 67 | const boards = data?.pages.flatMap((page) => page.list) ?? []; 68 | 69 | return { 70 | boards, 71 | isFetchingNextPage, 72 | isPending, 73 | hasNextPage, 74 | cursorRef, 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/features/boards-list/boards-list-favorite.page.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useBoardsList } from "./model/use-boards-list"; 3 | 4 | import { 5 | BoardsListLayout, 6 | BoardsListLayoutContent, 7 | BoardsListLayoutHeader, 8 | } from "./ui/boards-list-layout"; 9 | import { ViewMode, ViewModeToggle } from "./ui/view-mode-toggle"; 10 | 11 | import { BoardItem } from "./compose/board-item"; 12 | import { BoardCard } from "./compose/board-card"; 13 | import { BoardsSidebar } from "./ui/boards-sidebar"; 14 | 15 | function BoardsListPage() { 16 | const boardsQuery = useBoardsList({ 17 | isFavorite: true, 18 | }); 19 | 20 | const [viewMode, setViewMode] = useState("list"); 21 | 22 | return ( 23 | } 25 | header={ 26 | setViewMode(value)} 33 | /> 34 | } 35 | /> 36 | } 37 | > 38 | 46 | boardsQuery.boards.map((board) => ( 47 | 48 | )) 49 | } 50 | renderGrid={() => 51 | boardsQuery.boards.map((board) => ( 52 | 53 | )) 54 | } 55 | /> 56 | 57 | ); 58 | } 59 | 60 | export const Component = BoardsListPage; 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ["./tsconfig.node.json", "./tsconfig.app.json"], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }); 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from "eslint-plugin-react-x"; 39 | import reactDom from "eslint-plugin-react-dom"; 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | "react-x": reactX, 45 | "react-dom": reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs["recommended-typescript"].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }); 54 | ``` 55 | -------------------------------------------------------------------------------- /src/features/boards-list/ui/boards-list-item.tsx: -------------------------------------------------------------------------------- 1 | import { ROUTES } from "@/shared/model/routes"; 2 | import { Button } from "@/shared/ui/kit/button"; 3 | import { Link, href } from "react-router-dom"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuTrigger, 8 | } from "@/shared/ui/kit/dropdown-menu"; 9 | import { MoreHorizontalIcon } from "lucide-react"; 10 | 11 | interface BoardsListItemProps { 12 | board: { 13 | id: string; 14 | name: string; 15 | createdAt: string; 16 | lastOpenedAt: string; 17 | }; 18 | rightActions?: React.ReactNode; 19 | menuActions?: React.ReactNode; 20 | } 21 | 22 | export function BoardsListItem({ 23 | board, 24 | rightActions, 25 | menuActions, 26 | }: BoardsListItemProps) { 27 | return ( 28 |
29 |
30 | 41 |
42 |
Создано: {new Date(board.createdAt).toLocaleDateString()}
43 |
44 | Последнее открытие:{" "} 45 | {new Date(board.lastOpenedAt).toLocaleDateString()} 46 |
47 |
48 |
49 |
50 | {rightActions} 51 | {menuActions && ( 52 | 53 | 54 | 57 | 58 | {menuActions} 59 | 60 | )} 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/shared/ui/kit/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/shared/lib/css" 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ) 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ) 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ) 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent } 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "api": "npx openapi-typescript ./src/shared/api/schema/main.yaml -o ./src/shared/api/schema/generated.ts" 12 | }, 13 | "dependencies": { 14 | "@hookform/resolvers": "^5.0.1", 15 | "@radix-ui/react-dialog": "^1.1.11", 16 | "@radix-ui/react-dropdown-menu": "^2.1.12", 17 | "@radix-ui/react-label": "^2.1.4", 18 | "@radix-ui/react-scroll-area": "^1.2.6", 19 | "@radix-ui/react-select": "^2.2.2", 20 | "@radix-ui/react-slot": "^1.2.0", 21 | "@radix-ui/react-switch": "^1.2.2", 22 | "@radix-ui/react-tabs": "^1.1.9", 23 | "@tailwindcss/vite": "^4.1.4", 24 | "@tanstack/react-query": "^5.74.9", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "create-gstore": "^0.1.4", 28 | "jwt-decode": "^4.0.0", 29 | "lucide-react": "^0.503.0", 30 | "openapi-fetch": "^0.13.5", 31 | "openapi-react-query": "^0.3.1", 32 | "react": "^19.0.0", 33 | "react-dom": "^19.0.0", 34 | "react-hook-form": "^7.56.1", 35 | "react-router-dom": "^7.5.3", 36 | "tailwind-merge": "^3.2.0", 37 | "tailwindcss": "^4.1.4", 38 | "zod": "^3.24.3" 39 | }, 40 | "devDependencies": { 41 | "@eslint/js": "^9.22.0", 42 | "@types/react": "^19.0.10", 43 | "@types/react-dom": "^19.0.4", 44 | "@vitejs/plugin-react": "^4.3.4", 45 | "eslint": "^9.22.0", 46 | "eslint-import-resolver-typescript": "^4.3.4", 47 | "eslint-plugin-boundaries": "^5.0.1", 48 | "eslint-plugin-react-hooks": "^5.2.0", 49 | "eslint-plugin-react-refresh": "^0.4.19", 50 | "globals": "^16.0.0", 51 | "jose": "^6.0.10", 52 | "msw": "^2.7.5", 53 | "openapi-msw": "^1.2.0", 54 | "openapi-typescript": "^7.6.1", 55 | "prettier": "^3.5.3", 56 | "tw-animate-css": "^1.2.8", 57 | "typescript": "~5.7.2", 58 | "typescript-eslint": "^8.26.1", 59 | "vite": "^6.3.1", 60 | "vite-tsconfig-paths": "^5.1.4" 61 | }, 62 | "msw": { 63 | "workerDirectory": [ 64 | "public" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/features/auth/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/shared/ui/kit/button"; 2 | import { 3 | FormField, 4 | FormItem, 5 | FormLabel, 6 | FormControl, 7 | FormMessage, 8 | Form, 9 | } from "@/shared/ui/kit/form"; 10 | import { Input } from "@/shared/ui/kit/input"; 11 | import { useForm } from "react-hook-form"; 12 | import { z } from "zod"; 13 | import { zodResolver } from "@hookform/resolvers/zod"; 14 | import { useLogin } from "../model/use-login"; 15 | 16 | const loginSchema = z.object({ 17 | email: z 18 | .string({ 19 | required_error: "Email обязателен", 20 | }) 21 | .email("Неверный email"), 22 | password: z 23 | .string({ 24 | required_error: "Пароль обязателен", 25 | }) 26 | .min(6, "Пароль должен быть не менее 6 символов"), 27 | }); 28 | 29 | export function LoginForm() { 30 | const form = useForm({ 31 | resolver: zodResolver(loginSchema), 32 | }); 33 | 34 | const { errorMessage, isPending, login } = useLogin(); 35 | 36 | const onSubmit = form.handleSubmit(login); 37 | 38 | return ( 39 |
40 | 41 | ( 45 | 46 | Email 47 | 48 | 49 | 50 | 51 | 52 | 53 | )} 54 | /> 55 | ( 59 | 60 | Пароль 61 | 62 | 63 | 64 | 65 | 66 | 67 | )} 68 | /> 69 | 70 | {errorMessage && ( 71 |

{errorMessage}

72 | )} 73 | 74 | 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/shared/ui/kit/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/shared/lib/css" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/shared/ui/kit/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/shared/lib/css" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/shared/api/schema/endpoints/auth.yaml: -------------------------------------------------------------------------------- 1 | schemas: 2 | User: 3 | type: object 4 | required: 5 | - id 6 | - email 7 | properties: 8 | id: 9 | type: string 10 | email: 11 | type: string 12 | format: email 13 | 14 | LoginRequest: 15 | type: object 16 | required: 17 | - email 18 | - password 19 | properties: 20 | email: 21 | type: string 22 | format: email 23 | password: 24 | type: string 25 | format: password 26 | 27 | RegisterRequest: 28 | type: object 29 | required: 30 | - email 31 | - password 32 | properties: 33 | email: 34 | type: string 35 | format: email 36 | password: 37 | type: string 38 | format: password 39 | 40 | AuthResponse: 41 | type: object 42 | required: 43 | - accessToken 44 | - user 45 | properties: 46 | accessToken: 47 | type: string 48 | user: 49 | $ref: '#/schemas/User' 50 | 51 | login: 52 | summary: Login user 53 | requestBody: 54 | required: true 55 | content: 56 | application/json: 57 | schema: 58 | $ref: '#/schemas/LoginRequest' 59 | responses: 60 | '200': 61 | description: Login successful 62 | content: 63 | application/json: 64 | schema: 65 | $ref: '#/schemas/AuthResponse' 66 | '401': 67 | $ref: '../shared/responses.yaml#/UnauthorizedError' 68 | 69 | register: 70 | summary: Register new user 71 | requestBody: 72 | required: true 73 | content: 74 | application/json: 75 | schema: 76 | $ref: '#/schemas/RegisterRequest' 77 | responses: 78 | '201': 79 | description: Registration successful 80 | content: 81 | application/json: 82 | schema: 83 | $ref: '#/schemas/AuthResponse' 84 | '400': 85 | $ref: '../shared/responses.yaml#/BadRequestError' 86 | 87 | refresh: 88 | summary: Refresh access token 89 | parameters: 90 | - in: cookie 91 | name: refreshToken 92 | schema: 93 | type: string 94 | responses: 95 | '200': 96 | description: Access token refreshed successfully 97 | content: 98 | application/json: 99 | schema: 100 | $ref: '#/schemas/AuthResponse' 101 | '401': 102 | $ref: '../shared/responses.yaml#/UnauthorizedError' 103 | -------------------------------------------------------------------------------- /src/features/boards-list/boards-list-recent.page.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useBoardsList } from "./model/use-boards-list"; 3 | 4 | import { 5 | BoardsLayoutContentGroups, 6 | BoardsListLayout, 7 | BoardsListLayoutCards, 8 | BoardsListLayoutContent, 9 | BoardsListLayoutHeader, 10 | BoardsListLayoutList, 11 | } from "./ui/boards-list-layout"; 12 | import { ViewMode, ViewModeToggle } from "./ui/view-mode-toggle"; 13 | 14 | import { useRecentGroups } from "./model/use-recent-groups"; 15 | 16 | import { BoardCard } from "./compose/board-card"; 17 | import { BoardItem } from "./compose/board-item"; 18 | import { BoardsSidebar } from "./ui/boards-sidebar"; 19 | 20 | function BoardsListPage() { 21 | const boardsQuery = useBoardsList({ 22 | sort: "lastOpenedAt", 23 | }); 24 | 25 | const [viewMode, setViewMode] = useState("list"); 26 | 27 | const recentGroups = useRecentGroups(boardsQuery.boards); 28 | 29 | return ( 30 | } 32 | header={ 33 | setViewMode(value)} 40 | /> 41 | } 42 | /> 43 | } 44 | > 45 | 53 | ({ 55 | items: { 56 | list: ( 57 | 58 | {group.items.map((board) => ( 59 | 60 | ))} 61 | 62 | ), 63 | cards: ( 64 | 65 | {group.items.map((board) => ( 66 | 67 | ))} 68 | 69 | ), 70 | }[viewMode], 71 | title: group.title, 72 | }))} 73 | /> 74 | 75 | 76 | ); 77 | } 78 | 79 | export const Component = BoardsListPage; 80 | -------------------------------------------------------------------------------- /src/features/auth/ui/register-form.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/shared/ui/kit/button"; 2 | import { 3 | FormField, 4 | FormItem, 5 | FormLabel, 6 | FormControl, 7 | FormMessage, 8 | Form, 9 | } from "@/shared/ui/kit/form"; 10 | import { Input } from "@/shared/ui/kit/input"; 11 | import { useForm } from "react-hook-form"; 12 | import { z } from "zod"; 13 | import { zodResolver } from "@hookform/resolvers/zod"; 14 | import { useRegister } from "../model/use-register"; 15 | 16 | const registerSchema = z 17 | .object({ 18 | email: z 19 | .string({ 20 | required_error: "Email обязателен", 21 | }) 22 | .email("Неверный email"), 23 | password: z 24 | .string({ 25 | required_error: "Пароль обязателен", 26 | }) 27 | .min(6, "Пароль должен быть не менее 6 символов"), 28 | confirmPassword: z.string().optional(), 29 | }) 30 | .refine((data) => data.password === data.confirmPassword, { 31 | path: ["confirmPassword"], 32 | message: "Пароли не совпадают", 33 | }); 34 | 35 | export function RegisterForm() { 36 | const form = useForm({ 37 | resolver: zodResolver(registerSchema), 38 | }); 39 | 40 | const { errorMessage, isPending, register } = useRegister(); 41 | 42 | const onSubmit = form.handleSubmit(register); 43 | 44 | return ( 45 |
46 | 47 | ( 51 | 52 | Email 53 | 54 | 55 | 56 | 57 | 58 | 59 | )} 60 | /> 61 | ( 65 | 66 | Пароль 67 | 68 | 69 | 70 | 71 | 72 | 73 | )} 74 | /> 75 | ( 79 | 80 | Подтвердите пароль 81 | 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | /> 89 | 90 | {errorMessage && ( 91 |

{errorMessage}

92 | )} 93 | 94 | 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/features/boards-list/boards-list.page.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button } from "@/shared/ui/kit/button"; 3 | import { useBoardsList } from "./model/use-boards-list"; 4 | import { useBoardsFilters } from "./model/use-boards-filters"; 5 | import { useDebouncedValue } from "@/shared/lib/react"; 6 | import { useCreateBoard } from "./model/use-create-board"; 7 | 8 | import { PlusIcon } from "lucide-react"; 9 | import { 10 | BoardsListLayout, 11 | BoardsListLayoutContent, 12 | BoardsListLayoutFilters, 13 | BoardsListLayoutHeader, 14 | } from "./ui/boards-list-layout"; 15 | import { ViewMode, ViewModeToggle } from "./ui/view-mode-toggle"; 16 | import { BoardsSortSelect } from "./ui/boards-sort-select"; 17 | import { BoardsSearchInput } from "./ui/boards-search-input"; 18 | import { BoardItem } from "./compose/board-item"; 19 | import { BoardCard } from "./compose/board-card"; 20 | import { BoardsSidebar } from "./ui/boards-sidebar"; 21 | import { 22 | TemplatesGallery, 23 | TemplatesModal, 24 | useTemplatesModal, 25 | } from "@/features/board-templates"; 26 | 27 | function BoardsListPage() { 28 | const boardsFilters = useBoardsFilters(); 29 | const boardsQuery = useBoardsList({ 30 | sort: boardsFilters.sort, 31 | search: useDebouncedValue(boardsFilters.search, 300), 32 | }); 33 | 34 | const templatesModal = useTemplatesModal(); 35 | 36 | const createBoard = useCreateBoard(); 37 | 38 | const [viewMode, setViewMode] = useState("list"); 39 | 40 | return ( 41 | <> 42 | 43 | } 45 | sidebar={} 46 | header={ 47 | 52 | 55 | 62 | 63 | } 64 | /> 65 | } 66 | filters={ 67 | 73 | } 74 | filters={ 75 | 79 | } 80 | actions={ 81 | setViewMode(value)} 84 | /> 85 | } 86 | /> 87 | } 88 | > 89 | 97 | boardsQuery.boards.map((board) => ( 98 | 99 | )) 100 | } 101 | renderGrid={() => 102 | boardsQuery.boards.map((board) => ( 103 | 104 | )) 105 | } 106 | /> 107 | 108 | 109 | ); 110 | } 111 | 112 | export const Component = BoardsListPage; 113 | -------------------------------------------------------------------------------- /src/shared/api/mocks/handlers/auth.ts: -------------------------------------------------------------------------------- 1 | import { ApiSchemas } from "../../schema"; 2 | import { http } from "../http"; 3 | import { delay, HttpResponse } from "msw"; 4 | import { 5 | createRefreshTokenCookie, 6 | generateTokens, 7 | verifyToken, 8 | } from "../session"; 9 | 10 | const mockUsers: ApiSchemas["User"][] = [ 11 | { 12 | id: "1", 13 | email: "admin@gmail.com", 14 | }, 15 | ]; 16 | 17 | const userPasswords = new Map(); 18 | userPasswords.set("admin@gmail.com", "123456"); 19 | 20 | export const authHandlers = [ 21 | http.post("/auth/login", async ({ request }) => { 22 | const body = await request.json(); 23 | 24 | const user = mockUsers.find((u) => u.email === body.email); 25 | const storedPassword = userPasswords.get(body.email); 26 | 27 | await delay(); 28 | 29 | if (!user || !storedPassword || storedPassword !== body.password) { 30 | return HttpResponse.json( 31 | { 32 | message: "Неверный email или пароль", 33 | code: "INVALID_CREDENTIALS", 34 | }, 35 | { status: 401 }, 36 | ); 37 | } 38 | 39 | const { accessToken, refreshToken } = await generateTokens({ 40 | userId: user.id, 41 | email: user.email, 42 | }); 43 | 44 | return HttpResponse.json( 45 | { 46 | accessToken: accessToken, 47 | user, 48 | }, 49 | { 50 | status: 200, 51 | headers: { 52 | "Set-Cookie": createRefreshTokenCookie(refreshToken), 53 | }, 54 | }, 55 | ); 56 | }), 57 | 58 | http.post("/auth/register", async ({ request }) => { 59 | const body = await request.json(); 60 | 61 | await delay(); 62 | 63 | if (mockUsers.some((u) => u.email === body.email)) { 64 | return HttpResponse.json( 65 | { 66 | message: "Пользователь уже существует", 67 | code: "USER_EXISTS", 68 | }, 69 | { status: 400 }, 70 | ); 71 | } 72 | 73 | const newUser: ApiSchemas["User"] = { 74 | id: String(mockUsers.length + 1), 75 | email: body.email, 76 | }; 77 | 78 | const { accessToken, refreshToken } = await generateTokens({ 79 | userId: newUser.id, 80 | email: newUser.email, 81 | }); 82 | 83 | mockUsers.push(newUser); 84 | userPasswords.set(body.email, body.password); 85 | 86 | return HttpResponse.json( 87 | { 88 | accessToken: accessToken, 89 | user: newUser, 90 | }, 91 | { 92 | status: 201, 93 | headers: { 94 | "Set-Cookie": createRefreshTokenCookie(refreshToken), 95 | }, 96 | }, 97 | ); 98 | }), 99 | http.post("/auth/refresh", async ({ cookies }) => { 100 | const refreshToken = cookies.refreshToken; 101 | 102 | if (!refreshToken) { 103 | return HttpResponse.json( 104 | { 105 | message: "Refresh token не найден", 106 | code: "REFRESH_TOKEN_MISSING", 107 | }, 108 | { status: 401 }, 109 | ); 110 | } 111 | 112 | try { 113 | const session = await verifyToken(refreshToken); 114 | const user = mockUsers.find((u) => u.id === session.userId); 115 | 116 | if (!user) { 117 | throw new Error("User not found"); 118 | } 119 | 120 | const { accessToken, refreshToken: newRefreshToken } = 121 | await generateTokens({ 122 | userId: user.id, 123 | email: user.email, 124 | }); 125 | 126 | return HttpResponse.json( 127 | { 128 | accessToken, 129 | user, 130 | }, 131 | { 132 | status: 200, 133 | headers: { 134 | "Set-Cookie": createRefreshTokenCookie(newRefreshToken), 135 | }, 136 | }, 137 | ); 138 | } catch (error) { 139 | console.error("Error refreshing token:", error); 140 | return HttpResponse.json( 141 | { 142 | message: "Недействительный refresh token", 143 | code: "INVALID_REFRESH_TOKEN", 144 | }, 145 | { status: 401 }, 146 | ); 147 | } 148 | }), 149 | ]; 150 | -------------------------------------------------------------------------------- /src/shared/ui/kit/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { XIcon } from "lucide-react" 4 | 5 | import { cn } from "@/shared/lib/css" 6 | 7 | function Dialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function DialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return 17 | } 18 | 19 | function DialogPortal({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return 23 | } 24 | 25 | function DialogClose({ 26 | ...props 27 | }: React.ComponentProps) { 28 | return 29 | } 30 | 31 | function DialogOverlay({ 32 | className, 33 | ...props 34 | }: React.ComponentProps) { 35 | return ( 36 | 44 | ) 45 | } 46 | 47 | function DialogContent({ 48 | className, 49 | children, 50 | ...props 51 | }: React.ComponentProps) { 52 | return ( 53 | 54 | 55 | 63 | {children} 64 | 65 | 66 | Close 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 74 | return ( 75 |
80 | ) 81 | } 82 | 83 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { 84 | return ( 85 |
93 | ) 94 | } 95 | 96 | function DialogTitle({ 97 | className, 98 | ...props 99 | }: React.ComponentProps) { 100 | return ( 101 | 106 | ) 107 | } 108 | 109 | function DialogDescription({ 110 | className, 111 | ...props 112 | }: React.ComponentProps) { 113 | return ( 114 | 119 | ) 120 | } 121 | 122 | export { 123 | Dialog, 124 | DialogClose, 125 | DialogContent, 126 | DialogDescription, 127 | DialogFooter, 128 | DialogHeader, 129 | DialogOverlay, 130 | DialogPortal, 131 | DialogTitle, 132 | DialogTrigger, 133 | } 134 | -------------------------------------------------------------------------------- /src/shared/ui/kit/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | FormProvider, 7 | useFormContext, 8 | useFormState, 9 | type ControllerProps, 10 | type FieldPath, 11 | type FieldValues, 12 | } from "react-hook-form" 13 | 14 | import { cn } from "@/shared/lib/css" 15 | import { Label } from "@/shared/ui/kit/label" 16 | 17 | const Form = FormProvider 18 | 19 | type FormFieldContextValue< 20 | TFieldValues extends FieldValues = FieldValues, 21 | TName extends FieldPath = FieldPath, 22 | > = { 23 | name: TName 24 | } 25 | 26 | const FormFieldContext = React.createContext( 27 | {} as FormFieldContextValue 28 | ) 29 | 30 | const FormField = < 31 | TFieldValues extends FieldValues = FieldValues, 32 | TName extends FieldPath = FieldPath, 33 | >({ 34 | ...props 35 | }: ControllerProps) => { 36 | return ( 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | const useFormField = () => { 44 | const fieldContext = React.useContext(FormFieldContext) 45 | const itemContext = React.useContext(FormItemContext) 46 | const { getFieldState } = useFormContext() 47 | const formState = useFormState({ name: fieldContext.name }) 48 | const fieldState = getFieldState(fieldContext.name, formState) 49 | 50 | if (!fieldContext) { 51 | throw new Error("useFormField should be used within ") 52 | } 53 | 54 | const { id } = itemContext 55 | 56 | return { 57 | id, 58 | name: fieldContext.name, 59 | formItemId: `${id}-form-item`, 60 | formDescriptionId: `${id}-form-item-description`, 61 | formMessageId: `${id}-form-item-message`, 62 | ...fieldState, 63 | } 64 | } 65 | 66 | type FormItemContextValue = { 67 | id: string 68 | } 69 | 70 | const FormItemContext = React.createContext( 71 | {} as FormItemContextValue 72 | ) 73 | 74 | function FormItem({ className, ...props }: React.ComponentProps<"div">) { 75 | const id = React.useId() 76 | 77 | return ( 78 | 79 |
84 | 85 | ) 86 | } 87 | 88 | function FormLabel({ 89 | className, 90 | ...props 91 | }: React.ComponentProps) { 92 | const { error, formItemId } = useFormField() 93 | 94 | return ( 95 |