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
-
+
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
)
}
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 => (
- {label.name}
+ className={cn(
+ data?.labelDisplay === 'compact'
+ ? LABEL_BASE_COLOR_MAP[label.color]
+ : LABEL_COLOR_MAP[label.color]
+ )}>
+ {data?.labelDisplay === 'full' && label.name}
))}
- {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 (
-
-
- )
-}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+
+ )
+}
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.
+
+
+
+
+ )
+}
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 (
+
+ )
+}
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 => (
-
-
- {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) => (
+
+
+ {children}
+
+)
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 => (
-
-
+ {Object.values(TaskPriority).map(value => (
+ toggleFilter('priority', value)}>
+
- {capitalize(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)
}}
/>
-
+
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