diff --git a/README.md b/README.md index 1b8def584..55a12070b 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,4 @@ _Link to the backend repo:_ https://github.com/chertik77/TaskPro-backend ## Languages and Tools -![Languages and Tools](https://skills.syvixor.com/api/icons?i=ts,react,dndkit,baseui,tanstack,betterauth,heyapi,stanjs,axios,datefns,motion,reactdatepicker,reacthookform,lucide,valibot,tailwind,tailwindmerge,fusejs,commitlint,eslint,prettier,githubactions,fsd,yarn,vercel,vite,vscode,figma&perline=10) +![Languages and Tools](https://skills.syvixor.com/api/icons?i=ts,react,dndkit,baseui,tanstack,betterauth,heyapi,stanjs,axios,datefns,motion,reactdatepicker,reacthookform,lucide,thesvg,valibot,tailwind,tailwindmerge,fusejs,commitlint,eslint,prettier,githubactions,fsd,yarn,vercel,vite,vscode,figma&perline=10) diff --git a/eslint.config.ts b/eslint.config.ts index 695b42f45..c78bfe13c 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -46,6 +46,14 @@ export default defineConfig( 'Direct access to `import.meta.env` is forbidden. Use `@/shared/config` instead.' } ], + 'no-restricted-imports': [ + 'error', + { + name: 'motion/react', + importNames: ['motion'], + message: 'Do not import motion. Use m from motion/react-m instead.' + } + ], 'newline-before-return': 'error', 'arrow-body-style': ['warn', 'as-needed'] } @@ -53,6 +61,7 @@ export default defineConfig( { rules: { 'jsx-a11y/no-autofocus': 'off', + '@eslint-react/no-array-index-key': 'off', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/consistent-indexed-object-style': 'error', diff --git a/index.html b/index.html index a486f178a..47c93febd 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,33 @@ + diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index 28dd9abd1..a911b1612 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -8,13 +8,14 @@ export default defineConfig({ input: `${process.env.VITE_API_BASE_URL}/openapi.json`, output: 'src/shared/api/generated', plugins: [ + 'valibot', '@tanstack/react-query', { name: '@hey-api/client-axios', + throwOnError: true, runtimeConfigPath: 'src/shared/lib/openapi/hey-api.ts' }, { enums: 'javascript', name: '@hey-api/typescript' }, - { name: '@hey-api/sdk', transformer: true }, - { name: '@hey-api/transformers', dates: true } + { name: '@hey-api/sdk', validator: true } ] }) diff --git a/package.json b/package.json index 8f752f8c7..eacda3144 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,10 @@ { "name": "task-pro-frontend", "private": true, + "type": "module", "engines": { "node": ">=24.0.0" }, - "version": "0.0.0", - "type": "module", "scripts": { "dev": "vite --open", "build": "tsc && vite build", @@ -20,6 +19,7 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@better-auth/passkey": "^1.6.23", "@daypicker/react": "^10.0.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -28,6 +28,7 @@ "@radix-ui/react-roving-focus": "^1.1.15", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "1.170.17", + "@thesvg/react": "^3.2.8", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "axios": "^1.18.1", @@ -41,9 +42,9 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "7.81.0", - "sonner": "^2.0.7", "stan-js": "^1.9.1", "tailwind-merge": "^3.6.0", + "use-context-selector": "^2.0.0", "use-debounce": "^10.1.1", "valibot": "^1.4.2" }, diff --git a/public/social-icons.svg b/public/social-icons.svg deleted file mode 100644 index 66cfa4395..000000000 --- a/public/social-icons.svg +++ /dev/null @@ -1,57 +0,0 @@ - diff --git a/src/app/ambient/react-query.d.ts b/src/app/ambient/react-query.d.ts index 3a02f16ae..b0fadb7bb 100644 --- a/src/app/ambient/react-query.d.ts +++ b/src/app/ambient/react-query.d.ts @@ -1,13 +1,15 @@ import type { QueryKey } from '@tanstack/react-query' import type { AxiosError } from 'axios' +import type { BetterFetchError } from 'better-auth/react' declare module '@tanstack/react-query' { interface Register { - defaultError: AxiosError + defaultError: AxiosError | BetterFetchError mutationMeta: { invalidates?: QueryKey[] successMessage?: string - errorMessage?: string | ((error?: AxiosError) => string) + errorMessage?: + string | ((error?: AxiosError | BetterFetchError) => string | undefined) } } } diff --git a/src/app/index.css b/src/app/index.css index 5e5008483..cbec79025 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -32,6 +32,8 @@ } @theme inline { + --default-transition-duration: 0.3s; + --font-*: initial; --font-poppins: 'Poppins', sans-serif; @@ -45,46 +47,47 @@ --color-white: #ffffff; --color-white-soft: #fcfcfc; --color-white-muted: #f6f6f7; - --color-brand: #bedbb0; - --color-brand-light: #9dc888; - --color-blue: #8fa1d0; + --color-accent: var(--accent-color); + --color-priority-low: #9bb7d4; + --color-priority-medium: #e5a8b7; --color-red: #dc2626; - --color-pink: #e09cb5; + --color-green: #bedbb0; + --color-green-light: #9dc888; --text-*: initial; - --text-xs: 8px; + --text-xs: calc(8px * var(--font-scale)); --text-xs--line-height: normal; --text-xs--font-weight: 400; --text-xs--letter-spacing: -0.16px; - --text-sm: 10px; + --text-sm: calc(10px * var(--font-scale)); --text-sm--line-height: normal; --text-sm--font-weight: 400; --text-sm--letter-spacing: -0.2px; - --text-md: 12px; + --text-md: calc(12px * var(--font-scale)); --text-md--line-height: normal; --text-md--letter-spacing: -0.24px; - --text-base: 14px; + --text-base: calc(14px * var(--font-scale)); --text-base--line-height: 1.28; --text-base--letter-spacing: -0.28px; - --text-lg: 16px; + --text-lg: calc(16px * var(--font-scale)); --text-lg--line-height: normal; --text-lg--letter-spacing: -0.64px; - --text-xl: 18px; + --text-xl: calc(18px * var(--font-scale)); --text-xl--line-height: normal; --text-xl--font-weight: 500; --text-xl--letter-spacing: -0.36px; - --text-2xl: 28px; + --text-2xl: calc(28px * var(--font-scale)); --text-2xl--line-height: normal; --text-2xl--font-weight: 600; --text-2xl--letter-spacing: -1.12px; - --text-3xl: 40px; + --text-3xl: calc(40px * var(--font-scale)); --text-3xl--line-height: normal; --text-3xl--font-weight: 600; --text-3xl--letter-spacing: -1.6px; @@ -146,13 +149,50 @@ } @layer base { + :root { + --accent-color: var(--color-green); + --font-scale: 1; + } + button:not(:disabled), [role='button']:not(:disabled) { cursor: pointer; } + + html[data-animations='off'] *, + html[data-animations='off'] *::before, + html[data-animations='off'] *::after { + animation-duration: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: 0s !important; + scroll-behavior: auto !important; + } + + @media (prefers-reduced-motion: reduce) { + html[data-animations='system'] *, + html[data-animations='system'] *::before, + html[data-animations='system'] *::after { + animation-duration: 0s !important; + animation-iteration-count: 1 !important; + transition-duration: 0s !important; + scroll-behavior: auto !important; + } + } + + html[data-font-size='small'] { + --font-scale: 0.92; + } + + html[data-font-size='medium'] { + --font-scale: 1; + } + + html[data-font-size='large'] { + --font-scale: 1.12; + } } -@custom-variant dark (&:where(.dark, .dark *)); +@custom-variant dark (&:where(html[data-theme='dark'] *)); @custom-variant hocus { @media (hover: hover) { @@ -209,11 +249,11 @@ } @utility styled-outline { - @apply outline-brand outline-2; + @apply outline-accent outline-2; } @utility bg-soft-green { - @apply to-brand bg-linear-180 from-white; + @apply to-green bg-linear-180 from-white; } @utility disable-text-selection { diff --git a/src/app/providers/RouteProvider.tsx b/src/app/providers/RouteProvider.tsx index 58a614c59..8869139db 100644 --- a/src/app/providers/RouteProvider.tsx +++ b/src/app/providers/RouteProvider.tsx @@ -9,7 +9,7 @@ export const RouterProvider = () => ( defaultPendingComponent={() => (
- +
)} diff --git a/src/app/providers/ToastProvider.tsx b/src/app/providers/ToastProvider.tsx index 5aaae6b56..c302a872e 100644 --- a/src/app/providers/ToastProvider.tsx +++ b/src/app/providers/ToastProvider.tsx @@ -1,22 +1,3 @@ -import { Toaster } from 'sonner' +import { Toaster } from '@/shared/ui' -import { useMe } from '@/entities/user' - -import { useTabletAndBelowMediaQuery } from '@/shared/lib' - -export const ToastProvider = () => { - const user = useMe() - - const isTabletAndBelow = useTabletAndBelowMediaQuery() - - return ( - - ) -} +export const ToastProvider = () => diff --git a/src/app/routes/dashboard/$boardId.tsx b/src/app/routes/dashboard/$boardId.tsx index aa0dcebee..bdff691a5 100644 --- a/src/app/routes/dashboard/$boardId.tsx +++ b/src/app/routes/dashboard/$boardId.tsx @@ -7,5 +7,14 @@ import { Board } from '@/widgets/kanban-board' export const Route = createFileRoute('/dashboard/$boardId')({ component: Board, validateSearch: TaskContracts.TaskSearchSchema, - search: { middlewares: [stripSearchParams({ search: '' })] } + search: { + middlewares: [ + stripSearchParams({ + search: '', + priority: [], + deadline: [], + labels: [] + }) + ] + } }) diff --git a/src/app/routes/dashboard/settings/general.tsx b/src/app/routes/dashboard/settings/general.tsx new file mode 100644 index 000000000..c5e87f123 --- /dev/null +++ b/src/app/routes/dashboard/settings/general.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { GeneralSettings } from '@/features/settings/general' + +export const Route = createFileRoute('/dashboard/settings/general')({ + component: GeneralSettings +}) diff --git a/src/app/routes/dashboard/settings/index.tsx b/src/app/routes/dashboard/settings/index.tsx new file mode 100644 index 000000000..d52619e8b --- /dev/null +++ b/src/app/routes/dashboard/settings/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard/settings/')({ + beforeLoad: () => { + throw redirect({ to: '/dashboard/settings/general' }) + } +}) diff --git a/src/app/routes/dashboard/settings/labels.tsx b/src/app/routes/dashboard/settings/labels.tsx new file mode 100644 index 000000000..0298b1705 --- /dev/null +++ b/src/app/routes/dashboard/settings/labels.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { LabelsSettings } from '@/features/settings/labels' + +export const Route = createFileRoute('/dashboard/settings/labels')({ + component: LabelsSettings +}) diff --git a/src/app/routes/dashboard/settings/layout.tsx b/src/app/routes/dashboard/settings/layout.tsx new file mode 100644 index 000000000..8a4dd1ed4 --- /dev/null +++ b/src/app/routes/dashboard/settings/layout.tsx @@ -0,0 +1,6 @@ +import { SettingsPage } from '@/pages/settings' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/dashboard/settings')({ + component: SettingsPage +}) diff --git a/src/app/routes/dashboard/settings/security.tsx b/src/app/routes/dashboard/settings/security.tsx new file mode 100644 index 000000000..1abf48b8f --- /dev/null +++ b/src/app/routes/dashboard/settings/security.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { SecuritySettings } from '@/features/settings/security' + +export const Route = createFileRoute('/dashboard/settings/security')({ + component: SecuritySettings +}) diff --git a/src/app/routes/dashboard/settings/task.tsx b/src/app/routes/dashboard/settings/task.tsx new file mode 100644 index 000000000..127c82328 --- /dev/null +++ b/src/app/routes/dashboard/settings/task.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { TaskSettings } from '@/features/settings/task' + +export const Route = createFileRoute('/dashboard/settings/task')({ + component: TaskSettings +}) diff --git a/src/app/routes/index.tsx b/src/app/routes/index.tsx index b84c3a470..398f67e77 100644 --- a/src/app/routes/index.tsx +++ b/src/app/routes/index.tsx @@ -1,12 +1,13 @@ import { WelcomePage } from '@/pages/welcome' import { createFileRoute, redirect } from '@tanstack/react-router' -import { toast } from 'sonner' import * as v from 'valibot' import { sessionQueries } from '@/entities/user' +import { toast } from '@/shared/lib' + export const OauthErrorSearchSchema = v.object({ - error: v.optional(v.pipe(v.literal('access_denied'))) + error: v.optional(v.pipe(v.string())) }) export const Route = createFileRoute('/')({ diff --git a/src/entities/board/api/queries.ts b/src/entities/board/api/queries.ts new file mode 100644 index 000000000..0f7a805b0 --- /dev/null +++ b/src/entities/board/api/queries.ts @@ -0,0 +1,22 @@ +import { queryOptions } from '@tanstack/react-query' + +import { getAllBoards, getBoardById } from '@/shared/api' + +export const boardQueries = { + all: () => ['boards'], + lists: () => [...boardQueries.all(), 'list'], + list: () => + queryOptions({ + queryKey: boardQueries.lists(), + queryFn: async () => (await getAllBoards()).data, + staleTime: 60_000 // 1 minute + }), + details: () => [...boardQueries.all(), 'detail'], + detail: (id: string) => + queryOptions({ + queryKey: [...boardQueries.details(), id], + queryFn: async () => (await getBoardById({ path: { boardId: id } })).data, + enabled: !!id, + staleTime: 5 * 60_000 // 5 minutes + }) +} diff --git a/src/entities/board/config/bg-images.ts b/src/entities/board/config/bg-images.ts index f5a1dd3a2..9d30dea6e 100644 --- a/src/entities/board/config/bg-images.ts +++ b/src/entities/board/config/bg-images.ts @@ -1,4 +1,16 @@ -export const BOARD_BG_IMAGES = [ +import type { BoardBackgroundId } from '@/shared/api' + +type BoardBackground = { + id: BoardBackgroundId + icon: + | string + | { + light: string + dark: string + } +} + +export const BOARD_BG_IMAGES: BoardBackground[] = [ { id: 'default', icon: { @@ -68,5 +80,3 @@ export const BOARD_BG_IMAGES = [ icon: 'https://res.cloudinary.com/dmbnnewoy/image/upload/v1707102068/TaskPro/board_bg_images/bg_icons/northern_lights.png' } ] as const - -export const BOARD_BG_IMAGES_IDS = BOARD_BG_IMAGES.map(i => i.id) diff --git a/src/entities/board/config/icon.ts b/src/entities/board/config/icon.ts deleted file mode 100644 index a7d83d02f..000000000 --- a/src/entities/board/config/icon.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const BOARD_ICONS = [ - 'sparkles', - 'layout', - 'kanban', - 'layers', - 'compass', - 'target', - 'sunrise', - 'orbit', - 'palette', - 'leaf' -] as const diff --git a/src/entities/board/index.ts b/src/entities/board/index.ts index 44ea69609..3cd79e58d 100644 --- a/src/entities/board/index.ts +++ b/src/entities/board/index.ts @@ -1,9 +1,7 @@ -export * as BoardContracts from './model/contracts' -export * as BoardTypes from './model/types' export { useGetParamBoardId } from './lib/useGetParamBoardId' export { WHITE_TEXT_BOARD_BG_IDS } from './config/white-text-board-bg-ids' -export { BOARD_BG_IMAGES_IDS } from './config/bg-images' -export { BOARD_ICONS } from './config/icon' export { FormBgImageSelector } from './ui/FormBgImageSelector' +export { BoardBackgroundImage } from './ui/BoardBackgroundImage' export { BoardErrorView } from './ui/BoardErrorView' export { FormIconSelector } from './ui/FormIconSelector' +export { boardQueries } from './api/queries' diff --git a/src/entities/board/model/contracts.ts b/src/entities/board/model/contracts.ts deleted file mode 100644 index 479b53689..000000000 --- a/src/entities/board/model/contracts.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as v from 'valibot' - -import { ColumnSchema } from '@/entities/column/@x/board' - -import { BOARD_BG_IMAGES_IDS } from '../config/bg-images' -import { BOARD_ICONS } from '../config/icon' - -export const BoardSchema = v.object({ - id: v.string(), - title: v.string(), - icon: v.picklist(BOARD_ICONS), - background: v.object({ - identifier: v.picklist(BOARD_BG_IMAGES_IDS), - url: v.nullable(v.string()) - }), - columns: v.array(ColumnSchema) -}) - -export const BoardsSchema = v.array(v.omit(BoardSchema, ['columns'])) diff --git a/src/entities/board/model/types.ts b/src/entities/board/model/types.ts deleted file mode 100644 index fa12ab5ae..000000000 --- a/src/entities/board/model/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { InferOutput } from 'valibot' -import type { BOARD_BG_IMAGES_IDS } from '../config/bg-images' -import type { BOARD_ICONS } from '../config/icon' -import type { BoardSchema, BoardsSchema } from './contracts' - -export type BoardSchema = InferOutput -export type BoardsSchema = InferOutput -export type BoardBgImageId = (typeof BOARD_BG_IMAGES_IDS)[number] -export type BoardIcon = (typeof BOARD_ICONS)[number] diff --git a/src/entities/board/ui/BoardBackgroundImage.tsx b/src/entities/board/ui/BoardBackgroundImage.tsx new file mode 100644 index 000000000..7e53d3beb --- /dev/null +++ b/src/entities/board/ui/BoardBackgroundImage.tsx @@ -0,0 +1,21 @@ +import { useSettings } from '@/entities/setting/@x/board' + +import { cn } from '@/shared/lib' + +export const BoardBackgroundImage = ({ url }: { url: string | null }) => { + const { data: backgroundBlur } = useSettings( + select => select.general.boardBackgroundBlur + ) + + return ( +
+ ) +} diff --git a/src/entities/board/ui/BoardErrorView.tsx b/src/entities/board/ui/BoardErrorView.tsx index 758941cac..5d2e5532f 100644 --- a/src/entities/board/ui/BoardErrorView.tsx +++ b/src/entities/board/ui/BoardErrorView.tsx @@ -3,23 +3,20 @@ import type { AxiosError } from 'axios' import { useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { getBoardByIdQueryKey } from '@/shared/api' import { Button } from '@/shared/ui' -import { useGetParamBoardId } from '../lib/useGetParamBoardId' +import { boardQueries } from '../api/queries' export const BoardErrorView = ({ error }: { error: AxiosError }) => { const navigate = useNavigate() const queryClient = useQueryClient() - const boardId = useGetParamBoardId() - const isBoardNotFound = error.response?.status === 404 const refetchBoard = () => { queryClient.refetchQueries({ - queryKey: getBoardByIdQueryKey({ path: { boardId } }) + queryKey: boardQueries.details() }) } diff --git a/src/entities/board/ui/FormBgImageSelector.tsx b/src/entities/board/ui/FormBgImageSelector.tsx index 570274427..91b32d1da 100644 --- a/src/entities/board/ui/FormBgImageSelector.tsx +++ b/src/entities/board/ui/FormBgImageSelector.tsx @@ -1,7 +1,7 @@ import { Radio } from '@base-ui/react/radio' import { RadioGroup } from '@base-ui/react/radio-group' -import { useMe } from '@/entities/user/@x/board' +import { useSettings } from '@/entities/setting/@x/board' import { resolveTheme } from '@/shared/config' import { FormItem, useFormField } from '@/shared/ui' @@ -13,7 +13,7 @@ export const FormBgImageSelector = () => { field: { value, onChange } } = useFormField() - const user = useMe() + const { data: theme } = useSettings(state => state.general.theme) return ( { group-data-checked:scale-125' width={28} height={28} - src={ - typeof icon === 'object' - ? icon[resolveTheme(user?.theme)] - : icon - } + src={typeof icon === 'object' ? icon[resolveTheme(theme)] : icon} alt={id} /> diff --git a/src/entities/board/ui/FormIconSelector.tsx b/src/entities/board/ui/FormIconSelector.tsx index 593b89935..19545b068 100644 --- a/src/entities/board/ui/FormIconSelector.tsx +++ b/src/entities/board/ui/FormIconSelector.tsx @@ -2,10 +2,9 @@ import { Radio } from '@base-ui/react/radio' import { RadioGroup } from '@base-ui/react/radio-group' import { DynamicIcon } from 'lucide-react/dynamic' +import { BoardIcon } from '@/shared/api' import { FormItem, useFormField } from '@/shared/ui' -import { BOARD_ICONS } from '../config/icon' - export const FormIconSelector = () => { const { field: { value, onChange } @@ -16,17 +15,16 @@ export const FormIconSelector = () => { className='flex items-center gap-2' value={value} onValueChange={onChange}> - {BOARD_ICONS.map(icon => ( + {Object.values(BoardIcon).map(icon => ( + className='focus-visible:styled-outline group cursor-pointer'> diff --git a/src/entities/column/@x/board.ts b/src/entities/column/@x/board.ts deleted file mode 100644 index d86386931..000000000 --- a/src/entities/column/@x/board.ts +++ /dev/null @@ -1 +0,0 @@ -export { ColumnSchema } from '../model/contracts' diff --git a/src/entities/column/index.ts b/src/entities/column/index.ts index caba07e3a..09203c404 100644 --- a/src/entities/column/index.ts +++ b/src/entities/column/index.ts @@ -1,3 +1 @@ -export * as ColumnTypes from './model/types' -export * as ColumnContracts from './model/contracts' export { Column } from './ui/Column' diff --git a/src/entities/column/model/contracts.ts b/src/entities/column/model/contracts.ts deleted file mode 100644 index e3253bfd4..000000000 --- a/src/entities/column/model/contracts.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as v from 'valibot' - -import { TaskSchema } from '@/entities/task/@x/column' - -export const ColumnSchema = v.object({ - id: v.string(), - title: v.string(), - order: v.number(), - boardId: v.string(), - tasks: v.optional(v.array(TaskSchema)) -}) - -export const ColumnsSchema = v.array(ColumnSchema) diff --git a/src/entities/column/model/types.ts b/src/entities/column/model/types.ts deleted file mode 100644 index 81a63d440..000000000 --- a/src/entities/column/model/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { InferOutput } from 'valibot' -import type { ColumnSchema, ColumnsSchema } from './contracts' - -export type ColumnSchema = InferOutput -export type ColumnsSchema = InferOutput diff --git a/src/entities/column/ui/Column.tsx b/src/entities/column/ui/Column.tsx index 2892c7522..22a89cd21 100644 --- a/src/entities/column/ui/Column.tsx +++ b/src/entities/column/ui/Column.tsx @@ -1,15 +1,15 @@ +import type { Column as TColumn } from '@/shared/api' import type { ComponentProps, ReactNode } from 'react' -import type { ColumnSchema } from '../model/types' import { createContext, use, useMemo } from 'react' import { mergeProps, useRender } from '@base-ui/react' import { ScrollArea } from '@base-ui/react/scroll-area' import { GripVerticalIcon } from 'lucide-react' -import { cn, useTabletAndBelowMediaQuery } from '@/shared/lib' +import { cn, pluralize, useMediaQuery } from '@/shared/lib' type ColumnContext = { - column: ColumnSchema + column: TColumn } const ColumnContext = createContext(undefined) @@ -76,12 +76,33 @@ const ColumnTitle = ({ className, ...props }: ComponentProps<'p'>) => { ) } +type ColumnTaskCountProps = ComponentProps<'span'> & { + count: number +} + +const ColumnTaskCount = ({ + count, + className, + ...props +}: ColumnTaskCountProps) => ( + + {count} + +) + const ColumnScrollArea = ({ children, className, ...props }: ScrollArea.Root.Props) => { - const isTabletAndBelow = useTabletAndBelowMediaQuery() + const isTabletAndBelow = useMediaQuery('(max-width: 1025px)') return ( = { type: 'button', className: cn( - `focus-visible:styled-outline hocus:text-black - dark:hocus:text-white-soft dark:text-white-soft/50 text-black/50 - [&_svg]:size-4`, + `focus-visible:styled-outline dark:text-white-soft text-black + [&_svg]:size-4 [&_svg]:opacity-50 hocus:[&_svg]:opacity-100 + [&_svg]:transition-opacity`, className ) } @@ -172,6 +193,7 @@ export const Column = Object.assign(ColumnProvider, { Header: ColumnHeader, DragActivator: ColumnDragActivator, Title: ColumnTitle, + TaskCount: ColumnTaskCount, ScrollArea: ColumnScrollArea, ScrollAreaViewport: ColumnScrollAreaViewport, ScrollAreaContent: ColumnScrollAreaContent, diff --git a/src/entities/label/@x/task.ts b/src/entities/label/@x/task.ts index 9ad249e4e..9ffd51389 100644 --- a/src/entities/label/@x/task.ts +++ b/src/entities/label/@x/task.ts @@ -1,3 +1,2 @@ -export { LabelSchema } from '../model/contracts' -export { LABEL_COLOR_MAP } from '../config/color-map' +export { LABEL_COLOR_MAP, LABEL_BASE_COLOR_MAP } from '../config/color-map' export { Label } from '../ui/Label' diff --git a/src/entities/label/api/queries.ts b/src/entities/label/api/queries.ts new file mode 100644 index 000000000..bac1a6b92 --- /dev/null +++ b/src/entities/label/api/queries.ts @@ -0,0 +1,15 @@ +import { queryOptions } from '@tanstack/react-query' + +import { getAllLabels } from '@/shared/api' + +export const labelQueries = { + all: () => ['labels'] as const, + lists: () => [...labelQueries.all(), 'list'] as const, + list: () => + queryOptions({ + queryKey: labelQueries.lists(), + queryFn: async () => (await getAllLabels()).data, + staleTime: Infinity, + gcTime: Infinity + }) +} diff --git a/src/entities/label/config/color-map.ts b/src/entities/label/config/color-map.ts index a838a12ac..353574ff8 100644 --- a/src/entities/label/config/color-map.ts +++ b/src/entities/label/config/color-map.ts @@ -1,6 +1,6 @@ -import type { LABEL_COLORS } from './colors' +import type { AccentColor } from '@/shared/api' -export const LABEL_COLOR_MAP: Record<(typeof LABEL_COLORS)[number], string> = { +export const LABEL_COLOR_MAP: Record = { blue: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300', purple: 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300', @@ -15,7 +15,7 @@ export const LABEL_COLOR_MAP: Record<(typeof LABEL_COLORS)[number], string> = { gray: 'bg-gray-100 text-gray-700 dark:bg-gray-500/15 dark:text-gray-300' } as const -export const COLOR_MAP: Record<(typeof LABEL_COLORS)[number], string> = { +export const LABEL_BASE_COLOR_MAP: Record = { blue: 'bg-blue-500', purple: 'bg-violet-500', green: 'bg-emerald-500', diff --git a/src/entities/label/config/colors.ts b/src/entities/label/config/colors.ts deleted file mode 100644 index 455e06b43..000000000 --- a/src/entities/label/config/colors.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const LABEL_COLORS = [ - 'blue', - 'purple', - 'red', - 'gray', - 'cyan', - 'yellow', - 'green', - 'indigo' -] as const diff --git a/src/entities/label/index.ts b/src/entities/label/index.ts index a7c0789f6..5c84496d2 100644 --- a/src/entities/label/index.ts +++ b/src/entities/label/index.ts @@ -1,8 +1,7 @@ -export * as LabelTypes from './model/types' -export * as LabelContracts from './model/contracts' export { FormLabelsCombobox } from './ui/FormLabelsCombobox' -export { COLOR_MAP, LABEL_COLOR_MAP } from './config/color-map' -export { LABEL_COLORS } from './config/colors' +export { LABEL_BASE_COLOR_MAP, LABEL_COLOR_MAP } from './config/color-map' export { useLabelCombobox } from './lib/useLabelCombobox' +export { LabelColorPicker } from './ui/LabelColorPicker' export { useLabelModalStore } from './model/modal.store' +export { labelQueries } from './api/queries' export { Label } from './ui/Label' diff --git a/src/entities/label/lib/useLabelCombobox.ts b/src/entities/label/lib/useLabelCombobox.ts index c5c913aa7..c328dff4e 100644 --- a/src/entities/label/lib/useLabelCombobox.ts +++ b/src/entities/label/lib/useLabelCombobox.ts @@ -1,17 +1,18 @@ -import type { LabelSchema } from '../model/types' +import type { Label } from '@/shared/api' import { useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { getAllLabelsOptions } from '@/shared/api' - +import { labelQueries } from '../api/queries' import { useLabelModalStore } from '../model/modal.store' const CREATE_SENTINEL = '__create' const isCreateOption = (id: string) => id === CREATE_SENTINEL -const buildCreateOption = (name: string): LabelSchema => ({ +const buildCreateOption = ( + name: string +): Pick => ({ id: CREATE_SENTINEL, name: `Create "${name}"`, color: 'gray' @@ -22,11 +23,7 @@ export const useLabelCombobox = (labelsValues: string[] | undefined) => { const { setModal } = useLabelModalStore() - const { - data: labels = [], - isPending, - error - } = useQuery(getAllLabelsOptions()) + const { data: labels = [], isPending, error } = useQuery(labelQueries.list()) const labelMap = useMemo(() => new Map(labels.map(l => [l.id, l])), [labels]) @@ -50,7 +47,7 @@ export const useLabelCombobox = (labelsValues: string[] | undefined) => { isOpen: true, props: { name: inputValue, - onCreated: (newLabel: LabelSchema) => { + onCreated: (newLabel: Label) => { const current = labelsValues ?? [] onChange([...current, newLabel.id]) } diff --git a/src/entities/label/model/contracts.ts b/src/entities/label/model/contracts.ts deleted file mode 100644 index 990875e63..000000000 --- a/src/entities/label/model/contracts.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as v from 'valibot' - -import { LABEL_COLORS } from '../config/colors' - -export const LabelSchema = v.object({ - id: v.string(), - name: v.string(), - color: v.picklist(LABEL_COLORS) -}) - -export const LabelsSchema = v.array(LabelSchema) diff --git a/src/entities/label/model/types.ts b/src/entities/label/model/types.ts deleted file mode 100644 index f59b7ceb4..000000000 --- a/src/entities/label/model/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { InferOutput } from 'valibot' -import type { LabelSchema, LabelsSchema } from './contracts' - -export type LabelSchema = InferOutput -export type LabelsSchema = InferOutput diff --git a/src/entities/label/ui/FormLabelsCombobox.tsx b/src/entities/label/ui/FormLabelsCombobox.tsx index b98d9f217..0d583606e 100644 --- a/src/entities/label/ui/FormLabelsCombobox.tsx +++ b/src/entities/label/ui/FormLabelsCombobox.tsx @@ -1,4 +1,4 @@ -import type { LabelSchema } from '../model/types' +import type { Label } from '@/shared/api' import { cn } from '@/shared/lib' import { @@ -8,11 +8,11 @@ import { useFormField } from '@/shared/ui' -import { COLOR_MAP } from '../config/color-map' +import { LABEL_BASE_COLOR_MAP } from '../config/color-map' import { useLabelCombobox } from '../lib/useLabelCombobox' import { LabelChip } from './LabelChip' -const Combobox = createTypeSafeCombobox() +const Combobox = createTypeSafeCombobox() type FormLabelsComboboxProps = { labelsValues: string[] | undefined @@ -93,9 +93,12 @@ export const FormLabelsCombobox = ({ key={item.id} value={item.id}> - {item.name} + {item.name} )} diff --git a/src/entities/label/ui/LabelChip.tsx b/src/entities/label/ui/LabelChip.tsx index 1d4631df5..a0a680522 100644 --- a/src/entities/label/ui/LabelChip.tsx +++ b/src/entities/label/ui/LabelChip.tsx @@ -1,13 +1,13 @@ -import type { LabelSchema } from '../model/types' +import type { Label } from '@/shared/api' import { createTypeSafeCombobox } from '@/shared/ui' import { LABEL_COLOR_MAP } from '../config/color-map' -const Combobox = createTypeSafeCombobox() +const Combobox = createTypeSafeCombobox() type LabelChipProps = { - labelMap: Map + labelMap: Map values: string[] } diff --git a/src/features/label/add/ui/LabelColorPicker.tsx b/src/entities/label/ui/LabelColorPicker.tsx similarity index 64% rename from src/features/label/add/ui/LabelColorPicker.tsx rename to src/entities/label/ui/LabelColorPicker.tsx index 5fd0778cc..d71a67236 100644 --- a/src/features/label/add/ui/LabelColorPicker.tsx +++ b/src/entities/label/ui/LabelColorPicker.tsx @@ -1,8 +1,9 @@ import { Radio } from '@base-ui/react/radio' import { RadioGroup } from '@base-ui/react/radio-group' -import { COLOR_MAP, LABEL_COLORS } from '@/entities/label' +import { LABEL_BASE_COLOR_MAP } from '@/entities/label' +import { AccentColor } from '@/shared/api' import { cn } from '@/shared/lib' import { FormItem, useFormField } from '@/shared/ui' @@ -16,14 +17,14 @@ export const LabelColorPicker = () => { className='flex items-center gap-2' value={value} onValueChange={onChange}> - {LABEL_COLORS.map(color => ( + {Object.values(AccentColor).map(color => ( diff --git a/src/entities/setting/@x/board.ts b/src/entities/setting/@x/board.ts new file mode 100644 index 000000000..8710d1ebf --- /dev/null +++ b/src/entities/setting/@x/board.ts @@ -0,0 +1 @@ +export { useSettings } from '../model/useSettings' diff --git a/src/entities/setting/@x/task.ts b/src/entities/setting/@x/task.ts new file mode 100644 index 000000000..8710d1ebf --- /dev/null +++ b/src/entities/setting/@x/task.ts @@ -0,0 +1 @@ +export { useSettings } from '../model/useSettings' diff --git a/src/entities/setting/@x/user.ts b/src/entities/setting/@x/user.ts new file mode 100644 index 000000000..8710d1ebf --- /dev/null +++ b/src/entities/setting/@x/user.ts @@ -0,0 +1 @@ +export { useSettings } from '../model/useSettings' diff --git a/src/entities/setting/api/queries.ts b/src/entities/setting/api/queries.ts new file mode 100644 index 000000000..b907d5aa9 --- /dev/null +++ b/src/entities/setting/api/queries.ts @@ -0,0 +1,22 @@ +import type { GetAllSettingsResponse } from '@/shared/api' + +import { queryOptions } from '@tanstack/react-query' + +import { getAllSettings } from '@/shared/api' + +type SettingListOptions = { + select?: (data: GetAllSettingsResponse) => T +} + +export const settingQueries = { + all: () => ['settings'], + lists: () => [...settingQueries.all(), 'list'], + list: (options?: SettingListOptions) => + queryOptions({ + queryKey: settingQueries.lists(), + queryFn: async () => (await getAllSettings()).data, + staleTime: Infinity, + gcTime: Infinity, + select: options?.select + }) +} diff --git a/src/entities/setting/config/menu-data.ts b/src/entities/setting/config/menu-data.ts new file mode 100644 index 000000000..30e10f0cf --- /dev/null +++ b/src/entities/setting/config/menu-data.ts @@ -0,0 +1,9 @@ +import { ListTodo, Settings2, Tag, UserRound, UserRoundKey } from 'lucide-react' + +export const MENU_DATA = [ + { name: 'General', icon: Settings2, href: '/general' }, + { name: 'Profile', icon: UserRound, href: '/profile' }, + { name: 'Labels', icon: Tag, href: '/labels' }, + { name: 'Task Preferences', icon: ListTodo, href: '/task' }, + { name: 'Security & Access', icon: UserRoundKey, href: '/security' } +] as const diff --git a/src/entities/setting/index.ts b/src/entities/setting/index.ts new file mode 100644 index 000000000..2f91f6d3e --- /dev/null +++ b/src/entities/setting/index.ts @@ -0,0 +1,9 @@ +export * as SettingTypes from './model/types' +export * as SettingContracts from './model/contract' +export { Settings } from './ui/Settings' +export { SettingsSync } from './ui/SettingsSync' +export { settingQueries } from './api/queries' +export { ConfirmDeleteTrigger } from './ui/ConfirmDeleteTrigger' +export { SettingsMobileMenu } from './ui/SettingsMobileMenu' +export { useSettings } from './model/useSettings' +export { MENU_DATA } from './config/menu-data' diff --git a/src/entities/setting/model/contract.ts b/src/entities/setting/model/contract.ts new file mode 100644 index 000000000..56708def1 --- /dev/null +++ b/src/entities/setting/model/contract.ts @@ -0,0 +1,9 @@ +import * as v from 'valibot' + +import { vGeneralSettings, vLabelSettings, vTaskSettings } from '@/shared/api' + +export const SettingsSchema = v.object({ + general: vGeneralSettings, + task: vTaskSettings, + label: vLabelSettings +}) diff --git a/src/entities/setting/model/types.ts b/src/entities/setting/model/types.ts new file mode 100644 index 000000000..e5d97effe --- /dev/null +++ b/src/entities/setting/model/types.ts @@ -0,0 +1,39 @@ +import type { InferOutput } from 'valibot' +import type { SettingsSchema } from './contract' + +export type SettingsSchema = InferOutput + +type SettingsKeys = Omit + +type KeysOfType = { + [K in keyof T]-?: NonNullable extends TValue ? K : never +}[keyof T] + +export type Option = { + value: string + label: string +} + +export type SettingDefinition< + T, + TCustomKey extends keyof SettingsKeys = never +> = + | { + type: 'select' + key: KeysOfType, string | number> + title: string + description: string + options: Option[] + } + | { + type: 'switch' + key: KeysOfType, boolean> + title: string + description: string + } + | { + type: 'custom' + key: TCustomKey + title: string + description: string + } diff --git a/src/entities/setting/model/useSettings.ts b/src/entities/setting/model/useSettings.ts new file mode 100644 index 000000000..2cb23ed66 --- /dev/null +++ b/src/entities/setting/model/useSettings.ts @@ -0,0 +1,18 @@ +import type { GetAllSettingsResponse } from '@/shared/api' + +import { useQuery } from '@tanstack/react-query' + +import { settingQueries } from '../api/queries' + +type SettingsSelect = (data: GetAllSettingsResponse) => TData + +export const useSettings = ( + select?: SettingsSelect +) => { + const { data, isPending } = useQuery({ + ...settingQueries.list(), + select + }) + + return { data, isPending } +} diff --git a/src/entities/setting/ui/ConfirmDeleteTrigger.tsx b/src/entities/setting/ui/ConfirmDeleteTrigger.tsx new file mode 100644 index 000000000..80bef6feb --- /dev/null +++ b/src/entities/setting/ui/ConfirmDeleteTrigger.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react' + +import { cn } from '@/shared/lib' + +import { useSettings } from '../model/useSettings' + +type ConfirmDeleteTriggerProps = { + onConfirm: () => void + ariaLabel?: string + className?: string + children: ReactNode +} + +export const ConfirmDeleteTrigger = ({ + onConfirm, + ariaLabel, + className, + children +}: ConfirmDeleteTriggerProps) => { + const confirmBeforeDelete = useSettings( + state => state.general.confirmBeforeDelete + ) + + if (!confirmBeforeDelete) { + return ( + + ) + } + + return children +} diff --git a/src/entities/setting/ui/Settings.tsx b/src/entities/setting/ui/Settings.tsx new file mode 100644 index 000000000..d3215ef73 --- /dev/null +++ b/src/entities/setting/ui/Settings.tsx @@ -0,0 +1,183 @@ +import type { Variants } from 'motion/react' +import type { ReactNode } from 'react' + +import { ScrollArea } from '@base-ui/react/scroll-area' +import { stagger } from 'motion/react' +import * as m from 'motion/react-m' + +import { cn } from '@/shared/lib' +import { + Loader, + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, + SelectValue +} from '@/shared/ui' + +type SectionProps = { + title: string + children: ReactNode + isLoading?: boolean +} + +type ChildrenProps = { + children: ReactNode + className?: string +} + +const container: Variants = { + hidden: {}, + show: { transition: { delayChildren: stagger(0.05) } } +} + +const itemVariants: Variants = { + hidden: { opacity: 0, y: 8 }, + show: { opacity: 1, y: 0, transition: { ease: [0.22, 1, 0.36, 1] } }, + exit: { + opacity: 0, + scale: 0.98, + transition: { duration: 0.2, ease: 'easeIn' } + } +} + +const Section = ({ title, children, isLoading }: SectionProps) => ( +
+ {isLoading ? ( +
+ + Loading... +
+ ) : ( + +

{title}

+ + + + + {children} + + + + + + + +
+ )} +
+) + +const SubTitle = ({ children, className }: ChildrenProps) => ( +

{children}

+) + +const Item = ({ children, className }: ChildrenProps) => ( + + {children} + +) + +const Content = ({ children, className }: ChildrenProps) => ( +
+ {children} +
+) + +const Title = ({ children, className }: ChildrenProps) => ( +

+ {children} +

+) + +const Description = ({ children, className }: ChildrenProps) => ( +

+ {children} +

+) + +const Control = ({ children, className }: ChildrenProps) => ( +
+ {children} +
+) + +type SelectOption = { + value: T + label: string +} + +type SettingSelectProps = { + value: T | undefined + onChange: (value: T | null) => void + options: readonly SelectOption[] +} + +export const SettingSelect = ({ + value, + onChange, + options +}: SettingSelectProps) => ( + +) + +export const Settings = Object.assign(Section, { + SubTitle, + Item, + Content, + Title, + Description, + Control, + Select: SettingSelect +}) diff --git a/src/entities/setting/ui/SettingsMobileMenu.tsx b/src/entities/setting/ui/SettingsMobileMenu.tsx new file mode 100644 index 000000000..e5f92b2a3 --- /dev/null +++ b/src/entities/setting/ui/SettingsMobileMenu.tsx @@ -0,0 +1,66 @@ +import { Dialog } from '@base-ui/react/dialog' +import { useLocation, useNavigate } from '@tanstack/react-router' + +import { cn } from '@/shared/lib' + +import { MENU_DATA } from '../config/menu-data' + +type SettingsMobileMenuProps = { + isOpen: boolean + onOpenChange: (isOpen: boolean) => void +} + +export const SettingsMobileMenu = ({ + isOpen, + onOpenChange +}: SettingsMobileMenuProps) => { + const navigate = useNavigate() + + const { pathname } = useLocation() + + return ( + + + + + Settings +
    + {MENU_DATA.map(({ name, icon: Icon, href }) => ( +
  • + +
  • + ))} +
+
+
+
+ ) +} diff --git a/src/entities/setting/ui/SettingsSync.tsx b/src/entities/setting/ui/SettingsSync.tsx new file mode 100644 index 000000000..bb55e879f --- /dev/null +++ b/src/entities/setting/ui/SettingsSync.tsx @@ -0,0 +1,85 @@ +import { useEffect } from 'react' +import { MotionGlobalConfig } from 'motion/react' + +import { ACCENT_COLOR_MAP } from '@/entities/user/@x/setting' + +import { getStoredAppearance, setStoredAppearance } from '@/shared/config' + +import { useSettings } from '../model/useSettings' + +const storedAppearance = getStoredAppearance() + +export const SettingsSync = () => { + const { data: settings } = useSettings(settings => settings.general) + + const theme = settings?.theme ?? storedAppearance.theme + const fontSize = settings?.fontSize ?? storedAppearance.fontSize ?? 'medium' + const animations = + settings?.enableAnimations ?? storedAppearance.animations ?? 'system' + const accentColor = settings + ? ACCENT_COLOR_MAP[settings.accentColor] + : storedAppearance.accentColor + + useEffect(() => { + document.documentElement.dataset.animations = animations + + if (animations === 'system') { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + + const apply = () => { + MotionGlobalConfig.skipAnimations = mq.matches + } + + apply() + mq.addEventListener('change', apply) + + return () => mq.removeEventListener('change', apply) + } + + MotionGlobalConfig.skipAnimations = animations === 'off' + }, [animations]) + + useEffect(() => { + if (!accentColor) return + + document.documentElement.style.setProperty('--accent-color', accentColor) + }, [accentColor]) + + useEffect(() => { + document.documentElement.dataset.fontSize = fontSize + }, [fontSize]) + + useEffect(() => { + if (!theme) return + + const root = document.documentElement + + if (theme === 'system') { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + + const apply = () => { + root.dataset.theme = mq.matches ? 'dark' : 'light' + } + + apply() + mq.addEventListener('change', apply) + + return () => mq.removeEventListener('change', apply) + } + + root.dataset.theme = theme + }, [theme]) + + useEffect(() => { + if (!settings) return + + setStoredAppearance({ + theme: settings.theme, + accentColor: ACCENT_COLOR_MAP[settings.accentColor], + fontSize: settings.fontSize, + animations: settings.enableAnimations + }) + }, [settings]) + + return null +} diff --git a/src/entities/task/@x/column.ts b/src/entities/task/@x/column.ts deleted file mode 100644 index d7b08fbed..000000000 --- a/src/entities/task/@x/column.ts +++ /dev/null @@ -1 +0,0 @@ -export { TaskSchema } from '../model/contracts' diff --git a/src/entities/task/config/date-format-map.ts b/src/entities/task/config/date-format-map.ts new file mode 100644 index 000000000..a05cfe56c --- /dev/null +++ b/src/entities/task/config/date-format-map.ts @@ -0,0 +1,5 @@ +export const DATE_FORMAT_MAP = { + dd_mm_yyyy: 'dd/MM/yyyy', + mm_dd_yyyy: 'MM/dd/yyyy', + yyyy_mm_dd: 'yyyy/MM/dd' +} as const diff --git a/src/entities/task/config/deadline.ts b/src/entities/task/config/deadline.ts index ce470d118..ed7f5578b 100644 --- a/src/entities/task/config/deadline.ts +++ b/src/entities/task/config/deadline.ts @@ -1 +1,9 @@ -export const TASK_DEADLINES = ['Upcoming', 'Overdue', 'Far Future'] as const +export const TASK_DEADLINES = [ + 'Today', + 'Upcoming', + 'Overdue', + 'Far Future', + 'No Deadline' +] as const + +export type TaskDeadline = (typeof TASK_DEADLINES)[number] diff --git a/src/entities/task/config/priority.ts b/src/entities/task/config/priority.ts deleted file mode 100644 index cf7387307..000000000 --- a/src/entities/task/config/priority.ts +++ /dev/null @@ -1 +0,0 @@ -export const TASK_PRIORITIES = ['high', 'medium', 'low', 'without'] as const diff --git a/src/entities/task/index.ts b/src/entities/task/index.ts index ec0df84c4..6f027cba9 100644 --- a/src/entities/task/index.ts +++ b/src/entities/task/index.ts @@ -1,9 +1,12 @@ export * as TaskTypes from './model/types' -export * as TaskContracts from './model/contracts' -export { TASK_PRIORITIES } from './config/priority' -export { TASK_DEADLINES } from './config/deadline' -export { getTaskPriorityColor } from './lib/priority-colors' -export { formatDeadlineDate } from './lib/format-deadline-date' +export * as TaskContracts from './model/contract' +export { TASK_DEADLINES, type TaskDeadline } from './config/deadline' +export { + TASK_PRIORITY_COLORS, + getTaskPriorityColor +} from './lib/priority-colors' +export { parseDeadline, serializeDeadline } from './lib/deadline-day' +export { useCardDensity } from './lib/useCardDensity' export { FormDeadlinePicker } from './ui/FormDeadlinePicker' export { FormPrioritySelector } from './ui/FormPrioritySelector' export { Task } from './ui/Task' diff --git a/src/entities/task/lib/deadline-day.ts b/src/entities/task/lib/deadline-day.ts new file mode 100644 index 000000000..402e42fbf --- /dev/null +++ b/src/entities/task/lib/deadline-day.ts @@ -0,0 +1,12 @@ +export const parseDeadline = (deadline: Date | string | null | undefined) => { + if (!deadline) return undefined + + const date = new Date(deadline) + + return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +export const serializeDeadline = (date: Date) => + new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) + ).toISOString() diff --git a/src/entities/task/lib/format-deadline-date.ts b/src/entities/task/lib/format-deadline.ts similarity index 81% rename from src/entities/task/lib/format-deadline-date.ts rename to src/entities/task/lib/format-deadline.ts index 945b2fdb5..5d2620d51 100644 --- a/src/entities/task/lib/format-deadline-date.ts +++ b/src/entities/task/lib/format-deadline.ts @@ -1,6 +1,6 @@ import { format } from 'date-fns' -export const formatDeadlineDate = ( +export const formatDeadline = ( date: Date | undefined, formatType = 'dd/MM/yyyy' ) => { diff --git a/src/entities/task/lib/priority-colors.ts b/src/entities/task/lib/priority-colors.ts index 0fc4cbcb8..69e2f80cf 100644 --- a/src/entities/task/lib/priority-colors.ts +++ b/src/entities/task/lib/priority-colors.ts @@ -1,12 +1,12 @@ -import type { TaskPriority } from '../model/types' +import type { TaskPriority } from '@/shared/api' -const taskPriorityColors: Record = { - low: 'bg-blue', - medium: 'bg-pink', - high: 'bg-brand', +export const TASK_PRIORITY_COLORS: Record = { + low: 'bg-priority-low', + medium: 'bg-priority-medium', + high: 'bg-green', without: 'bg-black/30 dark:bg-white/30' -} +} as const export const getTaskPriorityColor = ( - priority: keyof typeof taskPriorityColors -) => taskPriorityColors[priority] || taskPriorityColors.without + priority: keyof typeof TASK_PRIORITY_COLORS +) => TASK_PRIORITY_COLORS[priority] || TASK_PRIORITY_COLORS.without diff --git a/src/entities/task/lib/useCardDensity.ts b/src/entities/task/lib/useCardDensity.ts new file mode 100644 index 000000000..7d802b8c3 --- /dev/null +++ b/src/entities/task/lib/useCardDensity.ts @@ -0,0 +1,7 @@ +import { useSettings } from '@/entities/setting/@x/task' + +export const useCardDensity = () => { + const { data } = useSettings(state => state.task.cardDensity) + + return data ?? 'comfortable' +} diff --git a/src/entities/task/model/contract.ts b/src/entities/task/model/contract.ts new file mode 100644 index 000000000..31a90c400 --- /dev/null +++ b/src/entities/task/model/contract.ts @@ -0,0 +1,12 @@ +import * as v from 'valibot' + +import { vTaskPriority } from '@/shared/api' + +import { TASK_DEADLINES } from '../config/deadline' + +export const TaskSearchSchema = v.object({ + search: v.optional(v.pipe(v.string(), v.trim()), ''), + priority: v.optional(v.fallback(v.array(vTaskPriority), []), []), + deadline: v.optional(v.fallback(v.array(v.picklist(TASK_DEADLINES)), []), []), + labels: v.optional(v.fallback(v.array(v.string()), []), []) +}) diff --git a/src/entities/task/model/contracts.ts b/src/entities/task/model/contracts.ts deleted file mode 100644 index 431cf2fa1..000000000 --- a/src/entities/task/model/contracts.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as v from 'valibot' - -import { LabelSchema } from '@/entities/label/@x/task' - -import { TASK_DEADLINES } from '../config/deadline' -import { TASK_PRIORITIES } from '../config/priority' - -export const TaskSchema = v.object({ - id: v.string(), - title: v.string(), - order: v.number(), - completed: v.boolean(), - columnId: v.string(), - description: v.nullable(v.string()), - priority: v.picklist(TASK_PRIORITIES), - labels: v.optional(v.array(LabelSchema)), - deadline: v.nullable(v.date()) -}) - -export const TasksSchema = v.array(TaskSchema) - -export const TaskSearchSchema = v.object({ - priority: v.optional(v.picklist(TASK_PRIORITIES)), - deadline: v.optional(v.picklist(TASK_DEADLINES)), - search: v.optional(v.pipe(v.string(), v.trim()), '') -}) diff --git a/src/entities/task/model/types.ts b/src/entities/task/model/types.ts index 5f82c20d4..d403a2350 100644 --- a/src/entities/task/model/types.ts +++ b/src/entities/task/model/types.ts @@ -1,10 +1,12 @@ import type { InferOutput } from 'valibot' -import type { TASK_DEADLINES } from '../config/deadline' -import type { TASK_PRIORITIES } from '../config/priority' -import type { TaskSchema, TaskSearchSchema, TasksSchema } from './contracts' +import type { TaskSearchSchema } from './contract' -export type TaskSchema = InferOutput -export type TasksSchema = InferOutput export type TaskSearchSchema = InferOutput -export type TaskDeadline = (typeof TASK_DEADLINES)[number] -export type TaskPriority = (typeof TASK_PRIORITIES)[number] + +export type TaskFilterKey = keyof TaskSearchSchema + +export type TaskArrayFilterKey = { + [K in TaskFilterKey]: TaskSearchSchema[K] extends readonly unknown[] + ? K + : never +}[TaskFilterKey] diff --git a/src/entities/task/ui/FormDeadlinePicker.tsx b/src/entities/task/ui/FormDeadlinePicker.tsx index 86b395ee5..3908e04b8 100644 --- a/src/entities/task/ui/FormDeadlinePicker.tsx +++ b/src/entities/task/ui/FormDeadlinePicker.tsx @@ -2,9 +2,10 @@ import type { ChangeEvent } from 'react' import { useState } from 'react' import { parseDate } from 'chrono-node' -import { startOfDay } from 'date-fns' +import { isBefore, startOfDay, startOfToday } from 'date-fns' import { CalendarIcon } from 'lucide-react' -import { useDebouncedCallback } from 'use-debounce' + +import { useSettings } from '@/entities/setting/@x/task' import { cn } from '@/shared/lib' import { @@ -17,27 +18,21 @@ import { useFormField } from '@/shared/ui' -import { formatDeadlineDate } from '../lib/format-deadline-date' - -type FormDeadlinePickerProps = { - mode?: 'create' | 'edit' -} - -const date = new Date() +import { formatDeadline } from '../lib/format-deadline' -export const FormDeadlinePicker = ({ mode }: FormDeadlinePickerProps) => { +export const FormDeadlinePicker = () => { const [isCalendarOpen, setIsCalendarOpen] = useState(false) + const { data: firstDayOfWeek } = useSettings( + select => select.general.firstDayOfWeek + ) + const { fieldState: { error }, field: { value, onChange, ...field } } = useFormField() - const [inputValue, setInputValue] = useState( - () => formatDeadlineDate(value) ?? '' - ) - - const clearValue = useDebouncedCallback(() => onChange(undefined), 1000) + const [inputValue, setInputValue] = useState(() => formatDeadline(value)) const handleChange = (e: ChangeEvent) => { const text = e.target.value @@ -45,24 +40,19 @@ export const FormDeadlinePicker = ({ mode }: FormDeadlinePickerProps) => { const parsed = parseDate(text) - if (parsed) onChange(parsed) - else onChange(undefined) + onChange(parsed ? startOfDay(parsed) : null) } const handleSelect = (date: Date | undefined) => { - onChange(date) - - if (date) { - clearValue.cancel() - setInputValue(formatDeadlineDate(date)) - } else { - setInputValue('') - clearValue() - } - + onChange(date ? startOfDay(date) : null) + setInputValue(formatDeadline(date)) setIsCalendarOpen(false) } + const isOverdue = value && isBefore(value, startOfToday()) + + const isUnparsed = !value && inputValue.trim().length > 0 + return ( <>
@@ -89,25 +79,26 @@ export const FormDeadlinePicker = ({ mode }: FormDeadlinePickerProps) => { date < startOfDay(date) : undefined - } - selected={value} + weekStartsOn={firstDayOfWeek === 'monday' ? 1 : 0} + defaultMonth={value ?? startOfToday()} + selected={value ?? undefined} onSelect={handleSelect} />
- {!error && value && inputValue && ( + {!error && (isUnparsed || value) && ( - {`This task is due on ${formatDeadlineDate(value, 'd MMM yyyy')}.`} + {isUnparsed && "We couldn't read that date. Try “tomorrow”."} + {value && + (isOverdue + ? `This deadline has passed — the task will show as overdue.` + : `This task is due on ${formatDeadline(value, 'd MMM yyyy')}.`)} )} diff --git a/src/entities/task/ui/FormPrioritySelector.tsx b/src/entities/task/ui/FormPrioritySelector.tsx index 653403da9..14abff3d9 100644 --- a/src/entities/task/ui/FormPrioritySelector.tsx +++ b/src/entities/task/ui/FormPrioritySelector.tsx @@ -1,7 +1,7 @@ +import { TaskPriority } from '@/shared/api' import { capitalize } from '@/shared/lib' import { FormItem, RadioGroup, RadioGroupItem, useFormField } from '@/shared/ui' -import { TASK_PRIORITIES } from '../config/priority' import { getTaskPriorityColor } from '../lib/priority-colors' export const FormPrioritySelector = () => { @@ -14,21 +14,23 @@ export const FormPrioritySelector = () => { value={value} className='gap-3' onValueChange={onChange}> - {TASK_PRIORITIES.toReversed().map(priority => ( - - - - ))} + {Object.values(TaskPriority) + .toReversed() + .map(priority => ( + + + + ))}
) } diff --git a/src/entities/task/ui/Task.tsx b/src/entities/task/ui/Task.tsx index cf361e33d..ce7324c60 100644 --- a/src/entities/task/ui/Task.tsx +++ b/src/entities/task/ui/Task.tsx @@ -1,21 +1,30 @@ +import type { TaskSettings, Task as TTask } from '@/shared/api' import type { ComponentProps } from 'react' -import type { TaskSchema } from '../model/types' import { createContext, use, useMemo } from 'react' import { mergeProps, useRender } from '@base-ui/react' -import { isToday } from 'date-fns' +import { isBefore, isToday, startOfToday } from 'date-fns' import { BellRingIcon } from 'lucide-react' -import { Label, LABEL_COLOR_MAP } from '@/entities/label/@x/task' +import { + Label, + LABEL_BASE_COLOR_MAP, + LABEL_COLOR_MAP +} from '@/entities/label/@x/task' +import { useSettings } from '@/entities/setting/@x/task' import { capitalize, cn } from '@/shared/lib' import { Checkbox } from '@/shared/ui' -import { formatDeadlineDate } from '../lib/format-deadline-date' +import { DATE_FORMAT_MAP } from '../config/date-format-map' +import { parseDeadline } from '../lib/deadline-day' +import { formatDeadline } from '../lib/format-deadline' import { getTaskPriorityColor } from '../lib/priority-colors' +import { useCardDensity } from '../lib/useCardDensity' type TaskContext = { - task: TaskSchema + task: TTask + density: TaskSettings['cardDensity'] } const TaskContext = createContext(undefined) @@ -30,7 +39,7 @@ const useTaskContext = () => { return context } -type TaskProviderProps = TaskContext & ComponentProps<'div'> +type TaskProviderProps = { task: TTask } & ComponentProps<'div'> const TaskProvider = ({ task, @@ -38,14 +47,18 @@ const TaskProvider = ({ children, ...props }: TaskProviderProps) => { - const value = useMemo(() => ({ task }), [task]) + const density = useCardDensity() + + const value = useMemo(() => ({ task, density }), [task, density]) return (
) => { const { task } = useTaskContext() + const { data: showPriorityIndicator } = useSettings( + state => state.task.showPriorityIndicator + ) + + if (!showPriorityIndicator) return null + return ( ) => { - const { task } = useTaskContext() + const { task, density } = useTaskContext() return (

) => { } const TaskDescription = ({ className, ...props }: ComponentProps<'p'>) => { - const { task } = useTaskContext() + const { task, density } = useTaskContext() return ( task.description && (

) => { const { - task: { labels } + task: { labels }, + density } = useTaskContext() - if (!labels?.length) return null + const { data } = useSettings(state => state.label) + + if (!labels?.length || data?.labelDisplay === 'hidden') return null + + let max = data?.maxLabelsShown - const labelsPerRow = 3 + if (max === 0) max = Infinity + + const visibleLabels = labels.slice(0, max) + + const hiddenCount = labels.length - visibleLabels.length return (

- {labels.slice(0, labelsPerRow).map(label => ( + {visibleLabels.map(label => ( ))} - {labels.length > labelsPerRow && ( + {hiddenCount > 0 && ( - +{labels.length - labelsPerRow} + +{hiddenCount} )}
@@ -159,16 +197,22 @@ const TaskLabels = ({ className, ...props }: ComponentProps<'div'>) => { } const TaskPriority = ({ className }: { className?: string }) => { - const { task } = useTaskContext() + const { task, density } = useTaskContext() + + const isCompact = density === 'compact' return (
-

Priority

+ {!isCompact && ( +

+ Priority +

+ )}
{ getTaskPriorityColor(task.priority) )} /> -

{capitalize(task.priority)}

+ {isCompact ? ( + Priority: {capitalize(task.priority)} + ) : ( +

{capitalize(task.priority)}

+ )}
) } const TaskDeadline = ({ className }: { className?: string }) => { - const { task } = useTaskContext() + const { task, density } = useTaskContext() + + const { data: dateFormat } = useSettings(state => state.general.dateFormat) + + const { data: overdueHighlight } = useSettings( + state => state.task.overdueHighlight + ) + + const deadline = parseDeadline(task.deadline) + + const isOverdue = + !!deadline && !task.completed && isBefore(deadline, startOfToday()) return ( - task.deadline && ( + deadline && (
-

- Deadline + {density === 'comfortable' && ( +

+ Deadline +

+ )} +

+ {formatDeadline( + deadline, + DATE_FORMAT_MAP[dateFormat ?? 'dd_mm_yyyy'] + )}

-

{formatDeadlineDate(task.deadline)}

) ) @@ -204,11 +274,13 @@ const TaskDeadline = ({ className }: { className?: string }) => { const TaskDeadlineTodayIndicator = ({ className }: { className?: string }) => { const { task } = useTaskContext() + const deadline = parseDeadline(task.deadline) + return ( - task.deadline && - isToday(task.deadline) && ( + deadline && + isToday(deadline) && ( ) ) @@ -222,9 +294,9 @@ const TaskActionButton = ({ const defaultProps: useRender.ElementProps<'button'> = { type: 'button', className: cn( - `focus-visible:styled-outline hocus:text-black - dark:hocus:text-white-soft dark:text-white-soft/50 text-black/50 - [&_svg]:size-4`, + `focus-visible:styled-outline dark:text-white-soft text-black + [&_svg]:size-4 [&_svg]:opacity-50 hocus:[&_svg]:opacity-100 + [&_svg]:transition-opacity`, className ) } diff --git a/src/entities/user/@x/setting.ts b/src/entities/user/@x/setting.ts new file mode 100644 index 000000000..7251ced96 --- /dev/null +++ b/src/entities/user/@x/setting.ts @@ -0,0 +1 @@ +export { ACCENT_COLOR_MAP } from '../config/accent-colors-map' diff --git a/src/entities/user/api/queries.ts b/src/entities/user/api/queries.ts index a19086dae..74da9817b 100644 --- a/src/entities/user/api/queries.ts +++ b/src/entities/user/api/queries.ts @@ -1,14 +1,40 @@ +import type { InferedSession } from '../model/types' + import { queryOptions } from '@tanstack/react-query' import { authClient } from '@/shared/api' export const sessionQueries = { all: () => ['session'] as const, + lists: () => [...sessionQueries.all(), 'list'] as const, + list: () => + queryOptions({ + queryKey: sessionQueries.lists(), + staleTime: 1000 * 30, + queryFn: async () => + (await authClient.listSessions()) as unknown as InferedSession[] + }), + passkeys: () => [...sessionQueries.all(), 'passkeys'] as const, + passkey: () => + queryOptions({ + queryKey: sessionQueries.passkeys(), + staleTime: 1000 * 30, + queryFn: async () => await authClient.passkey.listUserPasskeys() + }), + accounts: () => [...sessionQueries.all(), 'accounts'] as const, + account: () => + queryOptions({ + queryKey: sessionQueries.accounts(), + staleTime: 1000 * 30, + queryFn: async () => await authClient.listAccounts() + }), + currents: () => [...sessionQueries.all(), 'current'] as const, current: () => queryOptions({ - queryKey: sessionQueries.all(), + queryKey: sessionQueries.currents(), queryFn: async () => await authClient.getSession(), staleTime: 1000 * 60 * 10, // 10 minutes - retry: false + retry: false, + refetchOnWindowFocus: true }) } diff --git a/src/entities/user/config/accent-colors-map.ts b/src/entities/user/config/accent-colors-map.ts new file mode 100644 index 000000000..689be5e64 --- /dev/null +++ b/src/entities/user/config/accent-colors-map.ts @@ -0,0 +1,12 @@ +import type { AccentColor } from '@/shared/api' + +export const ACCENT_COLOR_MAP: Record = { + blue: '#8FB8D8', + purple: '#C3A6D8', + green: '#BEDBB0', + red: '#E7A6A6', + yellow: '#F2D28B', + cyan: '#91D4D8', + indigo: '#A7B1E8', + gray: '#B8B8B8' +} as const diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts index bf492b2c6..675872be2 100644 --- a/src/entities/user/index.ts +++ b/src/entities/user/index.ts @@ -1,5 +1,6 @@ export * as UserTypes from './model/types' -export * as UserContracts from './model/contracts' +export * as UserContracts from './model/contract' export { defaultAvatarUrl } from './config/default-avatar-url' +export { useMetaThemeColor } from './lib/useMetaThemeColor' export { sessionQueries } from './api/queries' export { useMe } from './model/useMe' diff --git a/src/entities/user/lib/useMetaThemeColor.ts b/src/entities/user/lib/useMetaThemeColor.ts new file mode 100644 index 000000000..281daf495 --- /dev/null +++ b/src/entities/user/lib/useMetaThemeColor.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react' + +import { useSettings } from '@/entities/setting/@x/user' + +import { resolveTheme } from '@/shared/config' + +const THEME_COLORS = { light: '#fcfcfc', dark: '#161616' } + +export const useMetaThemeColor = () => { + const { data: theme } = useSettings(select => select.general.theme) + + useEffect(() => { + let themeColorMeta = document.querySelector( + 'meta[name="theme-color"]' + ) + + if (!themeColorMeta) { + themeColorMeta = document.createElement('meta') + themeColorMeta.name = 'theme-color' + document.head.appendChild(themeColorMeta) + } + + const meta = themeColorMeta + + const apply = () => { + meta.content = THEME_COLORS[resolveTheme(theme)] + } + + apply() + + if (theme !== 'system') return + + const mq = window.matchMedia('(prefers-color-scheme: dark)') + + mq.addEventListener('change', apply) + + return () => mq.removeEventListener('change', apply) + }, [theme]) +} diff --git a/src/entities/user/model/contracts.ts b/src/entities/user/model/contract.ts similarity index 55% rename from src/entities/user/model/contracts.ts rename to src/entities/user/model/contract.ts index 6fb96e8e7..6a96cf7d4 100644 --- a/src/entities/user/model/contracts.ts +++ b/src/entities/user/model/contract.ts @@ -1,11 +1,8 @@ import * as v from 'valibot' -import { THEMES } from '@/shared/config' - export const UserSchema = v.object({ id: v.string(), name: v.string(), email: v.string(), - image: v.nullable(v.string()), - theme: v.picklist(THEMES) + image: v.nullable(v.string()) }) diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts index 3321fddb1..e7596260e 100644 --- a/src/entities/user/model/types.ts +++ b/src/entities/user/model/types.ts @@ -1,4 +1,6 @@ +import type { authClient } from '@/shared/api' import type { InferOutput } from 'valibot' -import type { UserSchema } from './contracts' +import type { UserSchema } from './contract' export type UserSchema = InferOutput +export type InferedSession = typeof authClient.$Infer.Session.session diff --git a/src/features/board/add/api/useAddBoard.ts b/src/features/board/add/api/useAddBoard.ts index 72c2083bf..57bc67426 100644 --- a/src/features/board/add/api/useAddBoard.ts +++ b/src/features/board/add/api/useAddBoard.ts @@ -3,7 +3,9 @@ import type { Dispatch, SetStateAction } from 'react' import { useMutation } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { createBoardMutation, getAllBoardsQueryKey } from '@/shared/api' +import { boardQueries } from '@/entities/board' + +import { createBoardMutation } from '@/shared/api' import { useSidebarStore } from '@/shared/store' export const useAddBoard = ( @@ -16,9 +18,9 @@ export const useAddBoard = ( return useMutation({ ...createBoardMutation(), meta: { - invalidates: [getAllBoardsQueryKey()], + invalidates: [boardQueries.lists()], errorMessage: - 'An error occurred while creating the board. Please try again shortly.' + 'An error occurred while creating the board. Please try again.' }, onSuccess(data) { setIsDialogOpen(false) diff --git a/src/features/board/add/model/contract.ts b/src/features/board/add/model/contract.ts index 6d517ea64..1bdf57e17 100644 --- a/src/features/board/add/model/contract.ts +++ b/src/features/board/add/model/contract.ts @@ -1,6 +1,6 @@ import * as v from 'valibot' -import { BOARD_BG_IMAGES_IDS, BOARD_ICONS } from '@/entities/board' +import { vBoardBackgroundId, vBoardIcon } from '@/shared/api' export const AddBoardSchema = v.object({ title: v.pipe( @@ -8,8 +8,8 @@ export const AddBoardSchema = v.object({ v.trim(), v.minLength(3, 'Please enter at least 3 characters.') ), - icon: v.fallback(v.picklist(BOARD_ICONS), 'sparkles'), - background: v.fallback(v.picklist(BOARD_BG_IMAGES_IDS), 'default') + icon: v.fallback(vBoardIcon, 'sparkles'), + background: v.fallback(vBoardBackgroundId, 'default') }) export type AddBoardSchema = v.InferOutput diff --git a/src/features/board/delete/api/useDeleteBoard.ts b/src/features/board/delete/api/useDeleteBoard.ts index 51c1e353b..d41198891 100644 --- a/src/features/board/delete/api/useDeleteBoard.ts +++ b/src/features/board/delete/api/useDeleteBoard.ts @@ -1,10 +1,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' -import { parse } from 'valibot' +import { array, parse } from 'valibot' -import { BoardContracts, useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { deleteBoard, getAllBoardsQueryKey } from '@/shared/api' +import { deleteBoard, vBoard } from '@/shared/api' export const useDeleteBoard = () => { const queryClient = useQueryClient() @@ -13,28 +13,25 @@ export const useDeleteBoard = () => { const navigate = useNavigate() - const allBoardsQueryKey = getAllBoardsQueryKey() + const allBoardsQueryKey = boardQueries.lists() return useMutation({ mutationFn: () => deleteBoard({ path: { boardId } }), meta: { errorMessage: - 'An error occurred while deleting the board. Please try again shortly.' + 'An error occurred while deleting the board. Please try again.' }, onMutate: async () => { await queryClient.cancelQueries({ queryKey: allBoardsQueryKey }) const previousBoards = queryClient.getQueryData(allBoardsQueryKey) - const parsedPreviousBoards = parse( - BoardContracts.BoardsSchema, - previousBoards - ) + const parsedPreviousBoards = parse(array(vBoard), previousBoards) queryClient.setQueryData(allBoardsQueryKey, oldBoards => { if (!oldBoards) return oldBoards - const parsedOldBoards = parse(BoardContracts.BoardsSchema, oldBoards) + const parsedOldBoards = parse(array(vBoard), oldBoards) return parsedOldBoards.filter(b => b.id !== boardId) }) diff --git a/src/features/board/delete/ui/DeleteBoardAlertDialog.tsx b/src/features/board/delete/ui/DeleteBoardAlertDialog.tsx index 0eadc7045..6c9c802fe 100644 --- a/src/features/board/delete/ui/DeleteBoardAlertDialog.tsx +++ b/src/features/board/delete/ui/DeleteBoardAlertDialog.tsx @@ -1,5 +1,7 @@ import { TrashIcon } from 'lucide-react' +import { ConfirmDeleteTrigger } from '@/entities/setting' + import { AlertDialog, AlertDialogAction, @@ -16,27 +18,36 @@ import { useDeleteBoard } from '../api/useDeleteBoard' export const DeleteBoardAlertDialog = () => { const { mutate: deleteBoard } = useDeleteBoard() + const handleDeleteBoard = () => { + deleteBoard() + } + return ( - - - - - - Delete board? - - This action cannot be undone. The board and all of its contents will - be permanently deleted. - - - Cancel - deleteBoard()}> - Delete - - - - + + + + + + + Delete board? + + This action cannot be undone. The board and all of its contents will + be permanently deleted. + + + Cancel + + Delete + + + + + ) } diff --git a/src/features/board/edit/api/useEditBoard.ts b/src/features/board/edit/api/useEditBoard.ts index 496d254b5..90a7a61c1 100644 --- a/src/features/board/edit/api/useEditBoard.ts +++ b/src/features/board/edit/api/useEditBoard.ts @@ -3,13 +3,9 @@ import type { EditBoardSchema } from '../model/contract' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { - getAllBoardsQueryKey, - getBoardByIdQueryKey, - updateBoard -} from '@/shared/api' +import { updateBoard } from '@/shared/api' export const useEditBoard = ( setIsDialogOpen: Dispatch> @@ -20,12 +16,9 @@ export const useEditBoard = ( mutationFn: (data: EditBoardSchema) => updateBoard({ path: { boardId }, body: data }), meta: { - invalidates: [ - getAllBoardsQueryKey(), - getBoardByIdQueryKey({ path: { boardId } }) - ], + invalidates: [boardQueries.all()], errorMessage: - 'An error occurred while editing the board. Please try again shortly.' + 'An error occurred while editing the board. Please try again.' }, onSuccess: () => { setIsDialogOpen(false) diff --git a/src/features/board/edit/model/contract.ts b/src/features/board/edit/model/contract.ts index b46e4ea74..06f242350 100644 --- a/src/features/board/edit/model/contract.ts +++ b/src/features/board/edit/model/contract.ts @@ -1,6 +1,6 @@ import * as v from 'valibot' -import { BOARD_BG_IMAGES_IDS, BOARD_ICONS } from '@/entities/board' +import { vBoardBackgroundId, vBoardIcon } from '@/shared/api' export const EditBoardSchema = v.partial( v.object({ @@ -9,8 +9,8 @@ export const EditBoardSchema = v.partial( v.trim(), v.minLength(3, 'Please enter at least 3 characters.') ), - icon: v.picklist(BOARD_ICONS), - background: v.picklist(BOARD_BG_IMAGES_IDS) + icon: vBoardIcon, + background: vBoardBackgroundId }) ) diff --git a/src/features/board/edit/model/types.ts b/src/features/board/edit/model/types.ts index 687902de8..d4365e5fd 100644 --- a/src/features/board/edit/model/types.ts +++ b/src/features/board/edit/model/types.ts @@ -1,7 +1,7 @@ -import type { BoardTypes } from '@/entities/board' +import type { BoardBackgroundId, BoardIcon } from '@/shared/api' export type EditBoardData = { title: string - icon: BoardTypes.BoardIcon - background: BoardTypes.BoardBgImageId + icon: BoardIcon + background: BoardBackgroundId } diff --git a/src/features/board/edit/ui/EditBoardDialog.tsx b/src/features/board/edit/ui/EditBoardDialog.tsx index bdb35555e..d7e359b11 100644 --- a/src/features/board/edit/ui/EditBoardDialog.tsx +++ b/src/features/board/edit/ui/EditBoardDialog.tsx @@ -26,9 +26,12 @@ export const EditBoardDialog = ({ data }: EditBoardDialogProps) => { onOpenChange={setIsDialogOpen}> - + className='focus-visible:styled-outline dark:text-white-soft group + text-black'> + Edit board diff --git a/src/features/column/add/api/useAddColumn.ts b/src/features/column/add/api/useAddColumn.ts index 8ca2b9e89..5f21e8624 100644 --- a/src/features/column/add/api/useAddColumn.ts +++ b/src/features/column/add/api/useAddColumn.ts @@ -3,9 +3,9 @@ import type { AddColumnSchema } from '../model/contract' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { createColumn, getBoardByIdQueryKey } from '@/shared/api' +import { createColumn } from '@/shared/api' export const useAddColumn = ( setIsDialogOpen: Dispatch> @@ -16,9 +16,9 @@ export const useAddColumn = ( mutationFn: (data: AddColumnSchema) => createColumn({ path: { boardId }, body: data }), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: - 'An error occurred while creating the column. Please try again shortly.' + 'An error occurred while creating the column. Please try again.' }, onSuccess() { setIsDialogOpen(false) diff --git a/src/features/column/delete/api/useDeleteColumn.ts b/src/features/column/delete/api/useDeleteColumn.ts index 993ce51cb..8367a0b27 100644 --- a/src/features/column/delete/api/useDeleteColumn.ts +++ b/src/features/column/delete/api/useDeleteColumn.ts @@ -1,41 +1,38 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { parse } from 'valibot' -import { BoardContracts, useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { deleteColumnMutation, getBoardByIdQueryKey } from '@/shared/api' +import { deleteColumnMutation, vBoard } from '@/shared/api' export const useDeleteColumn = () => { const queryClient = useQueryClient() const boardId = useGetParamBoardId() - const boardQueryKey = getBoardByIdQueryKey({ path: { boardId } }) + const boardQueryKey = boardQueries.detail(boardId).queryKey return useMutation({ ...deleteColumnMutation(), meta: { errorMessage: - 'An error occurred while deleting the column. Please try again shortly.' + 'An error occurred while deleting the column. Please try again.' }, onMutate: async ({ path: { columnId } }) => { await queryClient.cancelQueries({ queryKey: boardQueryKey }) const previousBoard = queryClient.getQueryData(boardQueryKey) - const parsedPreviousBoard = parse( - BoardContracts.BoardSchema, - previousBoard - ) + const parsedPreviousBoard = parse(vBoard, previousBoard) queryClient.setQueryData(boardQueryKey, oldBoard => { if (!oldBoard) return oldBoard - const parsedOldBoard = parse(BoardContracts.BoardSchema, oldBoard) + const parsedOldBoard = parse(vBoard, oldBoard) return { ...parsedOldBoard, - columns: parsedOldBoard.columns.filter( + columns: parsedOldBoard.columns?.filter( column => column.id !== columnId ) } diff --git a/src/features/column/delete/ui/DeleteColumnAlertDialog.tsx b/src/features/column/delete/ui/DeleteColumnAlertDialog.tsx index 993b4b930..a5113df78 100644 --- a/src/features/column/delete/ui/DeleteColumnAlertDialog.tsx +++ b/src/features/column/delete/ui/DeleteColumnAlertDialog.tsx @@ -1,6 +1,7 @@ import { TrashIcon } from 'lucide-react' import { Column } from '@/entities/column' +import { ConfirmDeleteTrigger } from '@/entities/setting' import { AlertDialog, @@ -18,27 +19,32 @@ import { useDeleteColumn } from '../api/useDeleteColumn' export const DeleteColumnAlertDialog = ({ columnId }: { columnId: string }) => { const { mutate: deleteColumn } = useDeleteColumn() + const handleDeleteColumn = () => { + deleteColumn({ path: { columnId } }) + } + return ( - - } - aria-label='Delete column'> - - - - Delete column? - - This action cannot be undone. The column and all of its contents will - be permanently deleted. - - - Cancel - deleteColumn({ path: { columnId } })}> - Delete - - - - + + + } + aria-label='Delete column'> + + + + Delete column? + + This action cannot be undone. The column and all of its contents + will be permanently deleted. + + + Cancel + + Delete + + + + + ) } diff --git a/src/features/column/edit/api/useEditColumn.ts b/src/features/column/edit/api/useEditColumn.ts index 69abb8794..de159d5fc 100644 --- a/src/features/column/edit/api/useEditColumn.ts +++ b/src/features/column/edit/api/useEditColumn.ts @@ -2,24 +2,21 @@ import type { Dispatch, SetStateAction } from 'react' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries } from '@/entities/board' -import { getBoardByIdQueryKey, updateColumnMutation } from '@/shared/api' +import { updateColumnMutation } from '@/shared/api' export const useEditColumn = ( setIsDialogOpen: Dispatch> -) => { - const boardId = useGetParamBoardId() - - return useMutation({ +) => + useMutation({ ...updateColumnMutation(), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: - 'An error occurred while editing the column. Please try again shortly.' + 'An error occurred while editing the column. Please try again.' }, onSuccess: () => { setIsDialogOpen(false) } }) -} diff --git a/src/features/drag-and-drop/api/useUpdateColumnOrder.ts b/src/features/drag-and-drop/api/useUpdateColumnOrder.ts index cab982780..81f001798 100644 --- a/src/features/drag-and-drop/api/useUpdateColumnOrder.ts +++ b/src/features/drag-and-drop/api/useUpdateColumnOrder.ts @@ -2,9 +2,9 @@ import type { UpdateColumnsOrderData } from '@/shared/api' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { getBoardByIdQueryKey, updateColumnsOrder } from '@/shared/api' +import { updateColumnsOrder } from '@/shared/api' export const useUpdateColumnOrder = () => { const boardId = useGetParamBoardId() @@ -13,7 +13,7 @@ export const useUpdateColumnOrder = () => { mutationFn: ({ ids }: UpdateColumnsOrderData['body']) => updateColumnsOrder({ path: { boardId }, body: { ids } }), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: 'Unexpected error during columns reordering. We apologize for the inconvenience. Please try again later.' } diff --git a/src/features/drag-and-drop/api/useUpdateTaskOrder.ts b/src/features/drag-and-drop/api/useUpdateTaskOrder.ts index 03ade8822..f07e47c54 100644 --- a/src/features/drag-and-drop/api/useUpdateTaskOrder.ts +++ b/src/features/drag-and-drop/api/useUpdateTaskOrder.ts @@ -1,18 +1,15 @@ import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries } from '@/entities/board' -import { getBoardByIdQueryKey, updateTasksOrderMutation } from '@/shared/api' +import { updateTasksOrderMutation } from '@/shared/api' -export const useUpdateTaskOrder = () => { - const boardId = useGetParamBoardId() - - return useMutation({ +export const useUpdateTaskOrder = () => + useMutation({ ...updateTasksOrderMutation(), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: 'Unexpected error during tasks reordering. We apologize for the inconvenience. Please try again later.' } }) -} diff --git a/src/features/drag-and-drop/index.ts b/src/features/drag-and-drop/index.ts index 57936b2d3..897bee079 100644 --- a/src/features/drag-and-drop/index.ts +++ b/src/features/drag-and-drop/index.ts @@ -1 +1 @@ -export { DragAndDropProvider, useDragAndDrop } from './model/context' +export { DragAndDropProvider, useDragAndDropSelector } from './model/context' diff --git a/src/features/drag-and-drop/lib/getDraggingTaskData.ts b/src/features/drag-and-drop/lib/getDraggingTaskData.ts index 5b5d32c2d..f816f6272 100644 --- a/src/features/drag-and-drop/lib/getDraggingTaskData.ts +++ b/src/features/drag-and-drop/lib/getDraggingTaskData.ts @@ -1,12 +1,11 @@ -import type { ColumnTypes } from '@/entities/column' -import type { TaskTypes } from '@/entities/task' +import type { Column, Task } from '@/shared/api' import type { UniqueIdentifier } from '@dnd-kit/core' export const getDraggingTaskData = ( taskId: UniqueIdentifier, columnId: string | null, - columns: ColumnTypes.ColumnsSchema, - tasks: TaskTypes.TasksSchema + columns: Column[], + tasks: Task[] ) => { const columnTasks = tasks.filter(c => c.columnId === columnId) const taskPosition = columnTasks.findIndex(c => c.id === taskId) diff --git a/src/features/drag-and-drop/model/context.tsx b/src/features/drag-and-drop/model/context.tsx index 11e2cb409..0914e7951 100644 --- a/src/features/drag-and-drop/model/context.tsx +++ b/src/features/drag-and-drop/model/context.tsx @@ -1,6 +1,6 @@ import type { DragAndDropContext, DragAndDropProviderProps } from './types' -import { createContext, use, useMemo } from 'react' +import { useMemo } from 'react' import { DndContext, KeyboardSensor, @@ -9,6 +9,7 @@ import { useSensor, useSensors } from '@dnd-kit/core' +import { createContext, useContextSelector } from 'use-context-selector' import { coordinateGetter } from '../lib/coordinateGetter' import { useGetAccessibilityAnnouncements } from '../lib/useGetAccessibilityAnnouncements' @@ -61,7 +62,8 @@ export const DragAndDropProvider = ({ ) return ( - + // eslint-disable-next-line @eslint-react/no-context-provider + {children} - + ) } -export const useDragAndDrop = () => { - const context = use(DragAndDropContext) +export const useDragAndDropSelector = ( + selector: (value: DragAndDropContext) => T +) => { + const selected = useContextSelector(DragAndDropContext, ctx => { + if (!ctx) { + throw new Error( + 'useDragAndDropSelector must be used within a DragAndDropProvider' + ) + } - if (!context) { - throw new Error('useDragAndDrop must be used within a DragAndDropProvider') - } + return selector(ctx) + }) - return context + return selected } diff --git a/src/features/drag-and-drop/model/types.ts b/src/features/drag-and-drop/model/types.ts index f147ca4eb..90135d19b 100644 --- a/src/features/drag-and-drop/model/types.ts +++ b/src/features/drag-and-drop/model/types.ts @@ -1,25 +1,24 @@ -import type { ColumnTypes } from '@/entities/column' -import type { TaskTypes } from '@/entities/task' +import type { Column, Task } from '@/shared/api' import type { Dispatch, ReactNode, SetStateAction } from 'react' export type DragAndDropContext = { - columns: ColumnTypes.ColumnsSchema - tasks: TaskTypes.TasksSchema - activeColumn: ColumnTypes.ColumnSchema | null - activeTask: TaskTypes.TaskSchema | null + columns: Column[] + tasks: Task[] + activeColumn: Column | null + activeTask: Task | null } export type DragAndDropProviderProps = { children: ReactNode - initialColumns: ColumnTypes.ColumnsSchema | undefined + initialColumns: Column[] | undefined } export type TaskDragHandlersProps = Pick & { - setTasks: Dispatch> - setActiveTask: Dispatch> + setTasks: Dispatch> + setActiveTask: Dispatch> } export type ColumnDragHandlersProps = Pick & { - setColumns: Dispatch> - setActiveColumn: Dispatch> + setColumns: Dispatch> + setActiveColumn: Dispatch> } diff --git a/src/features/drag-and-drop/model/useDndState.ts b/src/features/drag-and-drop/model/useDndState.ts index 09ca0df98..74f24ea2c 100644 --- a/src/features/drag-and-drop/model/useDndState.ts +++ b/src/features/drag-and-drop/model/useDndState.ts @@ -1,48 +1,25 @@ -import type { ColumnTypes } from '@/entities/column' -import type { TaskTypes } from '@/entities/task' - -import { useMemo, useState } from 'react' -import { parse } from 'valibot' - -import { ColumnContracts } from '@/entities/column' -import { TaskContracts } from '@/entities/task' - -export const useDndState = ( - initialColumns: ColumnTypes.ColumnsSchema | undefined -) => { - const [parsedColumns, parsedTasks] = useMemo( - () => [ - parse(ColumnContracts.ColumnsSchema, initialColumns), - parse( - TaskContracts.TasksSchema, - initialColumns?.flatMap(c => c.tasks) - ) - ], - [initialColumns] +import type { Column, Task } from '@/shared/api' + +import { useState } from 'react' + +export const useDndState = (initialColumns: Column[] | undefined) => { + const [columns, setColumns] = useState(initialColumns ?? []) + + const [tasks, setTasks] = useState( + initialColumns?.flatMap(c => c.tasks ?? []) ?? [] ) - const [columns, setColumns] = useState(parsedColumns) - const [tasks, setTasks] = useState(parsedTasks) const [prevInitialColumns, setPrevInitialColumns] = useState(initialColumns) - const [activeTask, setActiveTask] = useState( - null - ) + const [activeTask, setActiveTask] = useState(null) - const [activeColumn, setActiveColumn] = - useState(null) + const [activeColumn, setActiveColumn] = useState(null) if (initialColumns !== prevInitialColumns) { setPrevInitialColumns(initialColumns) - const parsedColumns = parse(ColumnContracts.ColumnsSchema, initialColumns) - const parsedTasks = parse( - TaskContracts.TasksSchema, - parsedColumns.flatMap(c => c.tasks) - ) - - setColumns(parsedColumns) - setTasks(parsedTasks) + setColumns(initialColumns ?? []) + setTasks(initialColumns?.flatMap(c => c.tasks ?? []) ?? []) } return { diff --git a/src/features/drag-and-drop/model/useTaskDragHandlers.ts b/src/features/drag-and-drop/model/useTaskDragHandlers.ts index 41f574e6f..4177fade3 100644 --- a/src/features/drag-and-drop/model/useTaskDragHandlers.ts +++ b/src/features/drag-and-drop/model/useTaskDragHandlers.ts @@ -29,40 +29,38 @@ export const useTaskDragHandlers = ({ const onDragOver = ({ active, over }: DragOverEvent) => { if (!over || active.id === over.id) return + if (recentlyDraggedOverIdRef.current === over.id) return + const isActiveATask = active.data.current?.type === 'task' const isDraggingOverATask = over.data.current?.type === 'task' const isDraggingOverAColumn = over.data.current?.type === 'column' if (!isActiveATask) return + recentlyDraggedOverIdRef.current = over.id + if (isDraggingOverATask) { - setTimeout(() => { - setTasks(prevTasks => { - const activeTaskIndex = prevTasks.findIndex(c => c.id === active.id) - const overTaskIndex = prevTasks.findIndex(c => c.id === over.id) - - const activeTask = prevTasks[activeTaskIndex] - const overTask = prevTasks[overTaskIndex] - - recentlyDraggedOverIdRef.current = over.id - - if ( - activeTask && - overTask && - activeTask.columnId !== overTask.columnId - ) { - activeTask.columnId = overTask.columnId - - return arrayMove( - prevTasks, - activeTaskIndex, - Math.max(0, overTaskIndex - 1) - ) - } - - return arrayMove(prevTasks, activeTaskIndex, overTaskIndex) - }) - }, 0) + setTasks(prevTasks => { + const activeTaskIndex = prevTasks.findIndex(c => c.id === active.id) + const overTaskIndex = prevTasks.findIndex(c => c.id === over.id) + + const activeTask = prevTasks[activeTaskIndex] + const overTask = prevTasks[overTaskIndex] + + if (!activeTask || !overTask) return prevTasks + + if (activeTask.columnId !== overTask.columnId) { + activeTask.columnId = overTask.columnId + + return arrayMove( + prevTasks, + activeTaskIndex, + Math.max(0, overTaskIndex - 1) + ) + } + + return arrayMove(prevTasks, activeTaskIndex, overTaskIndex) + }) } if (isDraggingOverAColumn) { @@ -86,11 +84,10 @@ export const useTaskDragHandlers = ({ const onDragEnd = ({ active }: DragEndEvent) => { setActiveTask(null) - if ( - !active || - active.data.current?.type !== 'task' || - !recentlyDraggedOverIdRef.current - ) { + const draggedOverId = recentlyDraggedOverIdRef.current + recentlyDraggedOverIdRef.current = null + + if (!active || active.data.current?.type !== 'task' || !draggedOverId) { return } diff --git a/src/features/label/add/api/useAddLabel.ts b/src/features/label/add/api/useAddLabel.ts deleted file mode 100644 index 3d51aea8a..000000000 --- a/src/features/label/add/api/useAddLabel.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMutation } from '@tanstack/react-query' - -import { useLabelModalStore } from '@/entities/label' - -import { createLabelMutation, getAllLabelsQueryKey } from '@/shared/api' - -export const useAddLabel = (onCreatedLabel: (...event: unknown[]) => void) => { - const { setModal } = useLabelModalStore() - - return useMutation({ - ...createLabelMutation(), - meta: { - invalidates: [getAllLabelsQueryKey()], - errorMessage: - 'An error occurred while adding the label. Please try again shortly.' - }, - onSuccess(data) { - setModal({ isOpen: false }) - onCreatedLabel(data) - } - }) -} diff --git a/src/features/label/add/index.ts b/src/features/label/add/index.ts deleted file mode 100644 index ae9daabe4..000000000 --- a/src/features/label/add/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddLabelDialog } from './ui/AddLabelDialog' diff --git a/src/features/label/add/model/contract.ts b/src/features/label/add/model/contract.ts deleted file mode 100644 index e388ad000..000000000 --- a/src/features/label/add/model/contract.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as v from 'valibot' - -import { LABEL_COLORS } from '@/entities/label' - -export const AddLabelSchema = v.object({ - name: v.pipe( - v.string(), - v.trim(), - v.minLength(2, 'Please enter at least 2 characters.') - ), - color: v.fallback(v.picklist(LABEL_COLORS), 'blue') -}) - -export type AddLabelSchema = v.InferOutput diff --git a/src/features/label/add/ui/AddLabelForm.tsx b/src/features/label/add/ui/AddLabelForm.tsx deleted file mode 100644 index 7eef58a6d..000000000 --- a/src/features/label/add/ui/AddLabelForm.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useAppForm } from '@/shared/lib' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, - Input, - PlusButtonWithLoader -} from '@/shared/ui' - -import { useAddLabel } from '../api/useAddLabel' -import { AddLabelSchema } from '../model/contract' -import { LabelColorPicker } from './LabelColorPicker' - -type AddLabelFormProps = { - name?: string - onCreatedLabel: (...event: unknown[]) => void -} - -export const AddLabelForm = ({ name, onCreatedLabel }: AddLabelFormProps) => { - const form = useAppForm(AddLabelSchema, { - defaultValues: { name: name ?? '', color: 'blue' } - }) - - const { mutate: addLabel, isPending } = useAddLabel(onCreatedLabel) - - return ( -
- addLabel({ body: data }))}> - ( - - - Name - - } /> - - - )} - /> - ( - - - Color - - - - - )} - /> - - Add - - - - ) -} diff --git a/src/features/settings/general/api/useUpdateGeneralSettings.ts b/src/features/settings/general/api/useUpdateGeneralSettings.ts new file mode 100644 index 000000000..7997d80c5 --- /dev/null +++ b/src/features/settings/general/api/useUpdateGeneralSettings.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { parse } from 'valibot' + +import { SettingContracts, settingQueries } from '@/entities/setting' + +import { updateGeneralSettingsMutation } from '@/shared/api' + +export const useUpdateGeneralSettings = () => { + const queryClient = useQueryClient() + + const allSettinsQueryKey = settingQueries.lists() + + return useMutation({ + ...updateGeneralSettingsMutation(), + meta: { + errorMessage: 'We couldn’t update your settings. Please try again.' + }, + onMutate: async ({ body }) => { + await queryClient.cancelQueries({ queryKey: allSettinsQueryKey }) + + const previousSettings = queryClient.getQueryData(allSettinsQueryKey) + + const parsedPreviousSettings = parse( + SettingContracts.SettingsSchema, + previousSettings + ) + + queryClient.setQueryData(allSettinsQueryKey, oldSettings => { + if (!oldSettings) return oldSettings + + const parsedOldSettings = parse( + SettingContracts.SettingsSchema, + oldSettings + ) + + return { + ...parsedOldSettings, + general: { ...parsedOldSettings.general, ...body } + } + }) + + return { previousSettings: parsedPreviousSettings } + }, + onError: (_, __, context) => { + queryClient.setQueryData(allSettinsQueryKey, context?.previousSettings) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: allSettinsQueryKey }) + } + }) +} diff --git a/src/features/settings/general/config/board-background-blur-options.ts b/src/features/settings/general/config/board-background-blur-options.ts new file mode 100644 index 000000000..86a919b87 --- /dev/null +++ b/src/features/settings/general/config/board-background-blur-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const BOARD_BACKGROUND_BLUR_OPTIONS: SettingTypes.Option[] = [ + { value: 'off', label: 'Off' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } +] as const diff --git a/src/features/settings/general/config/date-format-options.ts b/src/features/settings/general/config/date-format-options.ts new file mode 100644 index 000000000..527b7e111 --- /dev/null +++ b/src/features/settings/general/config/date-format-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const DATE_FORMAT_OPTIONS: SettingTypes.Option[] = [ + { value: 'dd_mm_yyyy', label: '31/12/2026' }, + { value: 'mm_dd_yyyy', label: '12/31/2026' }, + { value: 'yyyy_mm_dd', label: '2026/12/31' } +] as const diff --git a/src/features/settings/general/config/enable-animations-options.ts b/src/features/settings/general/config/enable-animations-options.ts new file mode 100644 index 000000000..1a4b24cc4 --- /dev/null +++ b/src/features/settings/general/config/enable-animations-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const ENABLE_ANIMATIONS_OPTIONS: SettingTypes.Option[] = [ + { value: 'system', label: 'System' }, + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' } +] as const diff --git a/src/features/settings/general/config/first-day-week-options.ts b/src/features/settings/general/config/first-day-week-options.ts new file mode 100644 index 000000000..a86eacaeb --- /dev/null +++ b/src/features/settings/general/config/first-day-week-options.ts @@ -0,0 +1,6 @@ +import type { SettingTypes } from '@/entities/setting' + +export const FIRST_DAY_WEEK_OPTIONS: SettingTypes.Option[] = [ + { value: 'sunday', label: 'Sunday' }, + { value: 'monday', label: 'Monday' } +] as const diff --git a/src/features/settings/general/config/font-size-options.ts b/src/features/settings/general/config/font-size-options.ts new file mode 100644 index 000000000..5926ac017 --- /dev/null +++ b/src/features/settings/general/config/font-size-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const FONT_SIZE_OPTIONS: SettingTypes.Option[] = [ + { value: 'small', label: 'Small' }, + { value: 'medium', label: 'Medium' }, + { value: 'large', label: 'Large' } +] as const diff --git a/src/features/settings/general/config/general-settings-data.ts b/src/features/settings/general/config/general-settings-data.ts new file mode 100644 index 000000000..6667b8b08 --- /dev/null +++ b/src/features/settings/general/config/general-settings-data.ts @@ -0,0 +1,69 @@ +import type { SettingTypes } from '@/entities/setting' +import type { GeneralSettings } from '@/shared/api' + +import { BOARD_BACKGROUND_BLUR_OPTIONS } from './board-background-blur-options' +import { DATE_FORMAT_OPTIONS } from './date-format-options' +import { ENABLE_ANIMATIONS_OPTIONS } from './enable-animations-options' +import { FIRST_DAY_WEEK_OPTIONS } from './first-day-week-options' +import { FONT_SIZE_OPTIONS } from './font-size-options' +import { THEME_OPTIONS } from './theme-options' + +export const GENERAL_SETTINGS: SettingTypes.SettingDefinition< + GeneralSettings, + 'accentColor' +>[] = [ + { + type: 'select', + key: 'theme', + title: 'Theme', + description: 'Choose between light, dark, or system appearance.', + options: THEME_OPTIONS + }, + { + type: 'custom', + key: 'accentColor', + title: 'Accent color', + description: 'Set the primary color used throughout the app.' + }, + { + type: 'select', + key: 'firstDayOfWeek', + title: 'First day of the week', + description: 'Choose which day your calendars and date pickers start on.', + options: FIRST_DAY_WEEK_OPTIONS + }, + { + type: 'select', + key: 'dateFormat', + title: 'Date format', + description: 'Select how dates are displayed across the app.', + options: DATE_FORMAT_OPTIONS + }, + { + type: 'select', + key: 'boardBackgroundBlur', + title: 'Board background blur', + description: 'Adjust the blur applied to board backgrounds.', + options: BOARD_BACKGROUND_BLUR_OPTIONS + }, + { + type: 'select', + key: 'fontSize', + title: 'Font size', + description: 'Adjust the size of text throughout the app.', + options: FONT_SIZE_OPTIONS + }, + { + type: 'select', + key: 'enableAnimations', + title: 'Enable animations', + description: 'Enable animations and transitions throughout the app.', + options: ENABLE_ANIMATIONS_OPTIONS + }, + { + type: 'switch', + key: 'confirmBeforeDelete', + title: 'Confirm before delete', + description: 'Ask for confirmation before permanently deleting items.' + } +] as const diff --git a/src/features/settings/general/config/theme-options.ts b/src/features/settings/general/config/theme-options.ts new file mode 100644 index 000000000..ae2dfe5f1 --- /dev/null +++ b/src/features/settings/general/config/theme-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const THEME_OPTIONS: SettingTypes.Option[] = [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'system', label: 'System' } +] as const diff --git a/src/features/settings/general/index.ts b/src/features/settings/general/index.ts new file mode 100644 index 000000000..68021e0db --- /dev/null +++ b/src/features/settings/general/index.ts @@ -0,0 +1 @@ +export { GeneralSettings } from './ui/GeneralSettings' diff --git a/src/features/settings/general/ui/AccentColorSelect.tsx b/src/features/settings/general/ui/AccentColorSelect.tsx new file mode 100644 index 000000000..d938e0984 --- /dev/null +++ b/src/features/settings/general/ui/AccentColorSelect.tsx @@ -0,0 +1,57 @@ +import { ACCENT_COLOR_MAP } from '@/entities/user/@x/setting' + +import { AccentColor } from '@/shared/api' +import { capitalize } from '@/shared/lib' +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, + SelectValue +} from '@/shared/ui' + +type AccentColorSelectProps = { + value: AccentColor | undefined + onChange: (v: AccentColor | null) => void +} + +export const AccentColorSelect = ({ + value, + onChange +}: AccentColorSelectProps) => ( + +) diff --git a/src/features/settings/general/ui/GeneralSettings.tsx b/src/features/settings/general/ui/GeneralSettings.tsx new file mode 100644 index 000000000..615f35b59 --- /dev/null +++ b/src/features/settings/general/ui/GeneralSettings.tsx @@ -0,0 +1,55 @@ +import { Settings, useSettings } from '@/entities/setting' + +import { Switch } from '@/shared/ui' + +import { useUpdateGeneralSettings } from '../api/useUpdateGeneralSettings' +import { GENERAL_SETTINGS } from '../config/general-settings-data' +import { AccentColorSelect } from './AccentColorSelect' + +export const GeneralSettings = () => { + const { data: generalSettings, isPending } = useSettings( + select => select.general + ) + + const { mutate: update } = useUpdateGeneralSettings() + + return ( + + {GENERAL_SETTINGS.map(setting => ( + + + {setting.title} + {setting.description} + + + {setting.type === 'select' && ( + update({ body: { [setting.key]: value } })} + /> + )} + + {setting.type === 'switch' && ( + + update({ body: { [setting.key]: checked } }) + } + /> + )} + + {setting.type === 'custom' && ( + update({ body: { accentColor: value! } })} + /> + )} + + + ))} + + ) +} diff --git a/src/features/settings/labels/api/useAddLabel.ts b/src/features/settings/labels/api/useAddLabel.ts new file mode 100644 index 000000000..7d2b0e98d --- /dev/null +++ b/src/features/settings/labels/api/useAddLabel.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query' +import { isAxiosError } from 'axios' + +import { labelQueries, useLabelModalStore } from '@/entities/label' + +import { createLabelMutation } from '@/shared/api' + +export const useAddLabel = ( + onCreatedLabel: ((...event: unknown[]) => void) | undefined +) => { + const { setModal } = useLabelModalStore() + + return useMutation({ + ...createLabelMutation(), + meta: { + invalidates: [labelQueries.lists()], + errorMessage: e => { + if (isAxiosError(e)) { + return e.response?.status === 409 + ? 'A label with this name already exists.' + : 'An error occurred while adding the label. Please try again.' + } + } + }, + onSuccess(data) { + setModal({ isOpen: false }) + onCreatedLabel?.(data) + } + }) +} diff --git a/src/features/settings/labels/api/useDeleteLabel.ts b/src/features/settings/labels/api/useDeleteLabel.ts new file mode 100644 index 000000000..d7a0c4642 --- /dev/null +++ b/src/features/settings/labels/api/useDeleteLabel.ts @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { array, parse } from 'valibot' + +import { boardQueries } from '@/entities/board' +import { labelQueries } from '@/entities/label' + +import { deleteLabelMutation, vLabel } from '@/shared/api' + +export const useDeleteLabel = () => { + const queryClient = useQueryClient() + + const allLabelsQueryKey = labelQueries.lists() + + return useMutation({ + ...deleteLabelMutation(), + meta: { + errorMessage: + 'An error occurred while deleting the label. Please try again.' + }, + onMutate: async ({ path: { labelId } }) => { + await queryClient.cancelQueries({ queryKey: allLabelsQueryKey }) + + const previousLabels = queryClient.getQueryData(allLabelsQueryKey) + + const parsedPreviousLabels = parse(array(vLabel), previousLabels) + + queryClient.setQueryData(allLabelsQueryKey, oldLabels => { + if (!oldLabels) return oldLabels + + const parsedOldLabels = parse(array(vLabel), oldLabels) + + return parsedOldLabels.filter(l => l.id !== labelId) + }) + + return { previousLabels: parsedPreviousLabels } + }, + onError: (_, __, context) => { + queryClient.setQueryData(allLabelsQueryKey, context?.previousLabels) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: allLabelsQueryKey }) + queryClient.invalidateQueries({ queryKey: boardQueries.lists() }) + queryClient.invalidateQueries({ queryKey: boardQueries.details() }) + } + }) +} diff --git a/src/features/settings/labels/api/useEditLabel.ts b/src/features/settings/labels/api/useEditLabel.ts new file mode 100644 index 000000000..55975c943 --- /dev/null +++ b/src/features/settings/labels/api/useEditLabel.ts @@ -0,0 +1,33 @@ +import type { Dispatch, SetStateAction } from 'react' + +import { useMutation } from '@tanstack/react-query' +import { isAxiosError } from 'axios' + +import { boardQueries } from '@/entities/board' +import { labelQueries } from '@/entities/label' + +import { updateLabelMutation } from '@/shared/api' + +export const useEditLabel = ( + setIsDialogOpen: Dispatch> +) => + useMutation({ + ...updateLabelMutation(), + meta: { + invalidates: [ + labelQueries.lists(), + boardQueries.lists(), + boardQueries.details() + ], + errorMessage: e => { + if (isAxiosError(e)) { + return e.response?.status === 409 + ? 'A label with this name already exists.' + : 'An error occurred while editing the label. Please try again.' + } + } + }, + onSuccess() { + setIsDialogOpen(false) + } + }) diff --git a/src/features/settings/labels/api/useGetLabelsSettings.ts b/src/features/settings/labels/api/useGetLabelsSettings.ts new file mode 100644 index 000000000..8acb9a6b7 --- /dev/null +++ b/src/features/settings/labels/api/useGetLabelsSettings.ts @@ -0,0 +1,17 @@ +import { useQueries } from '@tanstack/react-query' + +import { labelQueries } from '@/entities/label' +import { settingQueries } from '@/entities/setting' + +export const useGetLabelsSettings = () => + useQueries({ + queries: [ + labelQueries.list(), + settingQueries.list({ select: settings => settings.label }) + ], + combine: results => ({ + labels: results[0].data, + labelSettings: results[1].data, + isPending: results.some(result => result.isPending) + }) + }) diff --git a/src/features/settings/labels/api/useUpdateLabelsSettings.ts b/src/features/settings/labels/api/useUpdateLabelsSettings.ts new file mode 100644 index 000000000..0fe2bc1f4 --- /dev/null +++ b/src/features/settings/labels/api/useUpdateLabelsSettings.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { parse } from 'valibot' + +import { SettingContracts, settingQueries } from '@/entities/setting' + +import { updateLabelSettingsMutation } from '@/shared/api' + +export const useUpdateLabelsSettings = () => { + const queryClient = useQueryClient() + + const allSettinsQueryKey = settingQueries.lists() + + return useMutation({ + ...updateLabelSettingsMutation(), + meta: { + errorMessage: 'We couldn’t update your settings. Please try again' + }, + onMutate: async ({ body }) => { + await queryClient.cancelQueries({ queryKey: allSettinsQueryKey }) + + const previousSettings = queryClient.getQueryData(allSettinsQueryKey) + + const parsedPreviousSettings = parse( + SettingContracts.SettingsSchema, + previousSettings + ) + + queryClient.setQueryData(allSettinsQueryKey, oldSettings => { + if (!oldSettings) return oldSettings + + const parsedOldSettings = parse( + SettingContracts.SettingsSchema, + oldSettings + ) + + return { + ...parsedOldSettings, + label: { ...parsedOldSettings.label, ...body } + } + }) + + return { previousSettings: parsedPreviousSettings } + }, + onError: (_, __, context) => { + queryClient.setQueryData(allSettinsQueryKey, context?.previousSettings) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: allSettinsQueryKey }) + } + }) +} diff --git a/src/features/settings/labels/config/label-display-options.ts b/src/features/settings/labels/config/label-display-options.ts new file mode 100644 index 000000000..aab401534 --- /dev/null +++ b/src/features/settings/labels/config/label-display-options.ts @@ -0,0 +1,7 @@ +import type { SettingTypes } from '@/entities/setting' + +export const LABEL_DISPLAY_OPTIONS: SettingTypes.Option[] = [ + { value: 'full', label: 'Full' }, + { value: 'compact', label: 'Compact' }, + { value: 'hidden', label: 'Hidden' } +] as const diff --git a/src/features/settings/labels/config/labels-settings-data.ts b/src/features/settings/labels/config/labels-settings-data.ts new file mode 100644 index 000000000..9ce65a69a --- /dev/null +++ b/src/features/settings/labels/config/labels-settings-data.ts @@ -0,0 +1,23 @@ +import type { SettingTypes } from '@/entities/setting' +import type { LabelSettings } from '@/shared/api' + +import { LABEL_DISPLAY_OPTIONS } from './label-display-options' +import { MAX_LABELS_SHOWN_OPTIONS } from './max-labels-shown-options' + +export const LABELS_SETTINGS: SettingTypes.SettingDefinition[] = + [ + { + type: 'select', + key: 'labelDisplay', + title: 'Label display', + description: 'Choose how labels appear on task cards.', + options: LABEL_DISPLAY_OPTIONS + }, + { + type: 'select', + key: 'maxLabelsShown', + title: 'Maximum labels shown', + description: 'Set the maximum number of labels displayed on each task.', + options: MAX_LABELS_SHOWN_OPTIONS + } + ] as const diff --git a/src/features/settings/labels/config/max-labels-shown-options.ts b/src/features/settings/labels/config/max-labels-shown-options.ts new file mode 100644 index 000000000..d43b80905 --- /dev/null +++ b/src/features/settings/labels/config/max-labels-shown-options.ts @@ -0,0 +1,8 @@ +import type { SettingTypes } from '@/entities/setting' + +export const MAX_LABELS_SHOWN_OPTIONS: SettingTypes.Option[] = [ + { value: '0', label: 'All' }, + { value: '1', label: '1' }, + { value: '2', label: '2' }, + { value: '3', label: '3' } +] as const diff --git a/src/features/settings/labels/index.ts b/src/features/settings/labels/index.ts new file mode 100644 index 000000000..84ba3d678 --- /dev/null +++ b/src/features/settings/labels/index.ts @@ -0,0 +1,2 @@ +export { LabelsSettings } from './ui/LabelsSettings' +export { AddLabelDialog } from './ui/add/AddLabelDialog' diff --git a/src/features/settings/labels/model/contract.ts b/src/features/settings/labels/model/contract.ts new file mode 100644 index 000000000..bae9e9cf1 --- /dev/null +++ b/src/features/settings/labels/model/contract.ts @@ -0,0 +1,32 @@ +import * as v from 'valibot' + +import { vAccentColor } from '@/shared/api' + +export const AddLabelSchema = v.object({ + name: v.pipe( + v.string(), + v.trim(), + v.minLength(2, 'Please enter at least 2 characters.') + ), + color: v.fallback(vAccentColor, 'blue') +}) + +export const EditLabelSchema = v.intersect([ + v.partial(AddLabelSchema), + v.object({ + description: v.union([ + v.pipe( + v.literal(''), + v.transform(() => null) + ), + v.pipe( + v.string(), + v.trim(), + v.minLength(3, 'Please enter at least 3 characters.') + ) + ]) + }) +]) + +export type AddLabelSchema = v.InferOutput +export type EditLabelSchema = v.InferOutput diff --git a/src/features/settings/labels/ui/LabelsSettings.tsx b/src/features/settings/labels/ui/LabelsSettings.tsx new file mode 100644 index 000000000..848051b6f --- /dev/null +++ b/src/features/settings/labels/ui/LabelsSettings.tsx @@ -0,0 +1,50 @@ +import { Settings } from '@/entities/setting' + +import { Switch } from '@/shared/ui' + +import { useGetLabelsSettings } from '../api/useGetLabelsSettings' +import { useUpdateLabelsSettings } from '../api/useUpdateLabelsSettings' +import { LABELS_SETTINGS } from '../config/labels-settings-data' +import { AddLabelDialogButton } from './add/AddLabelDialogButton' +import { LabelsTable } from './LabelsTable' + +export const LabelsSettings = () => { + const { labels, labelSettings, isPending } = useGetLabelsSettings() + + const { mutate: update } = useUpdateLabelsSettings() + + return ( + + + + {LABELS_SETTINGS.map(setting => ( + + + {setting.title} + {setting.description} + + + {setting.type === 'select' && ( + update({ body: { [setting.key]: value } })} + /> + )} + + {setting.type === 'switch' && ( + + update({ body: { [setting.key]: checked } }) + } + /> + )} + + + ))} + + ) +} diff --git a/src/features/settings/labels/ui/LabelsTable.tsx b/src/features/settings/labels/ui/LabelsTable.tsx new file mode 100644 index 000000000..7cc5c5af8 --- /dev/null +++ b/src/features/settings/labels/ui/LabelsTable.tsx @@ -0,0 +1,100 @@ +import type { Label } from '@/shared/api' + +import { format } from 'date-fns' +import { AnimatePresence } from 'motion/react' +import * as m from 'motion/react-m' + +import { LABEL_BASE_COLOR_MAP } from '@/entities/label' + +import { cn, useMediaQuery } from '@/shared/lib' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/shared/ui' + +import { DeleteLabelAlertDialog } from './delete/DeleteLabelAlertDialog' +import { EditLabelDialog } from './edit/EditLabelDialog' + +const MotionTableRow = m.create(TableRow) + +type LabelsTableProps = { + labels: Label[] | undefined +} + +export const LabelsTable = ({ labels }: LabelsTableProps) => { + const isDesktop = useMediaQuery('(min-width: 1440px)') + + return ( + + + + Name + + Description + + Created + Actions + + + + + {labels && labels.length > 0 ? ( + labels.map(({ id, name, description, color, createdAt }) => ( + + + +

{name}

+
+ + {description} + + + {format(createdAt, 'MMM yyyy')} + + + + + +
+ )) + ) : ( + + + No labels yet. Add your first label to get started. + + + )} +
+
+
+ ) +} diff --git a/src/features/label/add/ui/AddLabelDialog.tsx b/src/features/settings/labels/ui/add/AddLabelDialog.tsx similarity index 100% rename from src/features/label/add/ui/AddLabelDialog.tsx rename to src/features/settings/labels/ui/add/AddLabelDialog.tsx diff --git a/src/features/settings/labels/ui/add/AddLabelDialogButton.tsx b/src/features/settings/labels/ui/add/AddLabelDialogButton.tsx new file mode 100644 index 000000000..e08ba4d2d --- /dev/null +++ b/src/features/settings/labels/ui/add/AddLabelDialogButton.tsx @@ -0,0 +1,22 @@ +import { PlusIcon } from 'lucide-react' + +import { useLabelModalStore } from '@/entities/label' + +import { Button } from '@/shared/ui' + +export const AddLabelDialogButton = () => { + const { setModal } = useLabelModalStore() + + return ( + + ) +} diff --git a/src/features/settings/labels/ui/add/AddLabelForm.tsx b/src/features/settings/labels/ui/add/AddLabelForm.tsx new file mode 100644 index 000000000..13d37deda --- /dev/null +++ b/src/features/settings/labels/ui/add/AddLabelForm.tsx @@ -0,0 +1,80 @@ +import * as m from 'motion/react-m' + +import { LabelColorPicker } from '@/entities/label' + +import { formVariants, useAppForm } from '@/shared/lib' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + PlusButtonWithLoader +} from '@/shared/ui' + +import { useAddLabel } from '../../api/useAddLabel' +import { AddLabelSchema } from '../../model/contract' + +type AddLabelFormProps = { + name?: string + onCreatedLabel?: (...event: unknown[]) => void +} + +export const AddLabelForm = ({ name, onCreatedLabel }: AddLabelFormProps) => { + const form = useAppForm(AddLabelSchema, { + defaultValues: { name: name ?? '', color: 'blue' } + }) + + const { mutate: addLabel, isPending } = useAddLabel(onCreatedLabel) + + return ( +
+ addLabel({ body: data }))}> + + ( + + + Name + + } /> + + + )} + /> + + + ( + + + Color + + + + + )} + /> + + + + Add + + + +
+ ) +} diff --git a/src/features/settings/labels/ui/delete/DeleteLabelAlertDialog.tsx b/src/features/settings/labels/ui/delete/DeleteLabelAlertDialog.tsx new file mode 100644 index 000000000..d5cd14682 --- /dev/null +++ b/src/features/settings/labels/ui/delete/DeleteLabelAlertDialog.tsx @@ -0,0 +1,53 @@ +import { TrashIcon } from 'lucide-react' + +import { ConfirmDeleteTrigger } from '@/entities/setting' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogTrigger +} from '@/shared/ui' + +import { useDeleteLabel } from '../../api/useDeleteLabel' + +export const DeleteLabelAlertDialog = ({ labelId }: { labelId: string }) => { + const { mutate: deleteLabel } = useDeleteLabel() + + const handleDeleteLabel = () => { + deleteLabel({ path: { labelId } }) + } + + return ( + + + + + + + Delete label? + + This action cannot be undone. The label will be permanently deleted + and removed from all tasks that use it. + + + Cancel + + Delete + + + + + + ) +} diff --git a/src/features/settings/labels/ui/edit/EditLabelDialog.tsx b/src/features/settings/labels/ui/edit/EditLabelDialog.tsx new file mode 100644 index 000000000..4ba36b06b --- /dev/null +++ b/src/features/settings/labels/ui/edit/EditLabelDialog.tsx @@ -0,0 +1,46 @@ +import type { Label } from '@/shared/api' + +import { useState } from 'react' +import { PencilIcon } from 'lucide-react' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger +} from '@/shared/ui' + +import { EditLabelForm } from './EditLabelForm' + +type EditLabelDialogProps = { + formData: Pick +} + +export const EditLabelDialog = ({ formData }: EditLabelDialogProps) => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + + return ( + + + + + + Edit Label + + You can edit a label here by changing its name and color. + + + + + ) +} diff --git a/src/features/settings/labels/ui/edit/EditLabelForm.tsx b/src/features/settings/labels/ui/edit/EditLabelForm.tsx new file mode 100644 index 000000000..f9b19eedc --- /dev/null +++ b/src/features/settings/labels/ui/edit/EditLabelForm.tsx @@ -0,0 +1,94 @@ +import type { Label } from '@/shared/api' +import type { Dispatch, SetStateAction } from 'react' + +import { LabelColorPicker } from '@/entities/label' + +import { useAppForm } from '@/shared/lib' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + PlusButtonWithLoader, + TextArea +} from '@/shared/ui' + +import { useEditLabel } from '../../api/useEditLabel' +import { EditLabelSchema } from '../../model/contract' + +type EditLabelFormProps = { + formData: Pick + setIsDialogOpen: Dispatch> +} + +export const EditLabelForm = ({ + formData: { name, color, description, id }, + setIsDialogOpen +}: EditLabelFormProps) => { + const form = useAppForm(EditLabelSchema, { + defaultValues: { name, color, description: description ?? '' } + }) + + const { mutate: editLabel, isPending } = useEditLabel(setIsDialogOpen) + + return ( +
+ + editLabel({ path: { labelId: id }, body: data }) + )}> + ( + + + Name + + } /> + + + )} + /> + ( + + + Color + + + + + )} + /> + ( + + + Description + + } + className='h-30' + /> + + + )} + /> + + Edit + + + + ) +} diff --git a/src/features/settings/security/api/useAccountConnection.ts b/src/features/settings/security/api/useAccountConnection.ts new file mode 100644 index 000000000..e625fafcb --- /dev/null +++ b/src/features/settings/security/api/useAccountConnection.ts @@ -0,0 +1,42 @@ +import { useMutation } from '@tanstack/react-query' +import { useLocation } from '@tanstack/react-router' + +import { sessionQueries } from '@/entities/user' + +import { authClient, getAuthErrorMessage } from '@/shared/api' +import { env } from '@/shared/config' + +type AccountConnectionMutationData = { + providerId: string + isConnected: boolean +} + +export const useAccountConnection = () => { + const { href } = useLocation() + + return useMutation({ + mutationFn: async ({ + providerId, + isConnected + }: AccountConnectionMutationData) => { + if (isConnected) return authClient.unlinkAccount({ providerId }) + + return authClient.linkSocial({ + provider: providerId, + callbackURL: env.VITE_BASE_URL + href, + errorCallbackURL: env.VITE_BASE_URL + href + }) + }, + meta: { + invalidates: [sessionQueries.accounts()], + errorMessage: e => { + if (e && 'error' in e) { + return ( + getAuthErrorMessage(e.error.code) ?? + 'An error occurred while updating the account connection. Please try again.' + ) + } + } + } + }) +} diff --git a/src/features/settings/security/api/useAddPasskey.ts b/src/features/settings/security/api/useAddPasskey.ts new file mode 100644 index 000000000..9079b3870 --- /dev/null +++ b/src/features/settings/security/api/useAddPasskey.ts @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query' + +import { sessionQueries } from '@/entities/user' + +import { authClient, getAuthErrorMessage } from '@/shared/api' +import { toast } from '@/shared/lib' + +import { usePasskeyDialogStore } from '../model/passkey-dialog.store' + +export const useAddPasskey = () => { + const { setIsOpen } = usePasskeyDialogStore() + + return useMutation({ + mutationFn: () => authClient.passkey.addPasskey(), + meta: { + invalidates: [sessionQueries.passkeys()], + errorMessage: + 'An error occurred while adding the passkey. Please try again.' + }, + onSuccess({ error }) { + if (error && 'code' in error) { + if (error.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { + return toast.warning('Passkey sign-in cancelled.') + } + + const errorMessage = getAuthErrorMessage(error.code) + + toast.error(errorMessage, { + description: 'The authenticator is already registered.' + }) + } + + if (!error) setIsOpen(true) + } + }) +} diff --git a/src/features/settings/security/api/useDeletePasskey.ts b/src/features/settings/security/api/useDeletePasskey.ts new file mode 100644 index 000000000..d80bf5b0f --- /dev/null +++ b/src/features/settings/security/api/useDeletePasskey.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { sessionQueries } from '@/entities/user' + +import { authClient } from '@/shared/api' + +export const useDeletePasskey = () => { + const queryClient = useQueryClient() + + const allPasskeysQueryKey = sessionQueries.passkey().queryKey + + return useMutation({ + mutationFn: ({ id }: { id: string }) => + authClient.passkey.deletePasskey({ id }), + meta: { + errorMessage: + 'An error occurred while deleting the passkey. Please try again.' + }, + onMutate: async ({ id: passkeyId }) => { + await queryClient.cancelQueries({ queryKey: allPasskeysQueryKey }) + + const previousPasskeys = queryClient.getQueryData(allPasskeysQueryKey) + + queryClient.setQueryData(allPasskeysQueryKey, oldPasskeys => { + if (!oldPasskeys) return oldPasskeys + + return oldPasskeys.filter(passkey => passkey.id !== passkeyId) + }) + + return { previousPasskeys } + }, + onError: (_, __, context) => { + queryClient.setQueryData(allPasskeysQueryKey, context?.previousPasskeys) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: allPasskeysQueryKey }) + } + }) +} diff --git a/src/features/settings/security/api/useGetSecuritySettings.ts b/src/features/settings/security/api/useGetSecuritySettings.ts new file mode 100644 index 000000000..bb897f85b --- /dev/null +++ b/src/features/settings/security/api/useGetSecuritySettings.ts @@ -0,0 +1,18 @@ +import { useQueries } from '@tanstack/react-query' + +import { sessionQueries } from '@/entities/user' + +export const useGetSecuritySettings = () => + useQueries({ + queries: [ + sessionQueries.list(), + sessionQueries.passkey(), + sessionQueries.account() + ], + combine: results => ({ + sessions: results[0].data, + passkeys: results[1].data, + accounts: results[2].data, + isPending: results.some(result => result.isPending) + }) + }) diff --git a/src/features/settings/security/api/useRevokeSession.ts b/src/features/settings/security/api/useRevokeSession.ts new file mode 100644 index 000000000..76cb43ad0 --- /dev/null +++ b/src/features/settings/security/api/useRevokeSession.ts @@ -0,0 +1,31 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' + +import { sessionQueries } from '@/entities/user' + +import { authClient } from '@/shared/api' + +export const useRevokeSession = () => { + const { data: currentSession } = useQuery(sessionQueries.current()) + + const queryClient = useQueryClient() + + const navigate = useNavigate() + + return useMutation({ + mutationFn: ({ id }: { id: string }) => + authClient.revokeSessionById({ id }), + meta: { + invalidates: [sessionQueries.lists()], + errorMessage: + 'An error occurred while revoking the session. Please try again.' + }, + onSuccess: async (_, variables) => { + if (variables.id !== currentSession?.session.id) return + + await authClient.signOut() + queryClient.setQueryData(sessionQueries.currents(), null) + navigate({ to: '/' }) + } + }) +} diff --git a/src/features/settings/security/api/useUpdatePasskey.ts b/src/features/settings/security/api/useUpdatePasskey.ts new file mode 100644 index 000000000..252079258 --- /dev/null +++ b/src/features/settings/security/api/useUpdatePasskey.ts @@ -0,0 +1,24 @@ +import { useMutation } from '@tanstack/react-query' + +import { sessionQueries } from '@/entities/user' + +import { authClient } from '@/shared/api' + +type UpdatePasskeyMutation = { + id: string + name: string +} + +export const useUpdatePasskey = (closeDialog: () => void) => + useMutation({ + mutationFn: (data: UpdatePasskeyMutation) => + authClient.passkey.updatePasskey(data), + meta: { + invalidates: [sessionQueries.passkeys()], + errorMessage: + 'An error occurred while updating the passkey. Please try again.' + }, + onSuccess() { + closeDialog() + } + }) diff --git a/src/features/settings/security/config/account-data.ts b/src/features/settings/security/config/account-data.ts new file mode 100644 index 000000000..f92ca6f06 --- /dev/null +++ b/src/features/settings/security/config/account-data.ts @@ -0,0 +1,6 @@ +import { Google, Microsoft } from '@thesvg/react' + +export const ACCOUNT_DATA = [ + { id: 'google', icon: Google, label: 'Google' }, + { id: 'microsoft', icon: Microsoft, label: 'Microsoft' } +] diff --git a/src/features/settings/security/config/browser-icons.ts b/src/features/settings/security/config/browser-icons.ts new file mode 100644 index 000000000..43c3a44ca --- /dev/null +++ b/src/features/settings/security/config/browser-icons.ts @@ -0,0 +1,34 @@ +import type { ElementType } from 'react' + +import { + Bing, + BraveBrowser, + Chrome, + Chromium, + Duckduckgo, + Ecosia, + FirefoxBrowser, + MicrosoftEdge, + Opera, + Safari, + SamsungBrowser, + Vivaldi +} from '@thesvg/react' + +export const BROWSER_ICONS: Record = { + Chrome, + Safari, + Firefox: FirefoxBrowser, + Edge: MicrosoftEdge, + Brave: BraveBrowser, + Opera, + Bing, + Vivaldi, + DuckDuckGo: Duckduckgo, + Chromium, + Ecosia, + 'Samsung Internet': SamsungBrowser, + 'Mobile Chrome': Chrome, + 'Mobile Firefox': FirefoxBrowser, + 'Mobile Safari': Safari +} as const diff --git a/src/features/settings/security/index.ts b/src/features/settings/security/index.ts new file mode 100644 index 000000000..8426dba75 --- /dev/null +++ b/src/features/settings/security/index.ts @@ -0,0 +1 @@ +export { SecuritySettings } from './ui/SecuritySettings' diff --git a/src/features/settings/security/model/contract.ts b/src/features/settings/security/model/contract.ts new file mode 100644 index 000000000..9b199ce92 --- /dev/null +++ b/src/features/settings/security/model/contract.ts @@ -0,0 +1,11 @@ +import * as v from 'valibot' + +export const PasskeyNameSchema = v.object({ + name: v.pipe( + v.string(), + v.trim(), + v.minLength(2, 'Please enter at least 2 characters.') + ) +}) + +export type PasskeyNameSchema = v.InferOutput diff --git a/src/features/settings/security/model/passkey-dialog.store.ts b/src/features/settings/security/model/passkey-dialog.store.ts new file mode 100644 index 000000000..0cbc804eb --- /dev/null +++ b/src/features/settings/security/model/passkey-dialog.store.ts @@ -0,0 +1,5 @@ +import { createStore } from 'stan-js' + +export const { useStore: usePasskeyDialogStore } = createStore({ + isOpen: false +}) diff --git a/src/features/settings/security/ui/SecurityActionButton.tsx b/src/features/settings/security/ui/SecurityActionButton.tsx new file mode 100644 index 000000000..db7395f67 --- /dev/null +++ b/src/features/settings/security/ui/SecurityActionButton.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps } from 'react' + +import { cn } from '@/shared/lib' + +export const SecurityActionButton = ({ + children, + className, + onClick, + ...props +}: ComponentProps<'button'>) => ( + +) diff --git a/src/features/settings/security/ui/SecuritySettings.tsx b/src/features/settings/security/ui/SecuritySettings.tsx new file mode 100644 index 000000000..189ba0382 --- /dev/null +++ b/src/features/settings/security/ui/SecuritySettings.tsx @@ -0,0 +1,27 @@ +import { Settings } from '@/entities/setting' + +import { useGetSecuritySettings } from '../api/useGetSecuritySettings' +import { AccountList } from './account-list/AccountList' +import { AccountOAuthErrorHandler } from './account-list/AccountOAuthErrorHandler' +import { PasskeysSection } from './passkeys/PasskeysSection' +import { SessionsList } from './session-list/SessionList' + +export const SecuritySettings = () => { + const { sessions, passkeys, accounts, isPending } = useGetSecuritySettings() + + return ( + + Sessions + + Passkeys + + + Connected accounts + + + + + ) +} diff --git a/src/features/settings/security/ui/account-list/AccountConnectionButton.tsx b/src/features/settings/security/ui/account-list/AccountConnectionButton.tsx new file mode 100644 index 000000000..42c4cab7e --- /dev/null +++ b/src/features/settings/security/ui/account-list/AccountConnectionButton.tsx @@ -0,0 +1,28 @@ +import { useAccountConnection } from '../../api/useAccountConnection' +import { SecurityActionButton } from '../SecurityActionButton' + +type AccountConnectionButtonProps = { + isConnected: boolean + providerId: string +} + +export const AccountConnectionButton = ({ + isConnected, + providerId +}: AccountConnectionButtonProps) => { + const { mutate, isPending } = useAccountConnection() + + return ( + mutate({ providerId, isConnected })} + disabled={isPending}> + {isPending + ? isConnected + ? 'Disconnecting...' + : 'Connecting...' + : isConnected + ? 'Disconnect' + : 'Connect'} + + ) +} diff --git a/src/features/settings/security/ui/account-list/AccountList.tsx b/src/features/settings/security/ui/account-list/AccountList.tsx new file mode 100644 index 000000000..f9619ef6d --- /dev/null +++ b/src/features/settings/security/ui/account-list/AccountList.tsx @@ -0,0 +1,51 @@ +import type { Account } from 'better-auth' + +import { AnimatePresence } from 'motion/react' +import * as m from 'motion/react-m' + +import { Settings } from '@/entities/setting' + +import { ACCOUNT_DATA } from '../../config/account-data' +import { AccountConnectionButton } from './AccountConnectionButton' + +type AccountListProps = { + accounts: Account[] | undefined +} + +export const AccountList = ({ accounts }: AccountListProps) => + ACCOUNT_DATA.map(({ id, icon: Icon, label }) => { + const isConnected = !!accounts?.some(account => account.providerId === id) + + return ( + +
+ +
+

{label}

+ + {isConnected && ( + + + Connected + + )} + +
+ +
+
+ ) + }) diff --git a/src/features/settings/security/ui/account-list/AccountOAuthErrorHandler.tsx b/src/features/settings/security/ui/account-list/AccountOAuthErrorHandler.tsx new file mode 100644 index 000000000..d8418ec70 --- /dev/null +++ b/src/features/settings/security/ui/account-list/AccountOAuthErrorHandler.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react' +import { useLocation, useNavigate } from '@tanstack/react-router' + +import { toast } from '@/shared/lib' + +export const AccountOAuthErrorHandler = () => { + const { search } = useLocation() + + const navigate = useNavigate({ from: '/dashboard/settings/security' }) + + useEffect(() => { + if (search.error) { + toast.error( + search.error === "email_doesn't_match" + ? 'The email address you provided does not match the one associated with your account.' + : 'Something went wrong. Please try again in a moment' + ) + + navigate({ search: {} }) + } + }, [navigate, search.error]) + + return null +} diff --git a/src/features/settings/security/ui/passkeys/AddPasskeyButton.tsx b/src/features/settings/security/ui/passkeys/AddPasskeyButton.tsx new file mode 100644 index 000000000..ecf122fd1 --- /dev/null +++ b/src/features/settings/security/ui/passkeys/AddPasskeyButton.tsx @@ -0,0 +1,14 @@ +import { useAddPasskey } from '../../api/useAddPasskey' +import { SecurityActionButton } from '../SecurityActionButton' + +export const AddPasskeyButton = () => { + const { mutate: addPasskey, isPending } = useAddPasskey() + + return ( + addPasskey()} + disabled={isPending}> + {isPending ? 'Adding...' : 'Add'} + + ) +} diff --git a/src/features/settings/security/ui/passkeys/AddPasskeyNameDialog.tsx b/src/features/settings/security/ui/passkeys/AddPasskeyNameDialog.tsx new file mode 100644 index 000000000..817832a84 --- /dev/null +++ b/src/features/settings/security/ui/passkeys/AddPasskeyNameDialog.tsx @@ -0,0 +1,86 @@ +import { getAuthenticatorName } from '@better-auth/passkey' +import * as m from 'motion/react-m' + +import { formVariants, useAppForm } from '@/shared/lib' +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, + Form, + FormControl, + FormField, + FormItem, + FormMessage, + Input, + PlusButtonWithLoader +} from '@/shared/ui' + +import { useUpdatePasskey } from '../../api/useUpdatePasskey' +import { PasskeyNameSchema } from '../../model/contract' +import { usePasskeyDialogStore } from '../../model/passkey-dialog.store' + +type AddPasskeyNameDialogProps = { + passkeyId: string + passkeyAaguid: string | null | undefined +} + +export const AddPasskeyNameDialog = ({ + passkeyId, + passkeyAaguid +}: AddPasskeyNameDialogProps) => { + const { isOpen, setIsOpen } = usePasskeyDialogStore() + + const form = useAppForm(PasskeyNameSchema, { + defaultValues: { name: getAuthenticatorName(passkeyAaguid) ?? 'Passkey' } + }) + + const { mutate: updatePasskey, isPending } = useUpdatePasskey(() => + setIsOpen(false) + ) + + return ( + setIsOpen(false)}> + + New passkey added + + Give this passkey a name so you can easily recognize it when managing + your security settings. + +
+ + updatePasskey({ id: passkeyId, ...data }) + )} + className='space-y-6'> + + ( + + } /> + + + )} + /> + + + + Done + + + +
+
+
+ ) +} diff --git a/src/features/settings/security/ui/passkeys/DeletePasskeyAlertDialog.tsx b/src/features/settings/security/ui/passkeys/DeletePasskeyAlertDialog.tsx new file mode 100644 index 000000000..2844f4eae --- /dev/null +++ b/src/features/settings/security/ui/passkeys/DeletePasskeyAlertDialog.tsx @@ -0,0 +1,59 @@ +import { TrashIcon } from 'lucide-react' + +import { ConfirmDeleteTrigger } from '@/entities/setting' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogTrigger +} from '@/shared/ui' + +import { useDeletePasskey } from '../../api/useDeletePasskey' + +type DeleteBoardAlertDialogProps = { + passkeyId: string +} + +export const DeletePasskeyAlertDialog = ({ + passkeyId +}: DeleteBoardAlertDialogProps) => { + const { mutate: deletePasskey } = useDeletePasskey() + + const handleDeletePasskey = () => { + deletePasskey({ id: passkeyId }) + } + + return ( + + + + + + + Remove passkey? + + This passkey will no longer be available for signing in to your + account. You can add it again at any time. + + + Cancel + + Remove + + + + + + ) +} diff --git a/src/features/settings/security/ui/passkeys/EditPasskeyNameDialog.tsx b/src/features/settings/security/ui/passkeys/EditPasskeyNameDialog.tsx new file mode 100644 index 000000000..3ffc258b0 --- /dev/null +++ b/src/features/settings/security/ui/passkeys/EditPasskeyNameDialog.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react' +import { PencilIcon } from 'lucide-react' +import * as m from 'motion/react-m' + +import { formVariants, useAppForm } from '@/shared/lib' +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + PlusButtonWithLoader +} from '@/shared/ui' + +import { useUpdatePasskey } from '../../api/useUpdatePasskey' +import { PasskeyNameSchema } from '../../model/contract' + +type EditPasskeyNameDialogProps = { + passkeyId: string + name: string +} + +export const EditPasskeyNameDialog = ({ + passkeyId, + name +}: EditPasskeyNameDialogProps) => { + const [isDialogOpen, setIsDialogOpen] = useState(false) + + const form = useAppForm(PasskeyNameSchema, { + defaultValues: { name } + }) + + const { mutate: updatePasskey, isPending } = useUpdatePasskey(() => + setIsDialogOpen(false) + ) + + useEffect(() => { + form.reset({ name }) + }, [form, name]) + + return ( + + + + + + Edit passkey +
+ + updatePasskey({ id: passkeyId, ...data }) + )} + className='space-y-6'> + + ( + + Name + } /> + + + )} + /> + + + + Edit + + + +
+
+
+ ) +} diff --git a/src/features/settings/security/ui/passkeys/PasskeysSection.tsx b/src/features/settings/security/ui/passkeys/PasskeysSection.tsx new file mode 100644 index 000000000..764c6e926 --- /dev/null +++ b/src/features/settings/security/ui/passkeys/PasskeysSection.tsx @@ -0,0 +1,75 @@ +import type { Passkey } from '@better-auth/passkey/client' + +import { Separator } from '@base-ui/react' +import { KeyRoundIcon } from 'lucide-react' +import { AnimatePresence } from 'motion/react' +import * as m from 'motion/react-m' + +import { Settings } from '@/entities/setting' + +import { pluralize } from '@/shared/lib' + +import { AddPasskeyButton } from './AddPasskeyButton' +import { AddPasskeyNameDialog } from './AddPasskeyNameDialog' +import { DeletePasskeyAlertDialog } from './DeletePasskeyAlertDialog' +import { EditPasskeyNameDialog } from './EditPasskeyNameDialog' + +type PasskeysSectionProps = { + passkeys: Passkey[] | undefined +} + +export const PasskeysSection = ({ passkeys }: PasskeysSectionProps) => { + const hasPasskeys = !!passkeys?.length + + return ( + + +

+ {!hasPasskeys + ? 'No passkeys registered' + : pluralize(passkeys!.length, 'passkey')} +

+ +
+ {hasPasskeys && ( + <> + + + {passkeys!.map(passkey => ( + + +

{passkey.name}

+
+ + +
+ +
+ ))} +
+ + )} +
+ ) +} diff --git a/src/features/settings/security/ui/session-list/BrowserIcon.tsx b/src/features/settings/security/ui/session-list/BrowserIcon.tsx new file mode 100644 index 000000000..46bfecfa7 --- /dev/null +++ b/src/features/settings/security/ui/session-list/BrowserIcon.tsx @@ -0,0 +1,13 @@ +import { Globe } from 'lucide-react' + +import { BROWSER_ICONS } from '../../config/browser-icons' + +export const BrowserIcon = ({ browser }: { browser?: string }) => { + const icon = BROWSER_ICONS[browser as keyof typeof BROWSER_ICONS] + + if (!icon) return + + const Icon = icon + + return +} diff --git a/src/features/settings/security/ui/session-list/RevokeSessionButton.tsx b/src/features/settings/security/ui/session-list/RevokeSessionButton.tsx new file mode 100644 index 000000000..3db327c30 --- /dev/null +++ b/src/features/settings/security/ui/session-list/RevokeSessionButton.tsx @@ -0,0 +1,35 @@ +import { Loader } from '@/shared/ui' + +import { useRevokeSession } from '../../api/useRevokeSession' +import { SecurityActionButton } from '../SecurityActionButton' + +type RevokeSessionButtonProps = { + sessionId: string + isCurrent: boolean +} + +export const RevokeSessionButton = ({ + sessionId, + isCurrent +}: RevokeSessionButtonProps) => { + const { mutate: revokeSession, isPending, variables } = useRevokeSession() + + const isRevoking = isPending && variables?.id === sessionId + + return ( + revokeSession({ id: sessionId })}> + {isRevoking ? ( +
+ + {isCurrent ? 'Logging out...' : 'Revoking...'} +
+ ) : isCurrent ? ( + 'Log out' + ) : ( + 'Revoke' + )} +
+ ) +} diff --git a/src/features/settings/security/ui/session-list/SessionList.tsx b/src/features/settings/security/ui/session-list/SessionList.tsx new file mode 100644 index 000000000..f3e72f4c9 --- /dev/null +++ b/src/features/settings/security/ui/session-list/SessionList.tsx @@ -0,0 +1,75 @@ +import type { UserTypes } from '@/entities/user' + +import { Fragment } from 'react/jsx-runtime' +import { Separator } from '@base-ui/react' +import { formatDistanceToNowStrict } from 'date-fns' +import { AnimatePresence } from 'motion/react' + +import { Settings } from '@/entities/setting' + +import { BrowserIcon } from './BrowserIcon' +import { RevokeSessionButton } from './RevokeSessionButton' + +type SessionsListProps = { + sessions: UserTypes.InferedSession[] | undefined +} + +export const SessionsList = ({ sessions }: SessionsListProps) => ( + + {sessions?.map(({ id, browser, os, isCurrent, updatedAt }) => { + const meta = [ + isCurrent && ( +
+ + Current session +
+ ), + + !isCurrent && updatedAt && ( + + Last active{' '} + {formatDistanceToNowStrict(new Date(updatedAt), { + addSuffix: true + })} + + ) + ].filter(Boolean) + + return ( + + +
+ {browser && os && ( +

+ {browser} on {os} +

+ )} +
+ {meta.map((item, index) => ( + + {index > 0 && ( + + )} + {item} + + ))} +
+
+ +
+ ) + })} +
+) diff --git a/src/features/settings/task/api/useUpdateTaskSettings.ts b/src/features/settings/task/api/useUpdateTaskSettings.ts new file mode 100644 index 000000000..352faabcc --- /dev/null +++ b/src/features/settings/task/api/useUpdateTaskSettings.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { parse } from 'valibot' + +import { SettingContracts, settingQueries } from '@/entities/setting' + +import { updateTaskSettingsMutation } from '@/shared/api' + +export const useUpdateTaskSettings = () => { + const queryClient = useQueryClient() + + const allSettinsQueryKey = settingQueries.lists() + + return useMutation({ + ...updateTaskSettingsMutation(), + meta: { + errorMessage: 'We couldn’t update your settings. Please try again.' + }, + onMutate: async ({ body }) => { + await queryClient.cancelQueries({ queryKey: allSettinsQueryKey }) + + const previousSettings = queryClient.getQueryData(allSettinsQueryKey) + + const parsedPreviousSettings = parse( + SettingContracts.SettingsSchema, + previousSettings + ) + + queryClient.setQueryData(allSettinsQueryKey, oldSettings => { + if (!oldSettings) return oldSettings + + const parsedOldSettings = parse( + SettingContracts.SettingsSchema, + oldSettings + ) + + return { + ...parsedOldSettings, + task: { ...parsedOldSettings.task, ...body } + } + }) + + return { previousSettings: parsedPreviousSettings } + }, + onError: (_, __, context) => { + queryClient.setQueryData(allSettinsQueryKey, context?.previousSettings) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: allSettinsQueryKey }) + } + }) +} diff --git a/src/features/settings/task/config/card-density-options.ts b/src/features/settings/task/config/card-density-options.ts new file mode 100644 index 000000000..a0bf30ec8 --- /dev/null +++ b/src/features/settings/task/config/card-density-options.ts @@ -0,0 +1,6 @@ +import type { SettingTypes } from '@/entities/setting' + +export const CARD_DENSITY_OPTIONS: SettingTypes.Option[] = [ + { value: 'comfortable', label: 'Comfortable' }, + { value: 'compact', label: 'Compact' } +] as const diff --git a/src/features/settings/task/config/default-deadline-options.ts b/src/features/settings/task/config/default-deadline-options.ts new file mode 100644 index 000000000..0db96a59d --- /dev/null +++ b/src/features/settings/task/config/default-deadline-options.ts @@ -0,0 +1,9 @@ +import type { SettingTypes } from '@/entities/setting' + +export const DEFAULT_DEADLINE_OPTIONS: SettingTypes.Option[] = [ + { value: 'none', label: 'None' }, + { value: 'today', label: 'Today' }, + { value: 'tomorrow', label: 'Tomorrow' }, + { value: 'three_days', label: 'In 3 days' }, + { value: 'one_week', label: 'In a week' } +] as const diff --git a/src/features/settings/task/config/task-settings-data.ts b/src/features/settings/task/config/task-settings-data.ts new file mode 100644 index 000000000..532347a47 --- /dev/null +++ b/src/features/settings/task/config/task-settings-data.ts @@ -0,0 +1,43 @@ +import type { SettingTypes } from '@/entities/setting' +import type { TaskSettings } from '@/shared/api' + +import { CARD_DENSITY_OPTIONS } from './card-density-options' +import { DEFAULT_DEADLINE_OPTIONS } from './default-deadline-options' + +export const TASK_SETTINGS: SettingTypes.SettingDefinition< + TaskSettings, + 'defaultPriority' +>[] = [ + { + type: 'custom', + key: 'defaultPriority', + title: 'Default priority', + description: 'Pre-selected priority when you create a new task.' + }, + { + type: 'select', + key: 'defaultDeadline', + title: 'Default deadline', + description: 'Default deadline applied when creating new tasks.', + options: DEFAULT_DEADLINE_OPTIONS + }, + { + type: 'select', + key: 'cardDensity', + title: 'Card density', + description: 'Fit more tasks on screen by tightening spacing on cards.', + options: CARD_DENSITY_OPTIONS + }, + { + type: 'switch', + key: 'showPriorityIndicator', + title: 'Show priority indicator', + description: 'Display a colored priority stripe on the edge of task cards.' + }, + { + type: 'switch', + key: 'overdueHighlight', + title: 'Highlight overdue tasks', + description: 'Emphasise deadlines that have already passed.' + } +] as const diff --git a/src/features/settings/task/index.ts b/src/features/settings/task/index.ts new file mode 100644 index 000000000..732ab2677 --- /dev/null +++ b/src/features/settings/task/index.ts @@ -0,0 +1 @@ +export { TaskSettings } from './ui/TaskSettings' diff --git a/src/features/settings/task/ui/DefaultPrioritySelect.tsx b/src/features/settings/task/ui/DefaultPrioritySelect.tsx new file mode 100644 index 000000000..8d95839dd --- /dev/null +++ b/src/features/settings/task/ui/DefaultPrioritySelect.tsx @@ -0,0 +1,60 @@ +import { getTaskPriorityColor } from '@/entities/task' + +import { TaskPriority } from '@/shared/api' +import { capitalize, cn } from '@/shared/lib' +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, + SelectValue +} from '@/shared/ui' + +type DefaultPrioritySelectProps = { + value: TaskPriority | undefined + onChange: (v: TaskPriority | null) => void +} + +export const DefaultPrioritySelect = ({ + value, + onChange +}: DefaultPrioritySelectProps) => ( + +) diff --git a/src/features/settings/task/ui/TaskSettings.tsx b/src/features/settings/task/ui/TaskSettings.tsx new file mode 100644 index 000000000..645bdf429 --- /dev/null +++ b/src/features/settings/task/ui/TaskSettings.tsx @@ -0,0 +1,55 @@ +import { Settings, useSettings } from '@/entities/setting' + +import { Switch } from '@/shared/ui' + +import { useUpdateTaskSettings } from '../api/useUpdateTaskSettings' +import { TASK_SETTINGS } from '../config/task-settings-data' +import { DefaultPrioritySelect } from './DefaultPrioritySelect' + +export const TaskSettings = () => { + const { data: taskSettings, isPending } = useSettings(select => select.task) + + const { mutate: update } = useUpdateTaskSettings() + + return ( + + {TASK_SETTINGS.map(setting => ( + + + {setting.title} + {setting.description} + + + {setting.type === 'select' && ( + update({ body: { [setting.key]: value } })} + /> + )} + + {setting.type === 'switch' && ( + + update({ body: { [setting.key]: checked } }) + } + /> + )} + + {setting.type === 'custom' && setting.key === 'defaultPriority' && ( + + update({ body: { defaultPriority: value! } }) + } + /> + )} + + + ))} + + ) +} diff --git a/src/features/task/add/api/useAddTask.ts b/src/features/task/add/api/useAddTask.ts index 7145df487..0ee55940a 100644 --- a/src/features/task/add/api/useAddTask.ts +++ b/src/features/task/add/api/useAddTask.ts @@ -3,26 +3,23 @@ import type { AddTaskSchema } from '../model/contract' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries } from '@/entities/board' -import { createTask, getBoardByIdQueryKey } from '@/shared/api' +import { createTask } from '@/shared/api' export const useAddTask = ( columnId: string, setIsDialogOpen: Dispatch> -) => { - const boardId = useGetParamBoardId() - - return useMutation({ +) => + useMutation({ mutationFn: (data: AddTaskSchema) => createTask({ path: { columnId }, body: data }), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: - 'An error occurred while creating the task. Please try again shortly.' + 'An error occurred while creating the task. Please try again.' }, onSuccess() { setIsDialogOpen(false) } }) -} diff --git a/src/features/task/add/lib/default-deadline-map.ts b/src/features/task/add/lib/default-deadline-map.ts new file mode 100644 index 000000000..a3db4de57 --- /dev/null +++ b/src/features/task/add/lib/default-deadline-map.ts @@ -0,0 +1,24 @@ +import type { TaskSettings } from '@/shared/api' + +import { parseDate } from 'chrono-node' +import { startOfDay } from 'date-fns' + +type DefaultDeadline = TaskSettings['defaultDeadline'] + +const DEFAULT_DEADLINE_MAP: Record = { + none: null, + today: 'Today', + tomorrow: 'Tomorrow', + three_days: 'In 3 days', + one_week: 'In 1 week' +} + +export const resolveDefaultDeadline = (preset: DefaultDeadline | undefined) => { + const text = preset ? DEFAULT_DEADLINE_MAP[preset] : null + + if (!text) return undefined + + const parsed = parseDate(text) + + return parsed ? startOfDay(parsed) : undefined +} diff --git a/src/features/task/add/lib/useAddTaskForm.ts b/src/features/task/add/lib/useAddTaskForm.ts new file mode 100644 index 000000000..91295c41d --- /dev/null +++ b/src/features/task/add/lib/useAddTaskForm.ts @@ -0,0 +1,20 @@ +import { useSettings } from '@/entities/setting' + +import { useAppForm } from '@/shared/lib' + +import { AddTaskSchema } from '../model/contract' +import { resolveDefaultDeadline } from './default-deadline-map' + +export const useAddTaskForm = () => { + const { data: taskSettings } = useSettings(select => select.task) + + return useAppForm(AddTaskSchema, { + defaultValues: { + title: '', + description: '', + priority: taskSettings?.defaultPriority ?? 'without', + deadline: resolveDefaultDeadline(taskSettings?.defaultDeadline), + labels: [] + } + }) +} diff --git a/src/features/task/add/model/contract.ts b/src/features/task/add/model/contract.ts index e795ee86d..8b0c0e854 100644 --- a/src/features/task/add/model/contract.ts +++ b/src/features/task/add/model/contract.ts @@ -1,7 +1,8 @@ -import { startOfDay } from 'date-fns' import * as v from 'valibot' -import { TASK_PRIORITIES } from '@/entities/task' +import { serializeDeadline } from '@/entities/task' + +import { vTaskPriority } from '@/shared/api' export const AddTaskSchema = v.object({ title: v.pipe( @@ -20,16 +21,11 @@ export const AddTaskSchema = v.object({ v.minLength(3, 'Please enter at least 3 characters.') ) ]), - priority: v.fallback(v.picklist(TASK_PRIORITIES), 'without'), + priority: v.fallback(vTaskPriority, 'without'), labels: v.optional(v.array(v.string())), - deadline: v.optional( - v.pipe( - v.date(), - v.check( - d => startOfDay(d) >= startOfDay(new Date()), - 'Deadline must be today or in the future.' - ) - ) + deadline: v.pipe( + v.nullish(v.date()), + v.transform(d => (d ? serializeDeadline(d) : undefined)) ) }) diff --git a/src/features/task/add/ui/AddTaskForm.tsx b/src/features/task/add/ui/AddTaskForm.tsx index cfcd22f94..527cc32c2 100644 --- a/src/features/task/add/ui/AddTaskForm.tsx +++ b/src/features/task/add/ui/AddTaskForm.tsx @@ -5,7 +5,7 @@ import * as m from 'motion/react-m' import { FormLabelsCombobox } from '@/entities/label' import { FormDeadlinePicker, FormPrioritySelector } from '@/entities/task' -import { formVariants, useAppForm } from '@/shared/lib' +import { formVariants } from '@/shared/lib' import { Form, FormControl, @@ -19,7 +19,7 @@ import { } from '@/shared/ui' import { useAddTask } from '../api/useAddTask' -import { AddTaskSchema } from '../model/contract' +import { useAddTaskForm } from '../lib/useAddTaskForm' type AddTaskFormProps = { columnId: string @@ -30,15 +30,7 @@ export const AddTaskForm = ({ columnId, setIsDialogOpen }: AddTaskFormProps) => { - const form = useAppForm(AddTaskSchema, { - defaultValues: { - title: '', - description: '', - priority: 'without', - deadline: undefined, - labels: [] - } - }) + const form = useAddTaskForm() const { mutate: addTask, isPending } = useAddTask(columnId, setIsDialogOpen) @@ -108,7 +100,7 @@ export const AddTaskForm = ({ render={() => ( Deadline - + )} diff --git a/src/features/task/delete/api/useDeleteTask.ts b/src/features/task/delete/api/useDeleteTask.ts index 01196f727..01f8ea45d 100644 --- a/src/features/task/delete/api/useDeleteTask.ts +++ b/src/features/task/delete/api/useDeleteTask.ts @@ -1,41 +1,38 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { parse } from 'valibot' -import { BoardContracts, useGetParamBoardId } from '@/entities/board' +import { boardQueries, useGetParamBoardId } from '@/entities/board' -import { deleteTaskMutation, getBoardByIdQueryKey } from '@/shared/api' +import { deleteTaskMutation, vBoard } from '@/shared/api' export const useDeleteTask = () => { const queryClient = useQueryClient() const boardId = useGetParamBoardId() - const boardQueryKey = getBoardByIdQueryKey({ path: { boardId } }) + const boardQueryKey = boardQueries.detail(boardId).queryKey return useMutation({ ...deleteTaskMutation(), meta: { errorMessage: - 'An error occurred while deleting the task. Please try again shortly.' + 'An error occurred while deleting the task. Please try again.' }, onMutate: async ({ path: { taskId } }) => { await queryClient.cancelQueries({ queryKey: boardQueryKey }) const previousBoard = queryClient.getQueryData(boardQueryKey) - const parsedPreviousBoard = parse( - BoardContracts.BoardSchema, - previousBoard - ) + const parsedPreviousBoard = parse(vBoard, previousBoard) queryClient.setQueryData(boardQueryKey, oldBoard => { if (!oldBoard) return oldBoard - const parsedOldBoard = parse(BoardContracts.BoardSchema, oldBoard) + const parsedOldBoard = parse(vBoard, oldBoard) return { ...oldBoard, - columns: parsedOldBoard.columns.map(column => ({ + columns: parsedOldBoard.columns?.map(column => ({ ...column, tasks: column.tasks?.filter(task => task.id !== taskId) })) diff --git a/src/features/task/delete/ui/DeleteTaskAlertDialog.tsx b/src/features/task/delete/ui/DeleteTaskAlertDialog.tsx index fa83d597d..e523ffbd2 100644 --- a/src/features/task/delete/ui/DeleteTaskAlertDialog.tsx +++ b/src/features/task/delete/ui/DeleteTaskAlertDialog.tsx @@ -1,5 +1,6 @@ import { TrashIcon } from 'lucide-react' +import { ConfirmDeleteTrigger } from '@/entities/setting' import { Task } from '@/entities/task' import { @@ -18,26 +19,32 @@ import { useDeleteTask } from '../api/useDeleteTask' export const DeleteColumnAlertDialog = ({ taskId }: { taskId: string }) => { const { mutate: deleteTask } = useDeleteTask() + const handleDeleteTask = () => { + deleteTask({ path: { taskId } }) + } + return ( - - } - aria-label='Delete task'> - - - - Delete task? - - This action cannot be undone. This task and all of its associated data - will be permanently deleted. - - - Cancel - deleteTask({ path: { taskId } })}> - Delete - - - - + + + } + aria-label='Delete task'> + + + + Delete task? + + This action cannot be undone. This task and all of its associated + data will be permanently deleted. + + + Cancel + + Delete + + + + + ) } diff --git a/src/features/task/edit/api/useEditTask.ts b/src/features/task/edit/api/useEditTask.ts index c398bb069..05438414f 100644 --- a/src/features/task/edit/api/useEditTask.ts +++ b/src/features/task/edit/api/useEditTask.ts @@ -2,24 +2,21 @@ import type { Dispatch, SetStateAction } from 'react' import { useMutation } from '@tanstack/react-query' -import { useGetParamBoardId } from '@/entities/board' +import { boardQueries } from '@/entities/board' -import { getBoardByIdQueryKey, updateTaskMutation } from '@/shared/api' +import { updateTaskMutation } from '@/shared/api' export const useEditTask = ( setIsDialogOpen: Dispatch> -) => { - const boardId = useGetParamBoardId() - - return useMutation({ +) => + useMutation({ ...updateTaskMutation(), meta: { - invalidates: [getBoardByIdQueryKey({ path: { boardId } })], + invalidates: [boardQueries.details()], errorMessage: - 'An error occurred while editing the task. Please try again shortly.' + 'An error occurred while editing the task. Please try again.' }, onSuccess: () => { setIsDialogOpen(false) } }) -} diff --git a/src/features/task/edit/model/contract.ts b/src/features/task/edit/model/contract.ts index e8b49fa2d..40790c81a 100644 --- a/src/features/task/edit/model/contract.ts +++ b/src/features/task/edit/model/contract.ts @@ -1,6 +1,8 @@ import * as v from 'valibot' -import { TASK_PRIORITIES } from '@/entities/task' +import { serializeDeadline } from '@/entities/task' + +import { vTaskPriority } from '@/shared/api' export const EditTaskSchema = v.object({ title: v.optional( @@ -21,11 +23,11 @@ export const EditTaskSchema = v.object({ v.minLength(3, 'Please enter at least 3 characters.') ) ]), - priority: v.optional(v.picklist(TASK_PRIORITIES)), + priority: v.optional(vTaskPriority), labels: v.optional(v.array(v.string())), deadline: v.pipe( v.nullish(v.date()), - v.transform(v => v ?? null) + v.transform(d => (d ? serializeDeadline(d) : null)) ) }) diff --git a/src/features/task/edit/model/types.ts b/src/features/task/edit/model/types.ts index b0bebce36..c460e9561 100644 --- a/src/features/task/edit/model/types.ts +++ b/src/features/task/edit/model/types.ts @@ -1,9 +1,6 @@ -import type { TaskTypes } from '@/entities/task' +import type { Task } from '@/shared/api' -export type EditTaskFormValues = Omit< - TaskTypes.TaskSchema, - 'id' | 'columnId' | 'order' -> +export type EditTaskFormValues = Omit export type EditTaskData = { taskId: string diff --git a/src/features/task/edit/ui/EditTaskForm.tsx b/src/features/task/edit/ui/EditTaskForm.tsx index 65e87012c..f99e1856b 100644 --- a/src/features/task/edit/ui/EditTaskForm.tsx +++ b/src/features/task/edit/ui/EditTaskForm.tsx @@ -4,7 +4,11 @@ import type { EditTaskData } from '../model/types' import * as m from 'motion/react-m' import { FormLabelsCombobox } from '@/entities/label' -import { FormDeadlinePicker, FormPrioritySelector } from '@/entities/task' +import { + FormDeadlinePicker, + FormPrioritySelector, + parseDeadline +} from '@/entities/task' import { formVariants, useAppForm } from '@/shared/lib' import { @@ -34,7 +38,7 @@ export const EditTaskForm = ({ const form = useAppForm(EditTaskSchema, { defaultValues: { ...formValues, - deadline: formValues.deadline ?? undefined, + deadline: parseDeadline(formValues.deadline), description: formValues.description ?? '', labels: formValues.labels?.map(l => l.id) ?? [] } @@ -111,7 +115,7 @@ export const EditTaskForm = ({ render={() => ( Deadline - + )} diff --git a/src/features/task/filter/index.ts b/src/features/task/filter/index.ts index 54c0e590c..1c5c5cfe6 100644 --- a/src/features/task/filter/index.ts +++ b/src/features/task/filter/index.ts @@ -1,4 +1,6 @@ export { useFilteredTasks } from './lib/useFilteredTasks' +export { useTaskFilters } from './lib/useTaskFilters' export { DeadlineFilter } from './ui/DeadlineFilter' +export { LabelFilter } from './ui/LabelFilter' export { PriorityFilter } from './ui/PriorityFilter' export { SearchFilter } from './ui/SearchFilter' diff --git a/src/features/task/filter/lib/match-deadline.ts b/src/features/task/filter/lib/match-deadline.ts new file mode 100644 index 000000000..be73912c3 --- /dev/null +++ b/src/features/task/filter/lib/match-deadline.ts @@ -0,0 +1,30 @@ +import type { TaskDeadline } from '@/entities/task' +import type { Task } from '@/shared/api' + +import { isAfter, isBefore, isToday, isWithinInterval } from 'date-fns' + +import { parseDeadline } from '@/entities/task' + +type DeadlineRange = { today: Date; nextWeek: Date } + +export const matchesDeadline = ( + task: Task, + preset: TaskDeadline, + { today, nextWeek }: DeadlineRange +) => { + const deadline = parseDeadline(task.deadline) + + if (preset === 'No Deadline') return !deadline + + if (!deadline) return false + + if (preset === 'Today') return isToday(deadline) + + if (preset === 'Overdue') return isBefore(deadline, today) + + if (preset === 'Upcoming') { + return isWithinInterval(deadline, { start: today, end: nextWeek }) + } + + return isAfter(deadline, nextWeek) +} diff --git a/src/features/task/filter/lib/useFilteredTasks.ts b/src/features/task/filter/lib/useFilteredTasks.ts index 8eb1a42f2..f482a0866 100644 --- a/src/features/task/filter/lib/useFilteredTasks.ts +++ b/src/features/task/filter/lib/useFilteredTasks.ts @@ -1,68 +1,55 @@ -import type { TaskTypes } from '@/entities/task' +import type { Task } from '@/shared/api' import type { IFuseOptions } from 'fuse.js' import { useMemo } from 'react' -import { - addDays, - isAfter, - isBefore, - isWithinInterval, - startOfToday -} from 'date-fns' +import { addDays, startOfToday } from 'date-fns' import Fuse from 'fuse.js' +import { matchesDeadline } from './match-deadline' import { useTaskFilters } from './useTaskFilters' -const today = startOfToday() -const nextWeek = addDays(today, 7) - -const fuseOptions: IFuseOptions = { +const fuseOptions: IFuseOptions = { keys: [ - { name: 'title', weight: 0.7 }, - { name: 'description', weight: 0.3 } + { name: 'title', weight: 0.6 }, + { name: 'description', weight: 0.25 }, + { name: 'labels.name', weight: 0.15 } ], threshold: 0.35, ignoreLocation: true, minMatchCharLength: 2 } -export const useFilteredTasks = (tasks: TaskTypes.TasksSchema) => { - const { priority, deadline, search } = useTaskFilters() +export const useFilteredTasks = (tasks: Task[]) => { + const { search, priority, deadline, labels } = useTaskFilters() const fuse = useMemo(() => new Fuse(tasks, fuseOptions), [tasks]) - if (!priority && !deadline && !search) return tasks - - let filteredTasks = tasks - - if (search) { - filteredTasks = fuse.search(search).map(({ item }) => item) - } - - if (priority) { - filteredTasks = filteredTasks.filter(task => task.priority === priority) - } - - if (deadline) { - filteredTasks = filteredTasks.filter(task => { - const taskDeadline = task.deadline - - if (!taskDeadline) return false - - if (deadline === 'Overdue') return isBefore(taskDeadline, today) - - if (deadline === 'Upcoming') { - return isWithinInterval(taskDeadline, { - start: today, - end: nextWeek - }) - } - - if (deadline === 'Far Future') return isAfter(taskDeadline, nextWeek) - - return true - }) - } - - return filteredTasks + return useMemo(() => { + let filteredTasks = search + ? fuse.search(search).map(({ item }) => item) + : tasks + + if (priority.length) { + filteredTasks = filteredTasks.filter(task => + priority.includes(task.priority) + ) + } + + if (labels.length) { + filteredTasks = filteredTasks.filter(task => + task.labels?.some(label => labels.includes(label.id)) + ) + } + + if (deadline.length) { + const today = startOfToday() + const range = { today, nextWeek: addDays(today, 7) } + + filteredTasks = filteredTasks.filter(task => + deadline.some(preset => matchesDeadline(task, preset, range)) + ) + } + + return filteredTasks + }, [tasks, fuse, search, priority, deadline, labels]) } diff --git a/src/features/task/filter/lib/useTaskFilters.ts b/src/features/task/filter/lib/useTaskFilters.ts index cb55155fc..b54be6bb4 100644 --- a/src/features/task/filter/lib/useTaskFilters.ts +++ b/src/features/task/filter/lib/useTaskFilters.ts @@ -1,17 +1,63 @@ +import type { TaskTypes } from '@/entities/task' + import { useNavigate, useSearch } from '@tanstack/react-router' -type ParamKey = 'priority' | 'deadline' | 'search' +type Filters = TaskTypes.TaskSearchSchema +type ArrayKey = TaskTypes.TaskArrayFilterKey + +export const DEFAULT_FILTERS: Filters = { + search: '', + priority: [], + deadline: [], + labels: [] +} export const useTaskFilters = () => { - const { priority, deadline, search } = useSearch({ - from: '/dashboard/$boardId' - }) + const filters = useSearch({ from: '/dashboard/$boardId' }) const navigate = useNavigate({ from: '/dashboard/$boardId' }) - const handleParamsChange = (key: ParamKey, v: string) => { - navigate({ search: prev => ({ ...prev, [key]: v }) }) + const setFilters = (values: Partial) => { + navigate({ search: prev => ({ ...prev, ...values }) }) + } + + const setFilter = ( + key: TKey, + value: Filters[TKey] + ) => setFilters({ [key]: value } as Partial) + + const toggleFilter = ( + key: TKey, + value: Filters[TKey][number] + ) => { + navigate({ + search: prev => { + const current = prev[key] as string[] + + return { + ...prev, + [key]: current.includes(value) + ? current.filter(item => item !== value) + : [...current, value] + } + } + }) } - return { priority, deadline, search, handleParamsChange } + const resetFilters = () => navigate({ search: DEFAULT_FILTERS }) + + const activeFiltersCount = + filters.priority.length + + filters.deadline.length + + filters.labels.length + + (filters.search ? 1 : 0) + + return { + ...filters, + setFilter, + setFilters, + toggleFilter, + resetFilters, + activeFiltersCount + } } diff --git a/src/features/task/filter/ui/DeadlineFilter.tsx b/src/features/task/filter/ui/DeadlineFilter.tsx index b19f1a336..e22c394d5 100644 --- a/src/features/task/filter/ui/DeadlineFilter.tsx +++ b/src/features/task/filter/ui/DeadlineFilter.tsx @@ -1,32 +1,21 @@ import { TASK_DEADLINES } from '@/entities/task' -import { RadioGroup, RadioGroupItem } from '@/shared/ui' - import { useTaskFilters } from '../lib/useTaskFilters' +import { FilterOption } from './FilterOption' export const DeadlineFilter = () => { - const { deadline, handleParamsChange } = useTaskFilters() + const { deadline, toggleFilter } = useTaskFilters() return ( - handleParamsChange('deadline', v)}> - {TASK_DEADLINES.map(deadline => ( - +
+ {TASK_DEADLINES.map(value => ( + toggleFilter('deadline', value)}> + {value} + ))} - +
) } diff --git a/src/features/task/filter/ui/FilterOption.tsx b/src/features/task/filter/ui/FilterOption.tsx new file mode 100644 index 000000000..ef3ccb9fd --- /dev/null +++ b/src/features/task/filter/ui/FilterOption.tsx @@ -0,0 +1,32 @@ +import type { ComponentProps } from 'react' + +import { cn } from '@/shared/lib' +import { Checkbox } from '@/shared/ui' + +type FilterOptionProps = { + isChecked: boolean + onCheckedChange: () => void +} & Omit, 'onChange'> + +export const FilterOption = ({ + isChecked, + onCheckedChange, + className, + children, + ...props +}: FilterOptionProps) => ( + +) diff --git a/src/features/task/filter/ui/LabelFilter.tsx b/src/features/task/filter/ui/LabelFilter.tsx new file mode 100644 index 000000000..971a13cd5 --- /dev/null +++ b/src/features/task/filter/ui/LabelFilter.tsx @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' + +import { LABEL_BASE_COLOR_MAP, labelQueries } from '@/entities/label' + +import { cn } from '@/shared/lib' +import { Loader } from '@/shared/ui' + +import { useTaskFilters } from '../lib/useTaskFilters' +import { FilterOption } from './FilterOption' + +export const LabelFilter = () => { + const { labels, toggleFilter } = useTaskFilters() + + const { data: allLabels = [], isPending } = useQuery(labelQueries.list()) + + if (isPending) { + return ( +
+ + Loading labels... +
+ ) + } + + if (!allLabels.length) { + return ( +

+ You have no labels yet. +

+ ) + } + + return ( +
+ {allLabels.map(label => ( + toggleFilter('labels', label.id)}> + + {label.name} + + ))} +
+ ) +} diff --git a/src/features/task/filter/ui/PriorityFilter.tsx b/src/features/task/filter/ui/PriorityFilter.tsx index 4120faced..fa35cae74 100644 --- a/src/features/task/filter/ui/PriorityFilter.tsx +++ b/src/features/task/filter/ui/PriorityFilter.tsx @@ -1,33 +1,27 @@ -import { getTaskPriorityColor, TASK_PRIORITIES } from '@/entities/task' +import { getTaskPriorityColor } from '@/entities/task' -import { capitalize } from '@/shared/lib' -import { RadioGroup, RadioGroupItem } from '@/shared/ui' +import { TaskPriority } from '@/shared/api' +import { capitalize, cn } from '@/shared/lib' import { useTaskFilters } from '../lib/useTaskFilters' +import { FilterOption } from './FilterOption' export const PriorityFilter = () => { - const { priority, handleParamsChange } = useTaskFilters() + const { priority, toggleFilter } = useTaskFilters() return ( - handleParamsChange('priority', v)}> - {TASK_PRIORITIES.map(priority => ( - + {capitalize(value)} + ))} - +
) } diff --git a/src/features/task/filter/ui/SearchFilter.tsx b/src/features/task/filter/ui/SearchFilter.tsx index 59b4e5f24..cfca0b93e 100644 --- a/src/features/task/filter/ui/SearchFilter.tsx +++ b/src/features/task/filter/ui/SearchFilter.tsx @@ -7,22 +7,29 @@ import { Input } from '@/shared/ui' import { useTaskFilters } from '../lib/useTaskFilters' export const SearchFilter = () => { - const { search, handleParamsChange } = useTaskFilters() + const { search, setFilter } = useTaskFilters() - const [localSearch, setLocalSearch] = useState(search ?? '') + const [localSearch, setLocalSearch] = useState(search) + const [previousSearch, setPreviousSearch] = useState(search) - const debouncedParamsChange = useDebouncedCallback(value => { - handleParamsChange('search', value) + const debouncedParamsChange = useDebouncedCallback((value: string) => { + setFilter('search', value) }, 250) + if (search !== previousSearch) { + setPreviousSearch(search) + + if (!debouncedParamsChange.isPending()) setLocalSearch(search) + } + const inputRef = useRef(null) return (
{ const value = e.target.value @@ -30,13 +37,13 @@ export const SearchFilter = () => { debouncedParamsChange(value) }} /> - + +

Settings

+
+
    + {MENU_DATA.map(({ name, icon: Icon, href }) => ( +
  • + +
  • + ))} +
+
+
+ +
+
+ +
+
+ ) +} diff --git a/src/pages/signin/api/useSigninUser.ts b/src/pages/signin/api/useSigninUser.ts index 2a772a93f..9596b8e61 100644 --- a/src/pages/signin/api/useSigninUser.ts +++ b/src/pages/signin/api/useSigninUser.ts @@ -6,7 +6,7 @@ import { useNavigate } from '@tanstack/react-router' import { sessionQueries } from '@/entities/user' -import { authClient } from '@/shared/api' +import { authClient, getAuthErrorMessage } from '@/shared/api' export const useSigninUser = (reset: UseFormReset) => { const queryClient = useQueryClient() @@ -16,14 +16,18 @@ export const useSigninUser = (reset: UseFormReset) => { return useMutation({ mutationFn: (data: SigninSchema) => authClient.signIn.email(data), meta: { - errorMessage: e => - e?.status === 401 - ? 'The email or password you entered is incorrect. Please try again.' - : 'An error occurred during sign-in. Our technical team has been notified. Please try again shortly.' + errorMessage: e => { + if (e && 'error' in e) { + return ( + getAuthErrorMessage(e.error.code) ?? + 'An error occurred during sign-in. Our technical team has been notified. Please try again.' + ) + } + } }, onSuccess(session) { reset() - queryClient.setQueryData(sessionQueries.all(), session) + queryClient.setQueryData(sessionQueries.currents(), session) navigate({ to: '/dashboard' }) } }) diff --git a/src/pages/signup/api/useSignupUser.ts b/src/pages/signup/api/useSignupUser.ts index b6bf6ca16..cab06c9dc 100644 --- a/src/pages/signup/api/useSignupUser.ts +++ b/src/pages/signup/api/useSignupUser.ts @@ -6,7 +6,7 @@ import { useNavigate } from '@tanstack/react-router' import { sessionQueries } from '@/entities/user' -import { authClient } from '@/shared/api' +import { authClient, getAuthErrorMessage } from '@/shared/api' export const useSignupUser = (reset: UseFormReset) => { const queryClient = useQueryClient() @@ -14,17 +14,20 @@ export const useSignupUser = (reset: UseFormReset) => { const navigate = useNavigate() return useMutation({ - mutationFn: (data: SignupSchema) => - authClient.signUp.email({ ...data, theme: 'light' }), + mutationFn: (data: SignupSchema) => authClient.signUp.email(data), meta: { - errorMessage: e => - e?.status === 422 - ? 'An account with this email address already exists. Please sign in or use a different email.' - : 'An error occurred during sign-up. Our technical team has been notified. Please try again shortly.' + errorMessage: e => { + if (e && 'error' in e) { + return ( + getAuthErrorMessage(e.error.code) ?? + 'An error occurred during sign-up. Our technical team has been notified. Please try again.' + ) + } + } }, onSuccess(session) { reset() - queryClient.setQueryData(sessionQueries.all(), session) + queryClient.setQueryData(sessionQueries.currents(), session) navigate({ to: '/dashboard' }) } }) diff --git a/src/pages/welcome/api/usePasskeySignin.ts b/src/pages/welcome/api/usePasskeySignin.ts new file mode 100644 index 000000000..5e87488af --- /dev/null +++ b/src/pages/welcome/api/usePasskeySignin.ts @@ -0,0 +1,37 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' + +import { sessionQueries } from '@/entities/user' + +import { authClient, getAuthErrorMessage } from '@/shared/api' +import { toast } from '@/shared/lib' + +export const usePasskeySignin = () => { + const queryClient = useQueryClient() + + const navigate = useNavigate() + + return useMutation({ + mutationFn: () => authClient.signIn.passkey(), + meta: { + errorMessage: + 'An error occurred while signing in with passkey. Please try again.' + }, + onSuccess({ data: session, error }) { + if (error && 'code' in error) { + if (error.code === 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY') { + return toast.warning('Passkey sign-in cancelled.') + } + + const errorMessage = getAuthErrorMessage(error.code) + + toast.error(errorMessage, { + description: 'Try another sign-in method.' + }) + } + + queryClient.setQueryData(sessionQueries.currents(), session) + navigate({ to: '/dashboard' }) + } + }) +} diff --git a/src/pages/welcome/ui/PasskeyButton.tsx b/src/pages/welcome/ui/PasskeyButton.tsx new file mode 100644 index 000000000..4f5e3a096 --- /dev/null +++ b/src/pages/welcome/ui/PasskeyButton.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import { KeyRoundIcon } from 'lucide-react' + +import { usePasskeySignin } from '../api/usePasskeySignin' + +const supportsPasskeys = async () => { + if (!window.PublicKeyCredential) return false + + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() +} + +export const PasskeyButton = () => { + const [showPasskey, setShowPasskey] = useState(false) + + const { mutate: signinWithPasskey } = usePasskeySignin() + + useEffect(() => { + supportsPasskeys().then(setShowPasskey) + }, []) + + return ( + showPasskey && ( + + ) + ) +} diff --git a/src/pages/welcome/ui/SocialButton.tsx b/src/pages/welcome/ui/SocialButton.tsx index 5318bab7e..4324f56c0 100644 --- a/src/pages/welcome/ui/SocialButton.tsx +++ b/src/pages/welcome/ui/SocialButton.tsx @@ -1,21 +1,26 @@ +import type { SvgIconProps } from '@thesvg/react' +import type { ComponentType } from 'react' + import { capitalize } from '@/shared/lib' -import { SocialIcon } from '@/shared/ui' type SocialButtonProps = { + icon: ComponentType provider: 'google' | 'microsoft' onClick: () => void } -export const SocialButton = ({ provider, onClick }: SocialButtonProps) => ( +export const SocialButton = ({ + icon: Icon, + provider, + onClick +}: SocialButtonProps) => ( ) diff --git a/src/pages/welcome/ui/WelcomePage.tsx b/src/pages/welcome/ui/WelcomePage.tsx index 9b28e0686..34e7df647 100644 --- a/src/pages/welcome/ui/WelcomePage.tsx +++ b/src/pages/welcome/ui/WelcomePage.tsx @@ -1,17 +1,17 @@ import type { Variants } from 'motion/react' import { Link } from '@tanstack/react-router' +import { Google, Microsoft } from '@thesvg/react' import { stagger } from 'motion/react' import * as m from 'motion/react-m' import { authClient } from '@/shared/api' import { env } from '@/shared/config' -import { SocialIcon } from '@/shared/ui' +import { Logo } from '@/shared/ui' +import { PasskeyButton } from './PasskeyButton' import { SocialButton } from './SocialButton' -const MotionLink = m.create(Link) - const container: Variants = { hidden: {}, show: { transition: { delayChildren: stagger(0.12) } } @@ -56,10 +56,7 @@ export const WelcomePage = () => { - +

Task Pro

{ variants={item} className='space-y-3.5'> continueWithSocial('google')} /> continueWithSocial('microsoft')} /> - + + className='focus-visible:outline-green-light block w-84 rounded-lg + bg-black py-3.5 text-center text-white focus-visible:outline-2'> Registration - - + + className='focus-visible:outline-green-light block text-center + text-black focus-visible:outline-2'> Log In - + diff --git a/src/shared/api/auth-client.ts b/src/shared/api/auth-client.ts index 8ce068fc0..24b6ec330 100644 --- a/src/shared/api/auth-client.ts +++ b/src/shared/api/auth-client.ts @@ -1,12 +1,59 @@ +import type { BetterAuthClientPlugin } from 'better-auth' +import type { BetterFetchOption } from 'better-auth/react' + +import { passkeyClient } from '@better-auth/passkey/client' import { inferAdditionalFields } from 'better-auth/client/plugins' import { createAuthClient } from 'better-auth/react' -import { env, THEMES } from '../config' +import { env } from '../config' export const authClient = createAuthClient({ baseURL: env.VITE_API_BASE_URL + '/auth', fetchOptions: { throw: true }, plugins: [ - inferAdditionalFields({ user: { theme: { type: THEMES.map(t => t) } } }) + passkeyClient(), + revokeSessionByIdPlugin(), + inferAdditionalFields({ + session: { + browser: { type: 'string', input: false }, + os: { type: 'string', input: false }, + isCurrent: { type: 'boolean', input: false } + } + }) ] }) + +function revokeSessionByIdPlugin() { + return { + id: 'revoke-session-id', + getActions: $fetch => ({ + revokeSessionById: async ( + data: { id: string }, + fetchOptions?: BetterFetchOption + ) => + $fetch('/revoke-session-id', { + method: 'POST', + body: { id: data.id }, + ...fetchOptions + }) + }) + } satisfies BetterAuthClientPlugin +} + +type ErrorTypes = Partial> + +const errorCodes = { + PASSKEY_NOT_FOUND: 'This passkey is not registered for your account.', + USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: + 'An account with this email address already exists. Please sign in or use a different email.', + INVALID_EMAIL_OR_PASSWORD: + 'The email or password you entered is incorrect. Please try again.', + FAILED_TO_UNLINK_LAST_ACCOUNT: + 'You cannot unlink your last account. Please add another account first.' +} satisfies ErrorTypes + +export const getAuthErrorMessage = (code: string) => { + if (code in errorCodes) return errorCodes[code as keyof typeof errorCodes] + + return undefined +} diff --git a/src/shared/api/generated/@tanstack/react-query.gen.ts b/src/shared/api/generated/@tanstack/react-query.gen.ts index 459f33b6b..0165d0658 100644 --- a/src/shared/api/generated/@tanstack/react-query.gen.ts +++ b/src/shared/api/generated/@tanstack/react-query.gen.ts @@ -4,8 +4,8 @@ import { queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import type { AxiosError } from 'axios'; import { client } from '../client.gen'; -import { createBoard, createColumn, createLabel, createTask, deleteBoard, deleteColumn, deleteLabel, deleteTask, getAllBoards, getAllLabels, getBoardById, help, type Options, updateBoard, updateColumn, updateColumnsOrder, updateLabel, updateTask, updateTasksOrder } from '../sdk.gen'; -import type { CreateBoardData, CreateBoardError, CreateBoardResponse, CreateColumnData, CreateColumnError, CreateColumnResponse, CreateLabelData, CreateLabelError, CreateLabelResponse, CreateTaskData, CreateTaskError, CreateTaskResponse, DeleteBoardData, DeleteBoardError, DeleteBoardResponse, DeleteColumnData, DeleteColumnError, DeleteColumnResponse, DeleteLabelData, DeleteLabelError, DeleteLabelResponse, DeleteTaskData, DeleteTaskError, DeleteTaskResponse, GetAllBoardsData, GetAllBoardsError, GetAllBoardsResponse, GetAllLabelsData, GetAllLabelsError, GetAllLabelsResponse, GetBoardByIdData, GetBoardByIdError, GetBoardByIdResponse, HelpData, HelpError, HelpResponse, UpdateBoardData, UpdateBoardError, UpdateBoardResponse, UpdateColumnData, UpdateColumnError, UpdateColumnResponse, UpdateColumnsOrderData, UpdateColumnsOrderError, UpdateColumnsOrderResponse, UpdateLabelData, UpdateLabelError, UpdateLabelResponse, UpdateTaskData, UpdateTaskError, UpdateTaskResponse, UpdateTasksOrderData, UpdateTasksOrderError, UpdateTasksOrderResponse } from '../types.gen'; +import { createBoard, createColumn, createLabel, createTask, deleteBoard, deleteColumn, deleteLabel, deleteTask, getAllBoards, getAllLabels, getAllSettings, getBoardById, help, type Options, updateBoard, updateColumn, updateColumnsOrder, updateGeneralSettings, updateLabel, updateLabelSettings, updateTask, updateTaskSettings, updateTasksOrder } from '../sdk.gen'; +import type { CreateBoardData, CreateBoardError, CreateBoardResponse, CreateColumnData, CreateColumnError, CreateColumnResponse, CreateLabelData, CreateLabelError, CreateLabelResponse, CreateTaskData, CreateTaskError, CreateTaskResponse, DeleteBoardData, DeleteBoardError, DeleteBoardResponse, DeleteColumnData, DeleteColumnError, DeleteColumnResponse, DeleteLabelData, DeleteLabelError, DeleteLabelResponse, DeleteTaskData, DeleteTaskError, DeleteTaskResponse, GetAllBoardsData, GetAllBoardsError, GetAllBoardsResponse, GetAllLabelsData, GetAllLabelsError, GetAllLabelsResponse, GetAllSettingsData, GetAllSettingsError, GetAllSettingsResponse, GetBoardByIdData, GetBoardByIdError, GetBoardByIdResponse, HelpData, HelpError, HelpResponse, UpdateBoardData, UpdateBoardError, UpdateBoardResponse, UpdateColumnData, UpdateColumnError, UpdateColumnResponse, UpdateColumnsOrderData, UpdateColumnsOrderError, UpdateColumnsOrderResponse, UpdateGeneralSettingsData, UpdateGeneralSettingsError, UpdateGeneralSettingsResponse, UpdateLabelData, UpdateLabelError, UpdateLabelResponse, UpdateLabelSettingsData, UpdateLabelSettingsError, UpdateLabelSettingsResponse, UpdateTaskData, UpdateTaskError, UpdateTaskResponse, UpdateTaskSettingsData, UpdateTaskSettingsError, UpdateTaskSettingsResponse, UpdateTasksOrderData, UpdateTasksOrderError, UpdateTasksOrderResponse } from '../types.gen'; /** * Send email need help @@ -57,6 +57,75 @@ const createQueryKey = (id: string, options?: TOptions return [params]; }; +export const getAllSettingsQueryKey = (options?: Options) => createQueryKey('getAllSettings', options); + +/** + * Get all user settings + */ +export const getAllSettingsOptions = (options?: Options) => queryOptions, GetAllSettingsResponse, ReturnType>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getAllSettings({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getAllSettingsQueryKey(options) +}); + +/** + * Update general settings + */ +export const updateGeneralSettingsMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (fnOptions) => { + const { data } = await updateGeneralSettings({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Update task settings + */ +export const updateTaskSettingsMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (fnOptions) => { + const { data } = await updateTaskSettings({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +/** + * Update label settings + */ +export const updateLabelSettingsMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { + mutationFn: async (fnOptions) => { + const { data } = await updateLabelSettings({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const getAllBoardsQueryKey = (options?: Options) => createQueryKey('getAllBoards', options); /** @@ -162,12 +231,12 @@ export const createColumnMutation = (options?: Partial }; /** - * Update columns order + * Delete column by id */ -export const updateColumnsOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const deleteColumnMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await updateColumnsOrder({ + const { data } = await deleteColumn({ ...options, ...fnOptions, throwOnError: true @@ -179,12 +248,12 @@ export const updateColumnsOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const updateColumnMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await deleteColumn({ + const { data } = await updateColumn({ ...options, ...fnOptions, throwOnError: true @@ -196,12 +265,12 @@ export const deleteColumnMutation = (options?: Partial }; /** - * Update column by id + * Update columns order */ -export const updateColumnMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const updateColumnsOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await updateColumn({ + const { data } = await updateColumnsOrder({ ...options, ...fnOptions, throwOnError: true @@ -230,12 +299,12 @@ export const createTaskMutation = (options?: Partial>): }; /** - * Update tasks order + * Delete task by id */ -export const updateTasksOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const deleteTaskMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await updateTasksOrder({ + const { data } = await deleteTask({ ...options, ...fnOptions, throwOnError: true @@ -247,12 +316,12 @@ export const updateTasksOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const updateTaskMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await deleteTask({ + const { data } = await updateTask({ ...options, ...fnOptions, throwOnError: true @@ -264,12 +333,12 @@ export const deleteTaskMutation = (options?: Partial>): }; /** - * Update task by id + * Update tasks order */ -export const updateTaskMutation = (options?: Partial>): UseMutationOptions, Options> => { - const mutationOptions: UseMutationOptions, Options> = { +export const updateTasksOrderMutation = (options?: Partial>): UseMutationOptions, Options> => { + const mutationOptions: UseMutationOptions, Options> = { mutationFn: async (fnOptions) => { - const { data } = await updateTask({ + const { data } = await updateTasksOrder({ ...options, ...fnOptions, throwOnError: true diff --git a/src/shared/api/generated/client.gen.ts b/src/shared/api/generated/client.gen.ts index fbdb4b3d4..3ae7e5ec2 100644 --- a/src/shared/api/generated/client.gen.ts +++ b/src/shared/api/generated/client.gen.ts @@ -15,4 +15,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig = (override?: Config) => Config & T>; -export const client: Client = createClient(createClientConfig(createConfig({ baseURL: 'http://localhost:9537/api' }))); +export const client: Client = createClient(createClientConfig(createConfig({ baseURL: 'http://localhost:9537/api', throwOnError: true }))); diff --git a/src/shared/api/generated/index.ts b/src/shared/api/generated/index.ts index 6a8d2bf7b..71a11f681 100644 --- a/src/shared/api/generated/index.ts +++ b/src/shared/api/generated/index.ts @@ -11,17 +11,22 @@ export { deleteTask, getAllBoards, getAllLabels, + getAllSettings, getBoardById, help, type Options, updateBoard, updateColumn, updateColumnsOrder, + updateGeneralSettings, updateLabel, + updateLabelSettings, updateTask, + updateTaskSettings, updateTasksOrder } from './sdk.gen' export { + AccentColor, type Board, type BoardBackground, BoardBackgroundId, @@ -69,6 +74,7 @@ export { type DeleteTaskResponse, type DeleteTaskResponses, type ErrorResponse, + type GeneralSettings, type GetAllBoardsData, type GetAllBoardsError, type GetAllBoardsErrors, @@ -79,6 +85,11 @@ export { type GetAllLabelsErrors, type GetAllLabelsResponse, type GetAllLabelsResponses, + type GetAllSettingsData, + type GetAllSettingsError, + type GetAllSettingsErrors, + type GetAllSettingsResponse, + type GetAllSettingsResponses, type GetBoardByIdData, type GetBoardByIdError, type GetBoardByIdErrors, @@ -90,9 +101,10 @@ export { type HelpResponse, type HelpResponses, type Label, - LabelColor, + type LabelSettings, type Task, TaskPriority, + type TaskSettings, type UpdateBoardData, type UpdateBoardError, type UpdateBoardErrors, @@ -108,19 +120,35 @@ export { type UpdateColumnsOrderErrors, type UpdateColumnsOrderResponse, type UpdateColumnsOrderResponses, + type UpdateGeneralSettingsData, + type UpdateGeneralSettingsError, + type UpdateGeneralSettingsErrors, + type UpdateGeneralSettingsResponse, + type UpdateGeneralSettingsResponses, type UpdateLabelData, type UpdateLabelError, type UpdateLabelErrors, type UpdateLabelResponse, type UpdateLabelResponses, + type UpdateLabelSettingsData, + type UpdateLabelSettingsError, + type UpdateLabelSettingsErrors, + type UpdateLabelSettingsResponse, + type UpdateLabelSettingsResponses, type UpdateTaskData, type UpdateTaskError, type UpdateTaskErrors, type UpdateTaskResponse, type UpdateTaskResponses, + type UpdateTaskSettingsData, + type UpdateTaskSettingsError, + type UpdateTaskSettingsErrors, + type UpdateTaskSettingsResponse, + type UpdateTaskSettingsResponses, type UpdateTasksOrderData, type UpdateTasksOrderError, type UpdateTasksOrderErrors, type UpdateTasksOrderResponse, - type UpdateTasksOrderResponses + type UpdateTasksOrderResponses, + type UserSettings } from './types.gen' diff --git a/src/shared/api/generated/sdk.gen.ts b/src/shared/api/generated/sdk.gen.ts index 8335a3183..eec2e94c9 100644 --- a/src/shared/api/generated/sdk.gen.ts +++ b/src/shared/api/generated/sdk.gen.ts @@ -1,9 +1,11 @@ // This file is auto-generated by @hey-api/openapi-ts +import * as v from 'valibot'; + import type { Client, ClientMeta, Options as Options2, RequestResult, TDataShape } from './client'; import { client } from './client.gen'; -import { createBoardResponseTransformer, createColumnResponseTransformer, createLabelResponseTransformer, createTaskResponseTransformer, getAllBoardsResponseTransformer, getAllLabelsResponseTransformer, getBoardByIdResponseTransformer, updateBoardResponseTransformer, updateColumnResponseTransformer, updateColumnsOrderResponseTransformer, updateLabelResponseTransformer, updateTaskResponseTransformer, updateTasksOrderResponseTransformer } from './transformers.gen'; -import type { CreateBoardData, CreateBoardErrors, CreateBoardResponses, CreateColumnData, CreateColumnErrors, CreateColumnResponses, CreateLabelData, CreateLabelErrors, CreateLabelResponses, CreateTaskData, CreateTaskErrors, CreateTaskResponses, DeleteBoardData, DeleteBoardErrors, DeleteBoardResponses, DeleteColumnData, DeleteColumnErrors, DeleteColumnResponses, DeleteLabelData, DeleteLabelErrors, DeleteLabelResponses, DeleteTaskData, DeleteTaskErrors, DeleteTaskResponses, GetAllBoardsData, GetAllBoardsErrors, GetAllBoardsResponses, GetAllLabelsData, GetAllLabelsErrors, GetAllLabelsResponses, GetBoardByIdData, GetBoardByIdErrors, GetBoardByIdResponses, HelpData, HelpErrors, HelpResponses, UpdateBoardData, UpdateBoardErrors, UpdateBoardResponses, UpdateColumnData, UpdateColumnErrors, UpdateColumnResponses, UpdateColumnsOrderData, UpdateColumnsOrderErrors, UpdateColumnsOrderResponses, UpdateLabelData, UpdateLabelErrors, UpdateLabelResponses, UpdateTaskData, UpdateTaskErrors, UpdateTaskResponses, UpdateTasksOrderData, UpdateTasksOrderErrors, UpdateTasksOrderResponses } from './types.gen'; +import type { CreateBoardData, CreateBoardErrors, CreateBoardResponses, CreateColumnData, CreateColumnErrors, CreateColumnResponses, CreateLabelData, CreateLabelErrors, CreateLabelResponses, CreateTaskData, CreateTaskErrors, CreateTaskResponses, DeleteBoardData, DeleteBoardErrors, DeleteBoardResponses, DeleteColumnData, DeleteColumnErrors, DeleteColumnResponses, DeleteLabelData, DeleteLabelErrors, DeleteLabelResponses, DeleteTaskData, DeleteTaskErrors, DeleteTaskResponses, GetAllBoardsData, GetAllBoardsErrors, GetAllBoardsResponses, GetAllLabelsData, GetAllLabelsErrors, GetAllLabelsResponses, GetAllSettingsData, GetAllSettingsErrors, GetAllSettingsResponses, GetBoardByIdData, GetBoardByIdErrors, GetBoardByIdResponses, HelpData, HelpErrors, HelpResponses, UpdateBoardData, UpdateBoardErrors, UpdateBoardResponses, UpdateColumnData, UpdateColumnErrors, UpdateColumnResponses, UpdateColumnsOrderData, UpdateColumnsOrderErrors, UpdateColumnsOrderResponses, UpdateGeneralSettingsData, UpdateGeneralSettingsErrors, UpdateGeneralSettingsResponses, UpdateLabelData, UpdateLabelErrors, UpdateLabelResponses, UpdateLabelSettingsData, UpdateLabelSettingsErrors, UpdateLabelSettingsResponses, UpdateTaskData, UpdateTaskErrors, UpdateTaskResponses, UpdateTaskSettingsData, UpdateTaskSettingsErrors, UpdateTaskSettingsResponses, UpdateTasksOrderData, UpdateTasksOrderErrors, UpdateTasksOrderResponses } from './types.gen'; +import { vCreateBoardBody, vCreateBoardResponse, vCreateColumnBody, vCreateColumnPath, vCreateColumnResponse, vCreateLabelBody, vCreateLabelResponse, vCreateTaskBody, vCreateTaskPath, vCreateTaskResponse, vDeleteBoardPath, vDeleteBoardResponse, vDeleteColumnPath, vDeleteColumnResponse, vDeleteLabelPath, vDeleteLabelResponse, vDeleteTaskPath, vDeleteTaskResponse, vGetAllBoardsResponse, vGetAllLabelsResponse, vGetAllSettingsResponse, vGetBoardByIdPath, vGetBoardByIdResponse, vHelpBody, vHelpResponse, vUpdateBoardBody, vUpdateBoardPath, vUpdateBoardResponse, vUpdateColumnBody, vUpdateColumnPath, vUpdateColumnResponse, vUpdateColumnsOrderBody, vUpdateColumnsOrderPath, vUpdateColumnsOrderResponse, vUpdateGeneralSettingsBody, vUpdateGeneralSettingsResponse, vUpdateLabelBody, vUpdateLabelPath, vUpdateLabelResponse, vUpdateLabelSettingsBody, vUpdateLabelSettingsResponse, vUpdateTaskBody, vUpdateTaskPath, vUpdateTaskResponse, vUpdateTaskSettingsBody, vUpdateTaskSettingsResponse, vUpdateTasksOrderBody, vUpdateTasksOrderPath, vUpdateTasksOrderResponse } from './valibot.gen'; export type Options = Options2 & { /** @@ -22,8 +24,14 @@ export type Options(options: Options): RequestResult => (options.client ?? client).post({ +export const help = (options: Options): RequestResult => (options.client ?? client).post({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vHelpBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vHelpResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/user/help', ...options, @@ -33,12 +41,93 @@ export const help = (options: Options(options?: Options): RequestResult => (options?.client ?? client).get({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), + responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vGetAllSettingsResponse, data), + security: [{ scheme: 'bearer', type: 'http' }], + url: '/user/settings', + ...options +}); + +/** + * Update general settings + */ +export const updateGeneralSettings = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateGeneralSettingsBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), + responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateGeneralSettingsResponse, data), + security: [{ scheme: 'bearer', type: 'http' }], + url: '/user/settings/general', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Update task settings + */ +export const updateTaskSettings = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateTaskSettingsBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), + responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateTaskSettingsResponse, data), + security: [{ scheme: 'bearer', type: 'http' }], + url: '/user/settings/task', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Update label settings + */ +export const updateLabelSettings = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateLabelSettingsBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), + responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateLabelSettingsResponse, data), + security: [{ scheme: 'bearer', type: 'http' }], + url: '/user/settings/label', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Get all boards */ -export const getAllBoards = (options?: Options): RequestResult => (options?.client ?? client).get({ - responseTransformer: getAllBoardsResponseTransformer, +export const getAllBoards = (options?: Options): RequestResult => (options?.client ?? client).get({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vGetAllBoardsResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/board', ...options @@ -47,9 +136,14 @@ export const getAllBoards = (options?: Opt /** * Create new board */ -export const createBoard = (options: Options): RequestResult => (options.client ?? client).post({ - responseTransformer: createBoardResponseTransformer, +export const createBoard = (options: Options): RequestResult => (options.client ?? client).post({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vCreateBoardBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vCreateBoardResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/board', ...options, @@ -62,7 +156,13 @@ export const createBoard = (options: Optio /** * Delete board by id */ -export const deleteBoard = (options: Options): RequestResult => (options.client ?? client).delete({ +export const deleteBoard = (options: Options): RequestResult => (options.client ?? client).delete({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: vDeleteBoardPath, + query: v.optional(v.never()) + }), data), + responseValidator: async (data) => await v.parseAsync(vDeleteBoardResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/board/{boardId}', ...options @@ -71,9 +171,14 @@ export const deleteBoard = (options: Optio /** * Get board by id */ -export const getBoardById = (options: Options): RequestResult => (options.client ?? client).get({ - responseTransformer: getBoardByIdResponseTransformer, +export const getBoardById = (options: Options): RequestResult => (options.client ?? client).get({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: vGetBoardByIdPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vGetBoardByIdResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/board/{boardId}', ...options @@ -82,9 +187,14 @@ export const getBoardById = (options: Opti /** * Update board by id */ -export const updateBoard = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateBoardResponseTransformer, +export const updateBoard = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateBoardBody, + path: vUpdateBoardPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateBoardResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/board/{boardId}', ...options, @@ -97,9 +207,14 @@ export const updateBoard = (options: Optio /** * Create new column */ -export const createColumn = (options: Options): RequestResult => (options.client ?? client).post({ - responseTransformer: createColumnResponseTransformer, +export const createColumn = (options: Options): RequestResult => (options.client ?? client).post({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vCreateColumnBody, + path: vCreateColumnPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vCreateColumnResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/column/{boardId}', ...options, @@ -109,25 +224,16 @@ export const createColumn = (options: Opti } }); -/** - * Update columns order - */ -export const updateColumnsOrder = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateColumnsOrderResponseTransformer, - responseType: 'json', - security: [{ scheme: 'bearer', type: 'http' }], - url: '/column/{boardId}/order', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - /** * Delete column by id */ -export const deleteColumn = (options: Options): RequestResult => (options.client ?? client).delete({ +export const deleteColumn = (options: Options): RequestResult => (options.client ?? client).delete({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: vDeleteColumnPath, + query: v.optional(v.never()) + }), data), + responseValidator: async (data) => await v.parseAsync(vDeleteColumnResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/column/{columnId}', ...options @@ -136,9 +242,14 @@ export const deleteColumn = (options: Opti /** * Update column by id */ -export const updateColumn = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateColumnResponseTransformer, +export const updateColumn = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateColumnBody, + path: vUpdateColumnPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateColumnResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/column/{columnId}', ...options, @@ -149,13 +260,18 @@ export const updateColumn = (options: Opti }); /** - * Create new task + * Update columns order */ -export const createTask = (options: Options): RequestResult => (options.client ?? client).post({ - responseTransformer: createTaskResponseTransformer, +export const updateColumnsOrder = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateColumnsOrderBody, + path: vUpdateColumnsOrderPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateColumnsOrderResponse, data), security: [{ scheme: 'bearer', type: 'http' }], - url: '/task/{columnId}', + url: '/column/{boardId}/order', ...options, headers: { 'Content-Type': 'application/json', @@ -164,13 +280,18 @@ export const createTask = (options: Option }); /** - * Update tasks order + * Create new task */ -export const updateTasksOrder = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateTasksOrderResponseTransformer, +export const createTask = (options: Options): RequestResult => (options.client ?? client).post({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vCreateTaskBody, + path: vCreateTaskPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vCreateTaskResponse, data), security: [{ scheme: 'bearer', type: 'http' }], - url: '/task/{columnId}/order', + url: '/task/{columnId}', ...options, headers: { 'Content-Type': 'application/json', @@ -181,7 +302,13 @@ export const updateTasksOrder = (options: /** * Delete task by id */ -export const deleteTask = (options: Options): RequestResult => (options.client ?? client).delete({ +export const deleteTask = (options: Options): RequestResult => (options.client ?? client).delete({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: vDeleteTaskPath, + query: v.optional(v.never()) + }), data), + responseValidator: async (data) => await v.parseAsync(vDeleteTaskResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/task/{taskId}', ...options @@ -190,9 +317,14 @@ export const deleteTask = (options: Option /** * Update task by id */ -export const updateTask = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateTaskResponseTransformer, +export const updateTask = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateTaskBody, + path: vUpdateTaskPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateTaskResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/task/{taskId}', ...options, @@ -202,12 +334,37 @@ export const updateTask = (options: Option } }); +/** + * Update tasks order + */ +export const updateTasksOrder = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateTasksOrderBody, + path: vUpdateTasksOrderPath, + query: v.optional(v.never()) + }), data), + responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateTasksOrderResponse, data), + security: [{ scheme: 'bearer', type: 'http' }], + url: '/task/{columnId}/order', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Get all labels */ -export const getAllLabels = (options?: Options): RequestResult => (options?.client ?? client).get({ - responseTransformer: getAllLabelsResponseTransformer, +export const getAllLabels = (options?: Options): RequestResult => (options?.client ?? client).get({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vGetAllLabelsResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/label', ...options @@ -216,9 +373,14 @@ export const getAllLabels = (options?: Opt /** * Create new label */ -export const createLabel = (options: Options): RequestResult => (options.client ?? client).post({ - responseTransformer: createLabelResponseTransformer, +export const createLabel = (options: Options): RequestResult => (options.client ?? client).post({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vCreateLabelBody, + path: v.optional(v.never()), + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vCreateLabelResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/label', ...options, @@ -231,7 +393,13 @@ export const createLabel = (options: Optio /** * Delete label by id */ -export const deleteLabel = (options: Options): RequestResult => (options.client ?? client).delete({ +export const deleteLabel = (options: Options): RequestResult => (options.client ?? client).delete({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: v.optional(v.never()), + path: vDeleteLabelPath, + query: v.optional(v.never()) + }), data), + responseValidator: async (data) => await v.parseAsync(vDeleteLabelResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/label/{labelId}', ...options @@ -240,9 +408,14 @@ export const deleteLabel = (options: Optio /** * Update label by id */ -export const updateLabel = (options: Options): RequestResult => (options.client ?? client).patch({ - responseTransformer: updateLabelResponseTransformer, +export const updateLabel = (options: Options): RequestResult => (options.client ?? client).patch({ + requestValidator: async (data) => await v.parseAsync(v.object({ + body: vUpdateLabelBody, + path: vUpdateLabelPath, + query: v.optional(v.never()) + }), data), responseType: 'json', + responseValidator: async (data) => await v.parseAsync(vUpdateLabelResponse, data), security: [{ scheme: 'bearer', type: 'http' }], url: '/label/{labelId}', ...options, diff --git a/src/shared/api/generated/transformers.gen.ts b/src/shared/api/generated/transformers.gen.ts deleted file mode 100644 index fe67177b4..000000000 --- a/src/shared/api/generated/transformers.gen.ts +++ /dev/null @@ -1,110 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { CreateBoardResponse, CreateColumnResponse, CreateLabelResponse, CreateTaskResponse, GetAllBoardsResponse, GetAllLabelsResponse, GetBoardByIdResponse, UpdateBoardResponse, UpdateColumnResponse, UpdateColumnsOrderResponse, UpdateLabelResponse, UpdateTaskResponse, UpdateTasksOrderResponse } from './types.gen'; - -const labelSchemaResponseTransformer = (data: any) => { - data.createdAt = new Date(data.createdAt); - data.updatedAt = new Date(data.updatedAt); - return data; -}; - -const taskSchemaResponseTransformer = (data: any) => { - if (data.deadline) { - data.deadline = new Date(data.deadline); - } - if (data.completedAt) { - data.completedAt = new Date(data.completedAt); - } - if (data.labels) { - data.labels = data.labels.map((item: any) => labelSchemaResponseTransformer(item)); - } - data.createdAt = new Date(data.createdAt); - data.updatedAt = new Date(data.updatedAt); - return data; -}; - -const columnSchemaResponseTransformer = (data: any) => { - if (data.tasks) { - data.tasks = data.tasks.map((item: any) => taskSchemaResponseTransformer(item)); - } - data.createdAt = new Date(data.createdAt); - data.updatedAt = new Date(data.updatedAt); - return data; -}; - -const boardSchemaResponseTransformer = (data: any) => { - if (data.columns) { - data.columns = data.columns.map((item: any) => columnSchemaResponseTransformer(item)); - } - data.createdAt = new Date(data.createdAt); - data.updatedAt = new Date(data.updatedAt); - return data; -}; - -export const getAllBoardsResponseTransformer = async (data: any): Promise => { - data = data.map((item: any) => boardSchemaResponseTransformer(item)); - return data; -}; - -export const createBoardResponseTransformer = async (data: any): Promise => { - data = boardSchemaResponseTransformer(data); - return data; -}; - -export const getBoardByIdResponseTransformer = async (data: any): Promise => { - data = boardSchemaResponseTransformer(data); - if (data.columns) { - data.columns = data.columns.map((item: any) => columnSchemaResponseTransformer(item)); - } - return data; -}; - -export const updateBoardResponseTransformer = async (data: any): Promise => { - data = boardSchemaResponseTransformer(data); - return data; -}; - -export const createColumnResponseTransformer = async (data: any): Promise => { - data = columnSchemaResponseTransformer(data); - return data; -}; - -export const updateColumnsOrderResponseTransformer = async (data: any): Promise => { - data = data.map((item: any) => columnSchemaResponseTransformer(item)); - return data; -}; - -export const updateColumnResponseTransformer = async (data: any): Promise => { - data = columnSchemaResponseTransformer(data); - return data; -}; - -export const createTaskResponseTransformer = async (data: any): Promise => { - data = taskSchemaResponseTransformer(data); - return data; -}; - -export const updateTasksOrderResponseTransformer = async (data: any): Promise => { - data = data.map((item: any) => taskSchemaResponseTransformer(item)); - return data; -}; - -export const updateTaskResponseTransformer = async (data: any): Promise => { - data = taskSchemaResponseTransformer(data); - return data; -}; - -export const getAllLabelsResponseTransformer = async (data: any): Promise => { - data = data.map((item: any) => labelSchemaResponseTransformer(item)); - return data; -}; - -export const createLabelResponseTransformer = async (data: any): Promise => { - data = labelSchemaResponseTransformer(data); - return data; -}; - -export const updateLabelResponseTransformer = async (data: any): Promise => { - data = labelSchemaResponseTransformer(data); - return data; -}; diff --git a/src/shared/api/generated/types.gen.ts b/src/shared/api/generated/types.gen.ts index b72670411..f2d814ba5 100644 --- a/src/shared/api/generated/types.gen.ts +++ b/src/shared/api/generated/types.gen.ts @@ -4,24 +4,85 @@ export type ClientOptions = { baseURL: 'http://localhost:9537/api' | 'https://api.taskpro.qzz.io' | (string & {}); }; -export type Board = { +export type ErrorResponse = { + status: number; + message: string | { + [key: string]: Array; + }; +}; + +export type UserSettings = { id: string; - title: string; - icon: BoardIcon; - background: BoardBackground; + general: GeneralSettings; + task: TaskSettings; + label: LabelSettings; userId: string; - columns?: Array; - createdAt: Date; - updatedAt: Date; }; +export type GeneralSettings = { + theme: 'light' | 'dark' | 'system'; + accentColor: AccentColor; + firstDayOfWeek: 'sunday' | 'monday'; + fontSize: 'small' | 'medium' | 'large'; + dateFormat: 'dd_mm_yyyy' | 'mm_dd_yyyy' | 'yyyy_mm_dd'; + boardBackgroundBlur: 'off' | 'low' | 'medium'; + /** + * `system` defers to the client `prefers-reduced-motion` media query + */ + enableAnimations: 'system' | 'on' | 'off'; + confirmBeforeDelete: boolean; +}; + +export const AccentColor = { + BLUE: 'blue', + PURPLE: 'purple', + RED: 'red', + GRAY: 'gray', + CYAN: 'cyan', + YELLOW: 'yellow', + INDIGO: 'indigo', + GREEN: 'green' +} as const; + +export type AccentColor = typeof AccentColor[keyof typeof AccentColor]; + +export type TaskSettings = { + defaultPriority: 'without' | 'low' | 'medium' | 'high'; + defaultDeadline: 'none' | 'today' | 'tomorrow' | 'three_days' | 'one_week'; + cardDensity: 'compact' | 'comfortable'; + showPriorityIndicator: boolean; + overdueHighlight: boolean; +}; + +export type LabelSettings = { + labelDisplay: 'hidden' | 'full' | 'compact'; + /** + * Labels rendered on a task card before collapsing. 0 = all + */ + maxLabelsShown: number; +}; + +export const BoardIcon = { + SPARKLES: 'sparkles', + LAYOUT: 'layout', + KANBAN: 'kanban', + LAYERS: 'layers', + COMPASS: 'compass', + TARGET: 'target', + SUNRISE: 'sunrise', + ORBIT: 'orbit', + PALETTE: 'palette', + LEAF: 'leaf' +} as const; + +export type BoardIcon = typeof BoardIcon[keyof typeof BoardIcon]; + export type BoardBackground = { identifier: BoardBackgroundId; url: string | null; }; export const BoardBackgroundId = { - DEFAULT: 'default', FLOWERS: 'flowers', MOUNTAINS_NIGHT: 'mountains-night', SAKURA: 'sakura', @@ -36,25 +97,22 @@ export const BoardBackgroundId = { GORGE: 'gorge', SEA_BOAT_2: 'sea-boat-2', AIR_BALLOONS_2: 'air-balloons-2', - NORTHERN_LIGHTS: 'northern-lights' + NORTHERN_LIGHTS: 'northern-lights', + DEFAULT: 'default' } as const; export type BoardBackgroundId = typeof BoardBackgroundId[keyof typeof BoardBackgroundId]; -export const BoardIcon = { - SPARKLES: 'sparkles', - LAYOUT: 'layout', - KANBAN: 'kanban', - LAYERS: 'layers', - COMPASS: 'compass', - TARGET: 'target', - SUNRISE: 'sunrise', - ORBIT: 'orbit', - PALETTE: 'palette', - LEAF: 'leaf' -} as const; - -export type BoardIcon = typeof BoardIcon[keyof typeof BoardIcon]; +export type Board = { + id: string; + title: string; + icon: BoardIcon; + background: BoardBackground; + userId: string; + columns?: Array; + createdAt: string; + updatedAt: string; +}; export type Column = { id: string; @@ -62,8 +120,8 @@ export type Column = { order: number; boardId: string; tasks?: Array; - createdAt: Date; - updatedAt: Date; + createdAt: string; + updatedAt: string; }; export type Task = { @@ -71,14 +129,14 @@ export type Task = { title: string; description: string | null; priority: TaskPriority; - deadline: Date | null; + deadline: string | null; order: number; completed: boolean; - completedAt: Date | null; + completedAt: string | null; columnId: string; - labels?: Array