diff --git a/app/(protected)/profile/teams/page.tsx b/app/(protected)/profile/teams/page.tsx deleted file mode 100644 index e626153..0000000 --- a/app/(protected)/profile/teams/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { TeamsPage as default } from 'pages/profile'; diff --git a/app/(protected)/profile/layout.tsx b/app/(protected)/user/(user)/layout.tsx similarity index 100% rename from app/(protected)/profile/layout.tsx rename to app/(protected)/user/(user)/layout.tsx diff --git a/app/(protected)/profile/notifications/page.tsx b/app/(protected)/user/(user)/notifications/page.tsx similarity index 100% rename from app/(protected)/profile/notifications/page.tsx rename to app/(protected)/user/(user)/notifications/page.tsx diff --git a/app/(protected)/profile/me/page.tsx b/app/(protected)/user/(user)/profile/page.tsx similarity index 100% rename from app/(protected)/profile/me/page.tsx rename to app/(protected)/user/(user)/profile/page.tsx diff --git a/app/(protected)/profile/security/page.tsx b/app/(protected)/user/(user)/security/page.tsx similarity index 100% rename from app/(protected)/profile/security/page.tsx rename to app/(protected)/user/(user)/security/page.tsx diff --git a/app/(protected)/profile/page.tsx b/app/(protected)/user/page.tsx similarity index 78% rename from app/(protected)/profile/page.tsx rename to app/(protected)/user/page.tsx index dda1fcf..6944c90 100644 --- a/app/(protected)/profile/page.tsx +++ b/app/(protected)/user/page.tsx @@ -2,5 +2,5 @@ import { redirect } from 'next/navigation'; import { routes } from 'shared/config'; export default function ProfilePage() { - redirect(routes.profile.me()); + redirect(routes.user.profile()); } diff --git a/app/(protected)/user/teams/@invitations/error.tsx b/app/(protected)/user/teams/@invitations/error.tsx new file mode 100644 index 0000000..4408046 --- /dev/null +++ b/app/(protected)/user/teams/@invitations/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/user/teams/@invitations/page.tsx b/app/(protected)/user/teams/@invitations/page.tsx new file mode 100644 index 0000000..3ceee30 --- /dev/null +++ b/app/(protected)/user/teams/@invitations/page.tsx @@ -0,0 +1 @@ +export { InvitationsPage as default } from 'pages/invitations'; diff --git a/app/(protected)/user/teams/error.tsx b/app/(protected)/user/teams/error.tsx new file mode 100644 index 0000000..1da15dc --- /dev/null +++ b/app/(protected)/user/teams/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { ErrorState } from 'widgets/error-state'; + +export default function Error({ + unstable_retry, +}: { + error: Error & { digest?: string }; + unstable_retry: () => void; +}) { + return ( + unstable_retry()} + className="border" + /> + ); +} diff --git a/app/(protected)/user/teams/layout.tsx b/app/(protected)/user/teams/layout.tsx new file mode 100644 index 0000000..ba11ff8 --- /dev/null +++ b/app/(protected)/user/teams/layout.tsx @@ -0,0 +1,14 @@ +export default function Layout({ + children, + invitations, +}: { + children: React.ReactNode; + invitations: React.ReactNode; +}) { + return ( + <> + {children} + {invitations} + + ); +} diff --git a/app/(protected)/user/teams/page.tsx b/app/(protected)/user/teams/page.tsx new file mode 100644 index 0000000..3050d1f --- /dev/null +++ b/app/(protected)/user/teams/page.tsx @@ -0,0 +1 @@ +export { TeamsPage as default } from 'pages/teams'; diff --git a/infra/dev/.env.example b/infra/dev/.env.example index 1ef6246..3c88d89 100644 --- a/infra/dev/.env.example +++ b/infra/dev/.env.example @@ -40,4 +40,28 @@ S3_ACCESS_KEY='' S3_SECRET_KEY='' IMAGOR_URL=http://localhost -IMAGOR_SECRET=super-secret \ No newline at end of file +IMAGOR_SECRET=super-secret + +GOOGLE_CLIENT_ID=super-secret +GOOGLE_CLIENT_SECRET=super-secret + +GITHUB_CLIENT_ID=super-secret +GITHUB_CLIENT_SECRET=super-secret + +YANDEX_CLIENT_ID=super-secret +YANDEX_CLIENT_SECRET=super-secret + +VKONTAKTE_CLIENT_ID=super-secret +VKONTAKTE_CLIENT_SECRET=super-secret + +GOOGLE_CLIENT_ID=super-secret +GOOGLE_CLIENT_SECRET=super-secret + +GITHUB_CLIENT_ID=super-secret +GITHUB_CLIENT_SECRET=super-secret + +YANDEX_CLIENT_ID=super-secret +YANDEX_CLIENT_SECRET=super-secret + +VKONTAKTE_CLIENT_ID=super-secret +VKONTAKTE_CLIENT_SECRET=super-secret \ No newline at end of file diff --git a/proxy.ts b/proxy.ts index 1d245a2..8f184f6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -6,7 +6,7 @@ import { trace } from '@opentelemetry/api'; const REFRESH_COOKIE = 'refresh'; -const PROTECTED_PREFIXES = [routes.profile.root(), routes.team.root()]; +const PROTECTED_PREFIXES = [routes.user.root(), routes.team.root()]; const PUBLIC_ONLY_ROUTES = [routes.auth.signin(), routes.auth.signup()]; function startsWithOneOf(pathname: string, prefixes: string[]) { @@ -26,7 +26,7 @@ export function proxy(req: NextRequest) { } if (isPublicOnly && hasRefreshCookie) { - return NextResponse.redirect(new URL(routes.profile.root(), req.url)); + return NextResponse.redirect(new URL(routes.user.root(), req.url)); } const response = NextResponse.next(); diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..7a8073e --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/layouts/ErrorLayout.tsx b/src/app/layouts/ErrorLayout.tsx new file mode 100644 index 0000000..3978adc --- /dev/null +++ b/src/app/layouts/ErrorLayout.tsx @@ -0,0 +1 @@ +export { ErrorState } from 'widgets/error-state'; diff --git a/src/app/layouts/SidebarLayout.tsx b/src/app/layouts/SidebarLayout.tsx index 72857fd..81104ae 100644 --- a/src/app/layouts/SidebarLayout.tsx +++ b/src/app/layouts/SidebarLayout.tsx @@ -5,6 +5,7 @@ import { AppSidebar } from 'widgets/app-sidebar'; import { NavUser } from 'widgets/nav-user'; import { Notifications } from 'widgets/notifications'; import { QuickCreate } from 'widgets/quick-create'; +import { SidebarHeaderTitle } from 'widgets/sidebar-header-title'; export function SidebarLayout({ children, ...props }: ComponentProps) { return ( @@ -19,6 +20,7 @@ export function SidebarLayout({ children, ...props }: ComponentProps +
diff --git a/src/app/styles/animations.scss b/src/app/styles/animations.scss index 99104e0..d5e9d2e 100644 --- a/src/app/styles/animations.scss +++ b/src/app/styles/animations.scss @@ -71,10 +71,10 @@ overflow: hidden; } .collapsible-content[data-state='open'] { - animation: slideDown 300ms ease-out; + animation: slideDown 200ms ease-out; } .collapsible-content[data-state='closed'] { - animation: slideUp 300ms ease-out; + animation: slideUp 200ms ease-out; } @keyframes slideDown { diff --git a/src/app/styles/global.css b/src/app/styles/global.css index e6a368c..d6a75d8 100644 --- a/src/app/styles/global.css +++ b/src/app/styles/global.css @@ -48,80 +48,77 @@ :root { --background: oklch(1 0 0); - --foreground: oklch(0.3211 0 0); + --foreground: oklch(0.2077 0.0398 265.7549); --card: oklch(1 0 0); - --card-foreground: oklch(0.3211 0 0); + --card-foreground: oklch(0.2077 0.0398 265.7549); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.3211 0 0); - --primary: oklch(0.6231 0.188 259.8145); + --popover-foreground: oklch(0.2077 0.0398 265.7549); + --primary: oklch(0.4026 0.1374 261.393); --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.967 0.0029 264.5419); - --secondary-foreground: oklch(0.4461 0.0263 256.8018); - --muted: oklch(0.9846 0.0017 247.8389); - --muted-foreground: oklch(0.551 0.0234 264.3637); - --accent: oklch(0.9514 0.025 236.8242); - --accent-foreground: oklch(0.3791 0.1378 265.5222); + --secondary: oklch(0.9231 0.028 263.4295); + --secondary-foreground: oklch(0.4026 0.1374 261.393); + --muted: oklch(0.9683 0.0069 247.8956); + --muted-foreground: oklch(0.5544 0.0407 257.4166); + --accent: oklch(0.9683 0.0069 247.8956); + --accent-foreground: oklch(0.4026 0.1374 261.393); --destructive: oklch(0.6368 0.2078 25.3313); --destructive-foreground: oklch(1 0 0); - --border: oklch(0.9276 0.0058 264.5313); - --input: oklch(0.9276 0.0058 264.5313); - --ring: oklch(0.6231 0.188 259.8145); - --chart-1: oklch(0.6231 0.188 259.8145); - --chart-2: oklch(0.5461 0.2152 262.8809); - --chart-3: oklch(0.4882 0.2172 264.3763); - --chart-4: oklch(0.4244 0.1809 265.6377); - --chart-5: oklch(0.3791 0.1378 265.5222); - --sidebar: oklch(0.9846 0.0017 247.8389); - --sidebar-foreground: oklch(0.3211 0 0); - --sidebar-primary: oklch(0.6231 0.188 259.8145); + --border: oklch(0.9288 0.0126 255.5078); + --input: oklch(0.9288 0.0126 255.5078); + --ring: oklch(0.4026 0.1374 261.393); + --chart-1: oklch(0.4026 0.1374 261.393); + --chart-2: oklch(0.5598 0.1882 266.3996); + --chart-3: oklch(0.7212 0.1276 264.1059); + --chart-4: oklch(0.8122 0.0844 262.9727); + --chart-5: oklch(0.9231 0.028 263.4295); + --sidebar: oklch(0.9842 0.0034 247.8575); + --sidebar-foreground: oklch(0.2795 0.0368 260.031); + --sidebar-primary: oklch(0.4026 0.1374 261.393); --sidebar-primary-foreground: oklch(1 0 0); - --sidebar-accent: oklch(0.9514 0.025 236.8242); - --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222); - --sidebar-border: oklch(0.9276 0.0058 264.5313); - --sidebar-ring: oklch(0.6231 0.188 259.8145); - --font-sans: Inter, sans-serif; - --font-serif: Source Serif 4, serif; - --font-mono: JetBrains Mono, monospace; - --radius: 0.375rem; + --sidebar-accent: oklch(0.9231 0.028 263.4295); + --sidebar-accent-foreground: oklch(0.4026 0.1374 261.393); + --sidebar-border: oklch(0.9288 0.0126 255.5078); + --sidebar-ring: oklch(0.4026 0.1374 261.393); + --radius: 0.5rem; --link: oklch(1 0 89.876 / 0); - --link-foreground: oklch(54.65% 0.246 262.87); + --link-foreground: oklch(0.4026 0.1374 261.393); } .dark { - --background: oklch(0.2046 0 0); - --foreground: oklch(0.9219 0 0); - --card: oklch(0.2686 0 0); - --card-foreground: oklch(0.9219 0 0); - --popover: oklch(0.2686 0 0); - --popover-foreground: oklch(0.9219 0 0); - --primary: oklch(0.6231 0.188 259.8145); + --background: oklch(0.2077 0.0398 265.7549); + --foreground: oklch(0.9842 0.0034 247.8575); + --card: oklch(0.2795 0.0368 260.031); + --card-foreground: oklch(0.9842 0.0034 247.8575); + --popover: oklch(0.2077 0.0398 265.7549); + --popover-foreground: oklch(0.9842 0.0034 247.8575); + --primary: oklch(0.4026 0.1374 261.393); --primary-foreground: oklch(1 0 0); - --secondary: oklch(0.2686 0 0); - --secondary-foreground: oklch(0.9219 0 0); - --muted: oklch(0.2393 0 0); - --muted-foreground: oklch(0.7155 0 0); - --accent: oklch(0.3791 0.1378 265.5222); - --accent-foreground: oklch(0.8823 0.0571 254.1284); - --destructive: oklch(0.6368 0.2078 25.3313); - --destructive-foreground: oklch(1 0 0); - --border: oklch(0.3715 0 0); - --input: oklch(0.3715 0 0); - --ring: oklch(0.6231 0.188 259.8145); - --chart-1: oklch(0.7137 0.1434 254.624); + --secondary: oklch(0.2795 0.0368 260.031); + --secondary-foreground: oklch(0.9231 0.028 263.4295); + --muted: oklch(0.2795 0.0368 260.031); + --muted-foreground: oklch(0.7107 0.0351 256.7878); + --accent: oklch(0.2795 0.0368 260.031); + --accent-foreground: oklch(0.9231 0.028 263.4295); + --destructive: oklch(0.3958 0.1331 25.723); + --destructive-foreground: oklch(0.9842 0.0034 247.8575); + --border: oklch(0.3717 0.0392 257.287); + --input: oklch(0.3717 0.0392 257.287); + --ring: oklch(0.4026 0.1374 261.393); + --chart-1: oklch(0.4026 0.1374 261.393); --chart-2: oklch(0.6231 0.188 259.8145); - --chart-3: oklch(0.5461 0.2152 262.8809); - --chart-4: oklch(0.4882 0.2172 264.3763); - --chart-5: oklch(0.4244 0.1809 265.6377); - --sidebar: oklch(0.2046 0 0); - --sidebar-foreground: oklch(0.9219 0 0); - --sidebar-primary: oklch(0.6231 0.188 259.8145); + --chart-3: oklch(0.7137 0.1434 254.624); + --chart-4: oklch(0.8091 0.0956 251.8128); + --chart-5: oklch(0.8823 0.0571 254.1284); + --sidebar: oklch(0.2795 0.0368 260.031); + --sidebar-foreground: oklch(0.9842 0.0034 247.8575); + --sidebar-primary: oklch(0.4026 0.1374 261.393); --sidebar-primary-foreground: oklch(1 0 0); - --sidebar-accent: oklch(0.3791 0.1378 265.5222); - --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284); - --sidebar-border: oklch(0.3715 0 0); - --sidebar-ring: oklch(0.6231 0.188 259.8145); + --sidebar-accent: oklch(0.3717 0.0392 257.287); + --sidebar-accent-foreground: oklch(0.9231 0.028 263.4295); + --sidebar-border: oklch(0.3717 0.0392 257.287); + --sidebar-ring: oklch(0.4026 0.1374 261.393); --link: oklch(1 0 89.876 / 0); - --link-foreground: oklch(54.65% 0.246 262.87); + --link-foreground: oklch(0.4026 0.1374 261.393); } @layer base { diff --git a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx index 0eb111d..ab39fa2 100644 --- a/src/features/projects/archive/ui/ArchiveProjectDialog.tsx +++ b/src/features/projects/archive/ui/ArchiveProjectDialog.tsx @@ -1,6 +1,7 @@ 'use client'; import { ComponentProps } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; import { AlertDialog, AlertDialogAction, @@ -19,6 +20,7 @@ interface ArchiveProjectDialogProps extends ComponentProps void; + dialog?: ComponentProps; } export function ArchiveProjectDialog({ @@ -26,10 +28,20 @@ export function ArchiveProjectDialog({ teamId, projectId, onArchived, + dialog = {}, ...props }: ArchiveProjectDialogProps) { + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + const archiveProject = useArchiveProject({ - onSuccess: () => onArchived?.(), + onSuccess: () => { + setOpen(false); + onArchived?.(); + }, }); const onArchive = () => { @@ -37,8 +49,8 @@ export function ArchiveProjectDialog({ }; return ( - - + + {props.children ? : null} Архивировать проект? diff --git a/src/features/projects/archive/ui/RestoreProjectDialog.tsx b/src/features/projects/archive/ui/RestoreProjectDialog.tsx index 5dda208..05f7b4f 100644 --- a/src/features/projects/archive/ui/RestoreProjectDialog.tsx +++ b/src/features/projects/archive/ui/RestoreProjectDialog.tsx @@ -1,6 +1,7 @@ 'use client'; import { ComponentProps } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; import { AlertDialog, AlertDialogAction, @@ -18,23 +19,33 @@ interface RestoreProjectDialogProps extends ComponentProps; } export function RestoreProjectDialog({ projectName, teamId, projectId, + dialog = {}, ...props }: RestoreProjectDialogProps) { - const restoreProject = useRestoreProject(); + const [open, setOpen] = useControllableState({ + defaultValue: dialog.defaultOpen, + value: dialog.open, + onChange: dialog.onOpenChange, + }); + + const restoreProject = useRestoreProject({ + onSuccess: () => setOpen(false), + }); const onRestore = () => { restoreProject.mutate({ teamId, id: projectId }); }; return ( - - + + {props.children ? : null} Восстановить проект? diff --git a/src/features/projects/create/ui/CreateProjectDialog.tsx b/src/features/projects/create/ui/CreateProjectDialog.tsx index eb88e9a..3c829ba 100644 --- a/src/features/projects/create/ui/CreateProjectDialog.tsx +++ b/src/features/projects/create/ui/CreateProjectDialog.tsx @@ -31,7 +31,7 @@ export function CreateProjectDialog({ dialog = {}, ...props }: CreateProjectDial return ( - + {props.children ? : null} Новый проект diff --git a/src/features/projects/remove/ui/RemoveProjectDialog.tsx b/src/features/projects/remove/ui/RemoveProjectDialog.tsx index c892c6f..2e5921a 100644 --- a/src/features/projects/remove/ui/RemoveProjectDialog.tsx +++ b/src/features/projects/remove/ui/RemoveProjectDialog.tsx @@ -31,7 +31,7 @@ export function RemoveProjectDialog({ projectName, teamId, projectId, ...props } return ( - + {props.children ? : null} Удалить проект? diff --git a/src/features/projects/share/ui/ShareProjectDialog.tsx b/src/features/projects/share/ui/ShareProjectDialog.tsx index 963f33b..5a4813d 100644 --- a/src/features/projects/share/ui/ShareProjectDialog.tsx +++ b/src/features/projects/share/ui/ShareProjectDialog.tsx @@ -3,8 +3,8 @@ import { buildProjectShareUrl } from 'entities/project'; import { Copy } from 'lucide-react'; import { ComponentProps, useState } from 'react'; -import { formatDate } from 'shared/lib/utils'; import { useControllableState } from 'shared/lib/hooks'; +import { formatDate } from 'shared/lib/utils'; import { Button, Dialog, @@ -30,9 +30,9 @@ import { Spinner, } from 'shared/ui'; import { SHARE_TTL_OPTIONS } from '../config/ttl-options'; -import type { ShareTtlOption } from '../model/types'; import { copyShareUrl } from '../model/copy-share-url'; import { ttlOptionToBody } from '../model/ttl-option-to-body'; +import type { ShareTtlOption } from '../model/types'; import { useShareProject } from '../model/useShareProject'; interface ShareProjectDialogProps extends ComponentProps { @@ -89,7 +89,7 @@ export function ShareProjectDialog({ return ( - + {props.children ? : null} Поделиться проектом diff --git a/src/features/teams/create/ui/CreateTeamDialog.tsx b/src/features/teams/create/ui/CreateTeamDialog.tsx index bba9b99..3dba617 100644 --- a/src/features/teams/create/ui/CreateTeamDialog.tsx +++ b/src/features/teams/create/ui/CreateTeamDialog.tsx @@ -31,7 +31,7 @@ export function CreateTeamDialog({ dialog = {}, ...props }: CreateTeamDialogProp return ( - + {props.children ? : null} Новая команда diff --git a/src/features/teams/invite/model/useInviteTeamMember.ts b/src/features/teams/invite/model/useInviteTeamMember.ts index 7bb392d..fae58e7 100644 --- a/src/features/teams/invite/model/useInviteTeamMember.ts +++ b/src/features/teams/invite/model/useInviteTeamMember.ts @@ -1,5 +1,5 @@ import { type DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { teamFabricKeys, TeamHttp, type TTeam, useTeamStore } from 'entities/team'; +import { teamFabricKeys, TeamHttp, type TTeam } from 'entities/team'; import { toast } from 'sonner'; export type InviteTeamMemberVariables = { teamId: string; body: TTeam.InviteMemberBody }; @@ -10,8 +10,6 @@ export type UseInviteTeamMemberOptions = Omit< >; export function useInviteTeamMember({ onSuccess, ...rest }: UseInviteTeamMemberOptions = {}) { - const teamId = useTeamStore.use.teamId(); - return useMutation({ ...rest, mutationFn: ({ teamId, body }) => TeamHttp.inviteMember(teamId, body), @@ -19,8 +17,8 @@ export function useInviteTeamMember({ onSuccess, ...rest }: UseInviteTeamMemberO onSuccess?.(res, _v, _r, context); toast.success(res.message ?? 'Приглашение отправлено'); - if (teamId) { - await context.client.invalidateQueries({ queryKey: teamFabricKeys.invitations(teamId) }); + if (_v.teamId) { + await context.client.invalidateQueries({ queryKey: teamFabricKeys.invitations(_v.teamId) }); } }, }); diff --git a/src/features/teams/invite/model/useInviteTeamMemberForm.ts b/src/features/teams/invite/model/useInviteTeamMemberForm.ts index 6b7c801..a069e59 100644 --- a/src/features/teams/invite/model/useInviteTeamMemberForm.ts +++ b/src/features/teams/invite/model/useInviteTeamMemberForm.ts @@ -9,8 +9,12 @@ import { InviteTeamMemberFormSchema } from './schemas'; import type { InviteTeamMemberFormValues } from './types'; import { useInviteTeamMember, type UseInviteTeamMemberOptions } from './useInviteTeamMember'; -export function useInviteTeamMemberForm(mutateOptions: UseInviteTeamMemberOptions = {}) { - const teamId = useTeamStore.use.teamId(); +export function useInviteTeamMemberForm( + mutateOptions: UseInviteTeamMemberOptions = {}, + teamIdProp?: string +) { + const activeTeamId = useTeamStore.use.teamId(); + const teamId = teamIdProp ?? activeTeamId; const form = useForm({ resolver: zodResolver(InviteTeamMemberFormSchema), diff --git a/src/features/teams/invite/ui/InviteTeamMemberDialog.tsx b/src/features/teams/invite/ui/InviteTeamMemberDialog.tsx index 9ce723b..b89581d 100644 --- a/src/features/teams/invite/ui/InviteTeamMemberDialog.tsx +++ b/src/features/teams/invite/ui/InviteTeamMemberDialog.tsx @@ -1,6 +1,7 @@ 'use client'; import { ComponentProps, useId, useState } from 'react'; +import { useControllableState } from 'shared/lib/hooks'; import { Button, Dialog, @@ -13,14 +14,18 @@ import { DialogTrigger, Spinner, } from 'shared/ui'; -import { useControllableState } from 'shared/lib/hooks'; import { InviteTeamMemberForm } from './InviteTeamMemberForm'; interface InviteTeamMemberDialogProps extends ComponentProps { dialog?: ComponentProps; + teamId?: string; } -export function InviteTeamMemberDialog({ dialog = {}, ...props }: InviteTeamMemberDialogProps) { +export function InviteTeamMemberDialog({ + dialog = {}, + teamId, + ...props +}: InviteTeamMemberDialogProps) { const [open, setOpen] = useControllableState({ defaultValue: dialog.defaultOpen, value: dialog.open, @@ -31,7 +36,7 @@ export function InviteTeamMemberDialog({ dialog = {}, ...props }: InviteTeamMemb return ( - + {props.children ? : null} Пригласить участника @@ -41,6 +46,7 @@ export function InviteTeamMemberDialog({ dialog = {}, ...props }: InviteTeamMemb { diff --git a/src/features/teams/invite/ui/InviteTeamMemberForm.tsx b/src/features/teams/invite/ui/InviteTeamMemberForm.tsx index 144488a..70232b5 100644 --- a/src/features/teams/invite/ui/InviteTeamMemberForm.tsx +++ b/src/features/teams/invite/ui/InviteTeamMemberForm.tsx @@ -21,14 +21,16 @@ import { useInviteTeamMemberForm } from '../model/useInviteTeamMemberForm'; interface InviteTeamMemberFormProps extends Omit, 'children' | 'onSubmit'> { mutateOptions?: UseInviteTeamMemberOptions; + teamId?: string; } export function InviteTeamMemberForm({ className, mutateOptions, + teamId, ...props }: InviteTeamMemberFormProps) { - const { form, isPending, handleSubmit } = useInviteTeamMemberForm(mutateOptions); + const { form, isPending, handleSubmit } = useInviteTeamMemberForm(mutateOptions, teamId); return (
diff --git a/src/features/teams/remove/ui/RemoveTeamDialog.tsx b/src/features/teams/remove/ui/RemoveTeamDialog.tsx index 621f87e..ccb6c07 100644 --- a/src/features/teams/remove/ui/RemoveTeamDialog.tsx +++ b/src/features/teams/remove/ui/RemoveTeamDialog.tsx @@ -16,9 +16,10 @@ import { useRemoveTeam } from '../model/useRemoveTeam'; interface Props extends ComponentProps { teamName: string; teamId: string; + dialog?: ComponentProps; } -export function RemoveTeamDialog({ teamName, teamId, ...props }: Props) { +export function RemoveTeamDialog({ teamName, teamId, dialog = {}, ...props }: Props) { const [inputValue, setInputValue] = useState(''); const removeTeam = useRemoveTeam(); @@ -28,9 +29,17 @@ export function RemoveTeamDialog({ teamName, teamId, ...props }: Props) { removeTeam.mutate(teamId); }; + const handleOpenChange = (open: boolean) => { + if (!open) { + setInputValue(''); + } + + dialog.onOpenChange?.(open); + }; + return ( - - + + {props.children ? : null} Удалить рабочее пространство? diff --git a/src/pages/auth/signin/ui/SigninPage.tsx b/src/pages/auth/signin/ui/SigninPage.tsx index 9490e3f..cfe7c5c 100644 --- a/src/pages/auth/signin/ui/SigninPage.tsx +++ b/src/pages/auth/signin/ui/SigninPage.tsx @@ -22,7 +22,7 @@ function SigninPage() { onSuccess: (res) => { if (res.success) { AccessToken.token = res.token; - router.replace(routes.profile.root()); + router.replace(routes.user.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/auth/signup/ui/SignupPage.tsx b/src/pages/auth/signup/ui/SignupPage.tsx index 459a420..72f758a 100644 --- a/src/pages/auth/signup/ui/SignupPage.tsx +++ b/src/pages/auth/signup/ui/SignupPage.tsx @@ -63,7 +63,7 @@ function SignupPage() { if (res.success) { clearDraft(); AccessToken.token = res.token; - router.replace(routes.profile.root()); + router.replace(routes.user.root()); if (res.message) { toast.success(res.message); } diff --git a/src/pages/profile/api/useAcceptTeamInvitation.ts b/src/pages/invitations/api/useAcceptTeamInvitation.ts similarity index 100% rename from src/pages/profile/api/useAcceptTeamInvitation.ts rename to src/pages/invitations/api/useAcceptTeamInvitation.ts diff --git a/src/pages/invitations/index.ts b/src/pages/invitations/index.ts new file mode 100644 index 0000000..f384caa --- /dev/null +++ b/src/pages/invitations/index.ts @@ -0,0 +1 @@ +export { InvitationsPage } from './ui/InvitationsPage'; diff --git a/src/pages/invitations/model/get-invitations-count-text.ts b/src/pages/invitations/model/get-invitations-count-text.ts new file mode 100644 index 0000000..77a3617 --- /dev/null +++ b/src/pages/invitations/model/get-invitations-count-text.ts @@ -0,0 +1,14 @@ +import { getPluralForm, type PluralForms } from 'shared/lib/utils'; + +const invitationsCountForms: PluralForms = { + one: 'приглашение', + few: 'приглашения', + two: 'приглашения', + many: 'приглашений', + zero: 'приглашений', + other: 'приглашений', +}; + +export function getInvitationsCountText(count: number) { + return `${count} ${getPluralForm(count, invitationsCountForms)}`; +} diff --git a/src/pages/invitations/ui/InvitationCard.skeleton.tsx b/src/pages/invitations/ui/InvitationCard.skeleton.tsx new file mode 100644 index 0000000..5f8a480 --- /dev/null +++ b/src/pages/invitations/ui/InvitationCard.skeleton.tsx @@ -0,0 +1,38 @@ +import { ComponentProps } from 'react'; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, + Skeleton, +} from 'shared/ui'; + +export function InvitationCardSkeleton(props: Omit, 'children'>) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/pages/invitations/ui/InvitationCard.tsx b/src/pages/invitations/ui/InvitationCard.tsx new file mode 100644 index 0000000..4a76a0e --- /dev/null +++ b/src/pages/invitations/ui/InvitationCard.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { TUser } from 'entities/user'; +import { MailIcon } from 'lucide-react'; +import { useAcceptTeamInvitation } from '../api/useAcceptTeamInvitation'; +import { formatDate } from 'shared/lib/utils'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Button, + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemHeader, + ItemMedia, + ItemTitle, +} from 'shared/ui'; + +interface InvitationCardProps { + invitation: TUser.UserInvitationResponse; +} + +export function InvitationCard({ invitation }: InvitationCardProps) { + const acceptInvitation = useAcceptTeamInvitation(); + + return ( + + + + + + + + + + + + {invitation.teamName} + + + + + От {invitation.inviterName} + роль: {invitation.role} + До {formatDate(invitation.expiresAt)} + + + + + + + ); +} diff --git a/src/pages/invitations/ui/InvitationsPage.tsx b/src/pages/invitations/ui/InvitationsPage.tsx new file mode 100644 index 0000000..ac9cd20 --- /dev/null +++ b/src/pages/invitations/ui/InvitationsPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { InvitationsPageFallback } from './InvitationsPageFallback'; + +const InvitationsPageContent = dynamic( + () => import('./InvitationsPageContent').then((mod) => mod.InvitationsPageContent), + { + ssr: false, + loading: () => , + } +); + +export function InvitationsPage() { + return ; +} diff --git a/src/pages/invitations/ui/InvitationsPageContent.tsx b/src/pages/invitations/ui/InvitationsPageContent.tsx new file mode 100644 index 0000000..93d16b9 --- /dev/null +++ b/src/pages/invitations/ui/InvitationsPageContent.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; +import { MailOpen } from 'lucide-react'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { getInvitationsCountText } from '../model/get-invitations-count-text'; +import { InvitationCard } from './InvitationCard'; + +export function InvitationsPageContent() { + const invitationsQuery = useSuspenseQuery(UserQueries.getMyInvitations()); + const invitations = invitationsQuery.data.items; + const invitationsCount = invitationsQuery.data.meta.total ?? invitations.length; + + return ( + 0 ? `У вас ${getInvitationsCountText(invitationsCount)}` : undefined + } + > + {invitations.length === 0 ? ( + + + + + + Входящих приглашений нет + Когда вас пригласят в команду, они появятся здесь. + + + ) : ( + invitations.map((invitation) => ( + + )) + )} + + ); +} diff --git a/src/pages/invitations/ui/InvitationsPageFallback.tsx b/src/pages/invitations/ui/InvitationsPageFallback.tsx new file mode 100644 index 0000000..2de539e --- /dev/null +++ b/src/pages/invitations/ui/InvitationsPageFallback.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { InvitationCardSkeleton } from './InvitationCard.skeleton'; + +export function InvitationsPageFallback() { + return ( + } + > + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + ); +} diff --git a/src/pages/profile/config/tabs.ts b/src/pages/profile/config/tabs.ts index fb521f2..1ae5728 100644 --- a/src/pages/profile/config/tabs.ts +++ b/src/pages/profile/config/tabs.ts @@ -1,8 +1,7 @@ import { routes } from 'shared/config'; export const profileTabs = [ - { key: routes.profile.me(), label: 'Мой профиль' }, - { key: routes.profile.teams(), label: 'Команды' }, - { key: routes.profile.security(), label: 'Безопасность' }, - { key: routes.profile.notifications(), label: 'Уведомления' }, + { key: routes.user.profile(), label: 'Мой профиль' }, + { key: routes.user.security(), label: 'Безопасность' }, + { key: routes.user.notifications(), label: 'Уведомления' }, ]; diff --git a/src/pages/profile/index.ts b/src/pages/profile/index.ts index 879d3a0..8922138 100644 --- a/src/pages/profile/index.ts +++ b/src/pages/profile/index.ts @@ -2,4 +2,3 @@ export { profileTabs } from './config/tabs'; export { MePage } from './ui/me-page/MePage'; export { NotificationsPage } from './ui/notifications-page/NotificationsPage'; export { SecurityPage } from './ui/security-page/SecurityPage'; -export { TeamsPage } from './ui/teams-page/TeamsPage'; diff --git a/src/pages/profile/ui/teams-page/InvitationItem.tsx b/src/pages/profile/ui/teams-page/InvitationItem.tsx deleted file mode 100644 index 93e1201..0000000 --- a/src/pages/profile/ui/teams-page/InvitationItem.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { TUser } from 'entities/user'; -import { useAcceptTeamInvitation } from '../../api/useAcceptTeamInvitation'; -import { MailIcon } from 'lucide-react'; -import { formatDate } from 'shared/lib/utils'; -import { - Avatar, - AvatarFallback, - AvatarImage, - Button, - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from 'shared/ui'; - -export function InvitationItem(props: TUser.UserInvitationResponse) { - const acceptInvitation = useAcceptTeamInvitation(); - - return ( - - - - - - - - - - - {props.teamName} - - От {props.inviterName} · роль: {props.role}. Действует до {formatDate(props.expiresAt)}. - - - - - - - ); -} diff --git a/src/pages/profile/ui/teams-page/Invitations.tsx b/src/pages/profile/ui/teams-page/Invitations.tsx deleted file mode 100644 index 53a1d26..0000000 --- a/src/pages/profile/ui/teams-page/Invitations.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { UserQueries } from 'entities/user'; -import { InvitationItem } from './InvitationItem'; -import { InvitationItemSkeleton } from './skeletons/InvitationItem.skeleton'; - -export function Invitations() { - const invitationsQuery = useQuery(UserQueries.getMyInvitations()); - - if (invitationsQuery.isPending) { - return ( -
-
    - {Array.from({ length: 2 }).map((_, i) => ( -
  • - -
  • - ))} -
-
- ); - } - - if (invitationsQuery.isError) { - return ( -

- {invitationsQuery.error.message} -

- ); - } - - const invitations = invitationsQuery.data ?? []; - - return ( -
-
    - {invitations.items.length === 0 ? ( -

    Входящих приглашений нет.

    - ) : ( - invitations.items.map((inv) => { - return ( -
  • - -
  • - ); - }) - )} -
-
- ); -} diff --git a/src/pages/profile/ui/teams-page/TeamList.tsx b/src/pages/profile/ui/teams-page/TeamList.tsx deleted file mode 100644 index fb7af79..0000000 --- a/src/pages/profile/ui/teams-page/TeamList.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { TeamAvatar, useTeamStore } from 'entities/team'; -import { UserQueries } from 'entities/user'; -import { RemoveTeamDialog } from 'features/teams/remove'; -import { Trash2Icon } from 'lucide-react'; -import { - Badge, - Button, - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from 'shared/ui'; -import { TeamsEmpty } from './TeamsEmpty'; -import { TeamItemSkeleton } from './skeletons/TeamItem.skeleton'; -import { useSwitchTeam } from 'features/teams/active-team'; - -export function TeamsList() { - const teamsQuery = useQuery(UserQueries.getMyTeams()); - const teamId = useTeamStore.use.teamId(); - - const { switchTeam } = useSwitchTeam({ - teams: teamsQuery.data?.items, - defaultOptions: { redirect: true }, - }); - - if (teamsQuery.isPending) { - return ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ); - } - - if (teamsQuery.isError) { - return ( -

- {teamsQuery.error.message} -

- ); - } - - const teams = teamsQuery.data?.items ?? []; - if (teams.length === 0) { - return ; - } - - return ( -
    - {teams.map((team) => ( -
  • - - {team.permissions.isOwner ? ( - - Owner - - ) : null} - - - - - {team.name} - {team.description} - - -
    - - - - -
    -
    -
    -
  • - ))} -
- ); -} diff --git a/src/pages/profile/ui/teams-page/TeamsEmpty.tsx b/src/pages/profile/ui/teams-page/TeamsEmpty.tsx deleted file mode 100644 index 670bea0..0000000 --- a/src/pages/profile/ui/teams-page/TeamsEmpty.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { UserRoundXIcon } from 'lucide-react'; - -import { - Button, - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from 'shared/ui'; -import { CreateTeamDialog } from 'features/teams/create'; - -export function TeamsEmpty() { - return ( - - - - - - Команды отсутствуют - - У вас пока нет команд. Начните с создания первой команды. - - - - - - - - - ); -} diff --git a/src/pages/profile/ui/teams-page/TeamsPage.tsx b/src/pages/profile/ui/teams-page/TeamsPage.tsx deleted file mode 100644 index 6d9f6c0..0000000 --- a/src/pages/profile/ui/teams-page/TeamsPage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client'; - -import { CreateTeamDialog } from 'features/teams/create'; -import { Plus } from 'lucide-react'; -import { Button, CardSection, Separator } from 'shared/ui'; -import { Invitations } from './Invitations'; -import { TeamsList } from './TeamList'; - -function TeamsPage() { - return ( - <> - -
-
-

Мои команды

- - - -
- -
- - - -
-

Приглашения

- -
-
- - ); -} - -export { TeamsPage }; diff --git a/src/pages/profile/ui/teams-page/skeletons/InvitationItem.skeleton.tsx b/src/pages/profile/ui/teams-page/skeletons/InvitationItem.skeleton.tsx deleted file mode 100644 index 90902d8..0000000 --- a/src/pages/profile/ui/teams-page/skeletons/InvitationItem.skeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ComponentProps } from 'react'; -import { Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; - -export function InvitationItemSkeleton(props: Omit, 'children'>) { - return ( - - - - - - - - - - - - - ); -} diff --git a/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx b/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx deleted file mode 100644 index 71fafb0..0000000 --- a/src/pages/profile/ui/teams-page/skeletons/TeamItem.skeleton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ComponentProps } from 'react'; -import { Item, ItemActions, ItemContent, ItemMedia, Skeleton } from 'shared/ui'; - -export function TeamItemSkeleton(props: Omit, 'children'>) { - return ( - - - - - - - - - - - - - ); -} diff --git a/src/pages/team/ui/members/RemoveMemberDialog.tsx b/src/pages/team/ui/members/RemoveMemberDialog.tsx index f405d77..192990c 100644 --- a/src/pages/team/ui/members/RemoveMemberDialog.tsx +++ b/src/pages/team/ui/members/RemoveMemberDialog.tsx @@ -24,7 +24,7 @@ export function RemoveMemberDialog({ userId, name, ...props }: RemoveMemberDialo return ( - + {props.children ? : null} diff --git a/src/pages/teams/index.ts b/src/pages/teams/index.ts new file mode 100644 index 0000000..62806f8 --- /dev/null +++ b/src/pages/teams/index.ts @@ -0,0 +1 @@ +export { TeamsPage } from './ui/TeamsPage'; diff --git a/src/pages/teams/model/get-teams-count-text.ts b/src/pages/teams/model/get-teams-count-text.ts new file mode 100644 index 0000000..228b3af --- /dev/null +++ b/src/pages/teams/model/get-teams-count-text.ts @@ -0,0 +1,14 @@ +import { getPluralForm, type PluralForms } from 'shared/lib/utils'; + +const teamsCountForms: PluralForms = { + one: 'команде', + few: 'командах', + two: 'командах', + many: 'командах', + zero: 'командах', + other: 'командах', +}; + +export function getTeamsCountText(count: number) { + return `${count} ${getPluralForm(count, teamsCountForms)}`; +} diff --git a/src/pages/teams/ui/TeamCard.skeleton.tsx b/src/pages/teams/ui/TeamCard.skeleton.tsx new file mode 100644 index 0000000..10fc35d --- /dev/null +++ b/src/pages/teams/ui/TeamCard.skeleton.tsx @@ -0,0 +1,33 @@ +import { ComponentProps } from 'react'; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, + Skeleton, +} from 'shared/ui'; + +export function TeamCardSkeleton(props: Omit, 'children'>) { + return ( + + +
+ +
+
+ + + + + + + + + + + +
+ ); +} diff --git a/src/pages/teams/ui/TeamCard.tsx b/src/pages/teams/ui/TeamCard.tsx new file mode 100644 index 0000000..204f9fd --- /dev/null +++ b/src/pages/teams/ui/TeamCard.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { TeamAvatar, useTeamStore } from 'entities/team'; +import { TUser } from 'entities/user'; +import { Crown } from 'lucide-react'; +import { classNames } from 'shared/lib/utils'; +import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from 'shared/ui'; +import { TeamCardActions } from './TeamCardActions'; + +interface TeamCardProps { + team: TUser.UserTeamResponse; + onSelect?: (team: TUser.UserTeamResponse) => void; +} + +export function TeamCard({ team, onSelect }: TeamCardProps) { + const currentTeamSlug = useTeamStore.use.teamId(); + const isCurrentTeam = currentTeamSlug === team.id; + + return ( + { + if (!isCurrentTeam) { + onSelect?.(team); + } + }} + > + +
+ + {team.permissions.isOwner ? ( + + + + ) : null} +
+
+ + {team.name} + {team.description} + + + + +
+ ); +} diff --git a/src/pages/teams/ui/TeamCardActions.tsx b/src/pages/teams/ui/TeamCardActions.tsx new file mode 100644 index 0000000..6d8e8d4 --- /dev/null +++ b/src/pages/teams/ui/TeamCardActions.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { TUser } from 'entities/user'; +import { InviteTeamMemberDialog } from 'features/teams/invite'; +import { RemoveTeamDialog } from 'features/teams/remove'; +import { MoreHorizontal, Trash2, UserPlus } from 'lucide-react'; +import { useState } from 'react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'shared/ui'; + +interface TeamCardActionsProps { + team: TUser.UserTeamResponse; +} + +export function TeamCardActions({ team }: TeamCardActionsProps) { + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const [removeDialogOpen, setRemoveDialogOpen] = useState(false); + const isInviteDisabled = !team.permissions.canInvite; + const isRemoveDisabled = !team.permissions.canDelete; + + return ( +
{ + event.stopPropagation(); + }} + > + + + + + + setInviteDialogOpen(true)} disabled={isInviteDisabled}> + + Пригласить + + + setRemoveDialogOpen(true)} + disabled={isRemoveDisabled} + > + + Удалить + + + + + + +
+ ); +} diff --git a/src/pages/teams/ui/TeamsPage.tsx b/src/pages/teams/ui/TeamsPage.tsx new file mode 100644 index 0000000..4d3b4dc --- /dev/null +++ b/src/pages/teams/ui/TeamsPage.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { TeamsPageFallback } from './TeamsPageFallback'; + +const TeamsPageContent = dynamic( + () => import('./TeamsPageContent').then((mod) => mod.TeamsPageContent), + { + ssr: false, + loading: () => , + } +); + +export function TeamsPage() { + return ; +} diff --git a/src/pages/teams/ui/TeamsPageContent.tsx b/src/pages/teams/ui/TeamsPageContent.tsx new file mode 100644 index 0000000..5e229ff --- /dev/null +++ b/src/pages/teams/ui/TeamsPageContent.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; +import { useSwitchTeam } from 'features/teams/active-team'; +import { CreateTeamDialog } from 'features/teams/create'; +import { Plus } from 'lucide-react'; +import { Button } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { getTeamsCountText } from '../model/get-teams-count-text'; +import { TeamCard } from './TeamCard'; + +export function TeamsPageContent() { + const teamsQuery = useSuspenseQuery(UserQueries.getMyTeams()); + const teams = teamsQuery.data.items; + const teamsCount = teamsQuery.data.meta.total ?? teams.length; + + const { switchTeam } = useSwitchTeam({ + teams, + defaultOptions: { redirect: false }, + }); + + return ( + + + + } + > + {teams.map((team) => ( + switchTeam(team.id)} /> + ))} + + ); +} diff --git a/src/pages/teams/ui/TeamsPageFallback.tsx b/src/pages/teams/ui/TeamsPageFallback.tsx new file mode 100644 index 0000000..fcf33a8 --- /dev/null +++ b/src/pages/teams/ui/TeamsPageFallback.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from 'shared/ui'; +import { PageWrapper } from 'widgets/page-wrapper'; +import { TeamCardSkeleton } from './TeamCard.skeleton'; + +export function TeamsPageFallback() { + return ( + } + action={} + > + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + ); +} diff --git a/src/shared/assests/images/logo.svg b/src/shared/assests/images/logo.svg deleted file mode 100644 index 5377c08..0000000 --- a/src/shared/assests/images/logo.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/shared/assests/index.ts b/src/shared/assests/index.ts deleted file mode 100644 index c793b1f..0000000 --- a/src/shared/assests/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as LogoImage } from './images/logo.svg'; diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index f4fb8e8..1cee79d 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -2,12 +2,12 @@ import type { Route } from 'next'; export const routes = { home: (): Route => '/', - profile: { - root: (): Route => '/profile', - me: (): Route => '/profile/me', - security: (): Route => '/profile/security', - notifications: (): Route => '/profile/notifications', - teams: (): Route => '/profile/teams', + user: { + root: (): Route => '/user', + profile: (): Route => '/user/profile', + security: (): Route => '/user/security', + notifications: (): Route => '/user/notifications', + teams: (): Route => '/user/teams', }, team: { root: (): Route => '/team', diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index eaffba0..4704303 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -1,9 +1,10 @@ -export { cn } from './cn'; export { capitalize } from './capitalize/capitalize'; +export { classNames } from './class-names/class-names'; +export { cn } from './cn'; +export { createEntityKeys } from './create-entity-keys'; +export { debounce } from './debounce/debounce'; export { formatDate } from './format-date/format-date'; export { formatTime } from './format-time/format-time'; +export { getPluralForm, type PluralForms } from './pluralize/pluralize'; export { setFormErrors } from './set-form-errors'; -export { createEntityKeys } from './create-entity-keys'; -export { classNames } from './class-names/class-names'; export { throttle } from './throttle/throttle'; -export { debounce } from './debounce/debounce'; diff --git a/src/shared/lib/utils/pluralize/pluralize.test.ts b/src/shared/lib/utils/pluralize/pluralize.test.ts new file mode 100644 index 0000000..07d07af --- /dev/null +++ b/src/shared/lib/utils/pluralize/pluralize.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest'; +import { getPluralForm, type PluralForms } from './pluralize'; + +const testForms: PluralForms = { + one: 'яблоке', + few: 'яблоках', + two: 'яблоках', + many: 'яблоках', + zero: 'яблоках', + other: 'яблоках', +}; + +test('getPluralForm selects correct russian form', () => { + expect(getPluralForm(1, testForms)).toBe('яблоке'); + expect(getPluralForm(2, testForms)).toBe('яблоках'); + expect(getPluralForm(5, testForms)).toBe('яблоках'); + expect(getPluralForm(11, testForms)).toBe('яблоках'); + expect(getPluralForm(21, testForms)).toBe('яблоке'); + expect(getPluralForm(22, testForms)).toBe('яблоках'); +}); diff --git a/src/shared/lib/utils/pluralize/pluralize.ts b/src/shared/lib/utils/pluralize/pluralize.ts new file mode 100644 index 0000000..56ecdeb --- /dev/null +++ b/src/shared/lib/utils/pluralize/pluralize.ts @@ -0,0 +1,18 @@ +export type PluralForms = Record; + +const pluralRulesByLocale = new Map(); + +function getPluralRules(locale: string) { + let rules = pluralRulesByLocale.get(locale); + + if (!rules) { + rules = new Intl.PluralRules(locale); + pluralRulesByLocale.set(locale, rules); + } + + return rules; +} + +export function getPluralForm(count: number, forms: PluralForms, locale = 'ru') { + return forms[getPluralRules(locale).select(count)]; +} diff --git a/src/shared/ui/Card.tsx b/src/shared/ui/Card.tsx index d3c3bff..df153bf 100644 --- a/src/shared/ui/Card.tsx +++ b/src/shared/ui/Card.tsx @@ -50,7 +50,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { return (
); diff --git a/src/shared/ui/Skeleton.tsx b/src/shared/ui/Skeleton.tsx index a9ac1e1..24922c0 100644 --- a/src/shared/ui/Skeleton.tsx +++ b/src/shared/ui/Skeleton.tsx @@ -2,9 +2,9 @@ import { cn } from 'shared/lib/utils'; function Skeleton({ className, ...props }: React.ComponentProps<'div'>) { return ( -
); diff --git a/src/shared/ui/logo/Logo.tsx b/src/shared/ui/logo/Logo.tsx index 114da23..41f6b94 100644 --- a/src/shared/ui/logo/Logo.tsx +++ b/src/shared/ui/logo/Logo.tsx @@ -1,8 +1,8 @@ -import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from 'shared/lib/utils'; import Image from 'next/image'; -import { LogoImage } from 'shared/assests'; +import * as React from 'react'; +import LogoImage from 'logo.svg'; +import { cn } from 'shared/lib/utils'; const logoVariants = cva('flex items-center [&>span]:font-bold', { variants: { diff --git a/src/shared/ui/sidebar/Sidebar.tsx b/src/shared/ui/sidebar/Sidebar.tsx index 60af193..44d59eb 100644 --- a/src/shared/ui/sidebar/Sidebar.tsx +++ b/src/shared/ui/sidebar/Sidebar.tsx @@ -527,7 +527,7 @@ function SidebarMenuAction({ data-slot="sidebar-menu-action" data-sidebar="menu-action" className={cn( - 'text-sidebar-foreground ring-sidebar-ring peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', + 'text-sidebar-foreground ring-sidebar-ring peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-1/2 right-1 flex aspect-square w-5 -translate-y-1/2 items-center justify-center rounded-md p-0 outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0', showOnHover && 'peer-data-active/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 md:opacity-0', className diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx index 5094de9..18ebf7c 100644 --- a/src/widgets/app-sidebar/ui/AppSidebar.tsx +++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx @@ -1,5 +1,4 @@ import { - Separator, Sidebar, SidebarContent, SidebarGroup, @@ -7,10 +6,10 @@ import { SidebarMenu, SidebarRail, } from 'shared/ui'; -import { TeamsDropdown } from './teams/TeamsDropdown'; -import { Projects } from './Projects'; import { MyTeams } from './MyTeams'; -import { Team } from './Team'; +import { Projects } from './projects/Projects'; +import { Team } from './teams/Team'; +import { TeamsDropdown } from './teams/TeamsDropdown'; export function AppSidebar({ ...props }: Omit, 'children'>) { return ( @@ -18,15 +17,22 @@ export function AppSidebar({ ...props }: Omit - + + + + + - - + + + + + diff --git a/src/widgets/app-sidebar/ui/MyTeams.tsx b/src/widgets/app-sidebar/ui/MyTeams.tsx index 1423db5..db4911a 100644 --- a/src/widgets/app-sidebar/ui/MyTeams.tsx +++ b/src/widgets/app-sidebar/ui/MyTeams.tsx @@ -1,24 +1,27 @@ 'use client'; +import { useQuery } from '@tanstack/react-query'; +import { UserQueries } from 'entities/user'; import { Network } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { routes } from 'shared/config'; -import { SidebarMenuButton, SidebarMenuItem } from 'shared/ui'; +import { SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem } from 'shared/ui'; export function MyTeams() { const pathname = usePathname(); + const invitationsQuery = useQuery(UserQueries.getMyInvitations()); + const invitationsCount = + invitationsQuery.data?.meta.total ?? invitationsQuery.data?.items.length ?? 0; + return ( - - + + Мои команды + {invitationsCount > 0 ? {`+${invitationsCount}`} : null} ); } diff --git a/src/widgets/app-sidebar/ui/NavProjects.tsx b/src/widgets/app-sidebar/ui/NavProjects.tsx deleted file mode 100644 index 81551b8..0000000 --- a/src/widgets/app-sidebar/ui/NavProjects.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client'; - -import { Folder, Forward, type LucideIcon, MoreHorizontal, Trash2 } from 'lucide-react'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from 'shared/ui'; - -export function NavProjects({ - projects, -}: { - projects: { - name: string; - url: string; - icon: LucideIcon; - }[]; -}) { - const { isMobile } = useSidebar(); - - return ( - - Projects - - {projects.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - View Project - - - - Share Project - - - - - Delete Project - - - - - ))} - - - - More - - - - - ); -} diff --git a/src/widgets/app-sidebar/ui/NavUser.tsx b/src/widgets/app-sidebar/ui/NavUser.tsx index 172bdb6..87fbf06 100644 --- a/src/widgets/app-sidebar/ui/NavUser.tsx +++ b/src/widgets/app-sidebar/ui/NavUser.tsx @@ -73,7 +73,7 @@ export function NavUser() { - Account + Account diff --git a/src/widgets/app-sidebar/ui/Projects.tsx b/src/widgets/app-sidebar/ui/Projects.tsx deleted file mode 100644 index cd17642..0000000 --- a/src/widgets/app-sidebar/ui/Projects.tsx +++ /dev/null @@ -1,143 +0,0 @@ -'use client'; - -import { useQuery } from '@tanstack/react-query'; -import { projectIconCodeToEmoji, ProjectQueries } from 'entities/project'; -import { useTeamStore } from 'entities/team'; -import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive'; -import { CreateProjectDialog } from 'features/projects/create'; -import { ShareProjectDialog } from 'features/projects/share'; -import { Archive, BriefcaseBusiness, Link2, MoreHorizontal, Plus } from 'lucide-react'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useState } from 'react'; -import { routes } from 'shared/config'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from 'shared/ui'; - -export function Projects() { - const teamId = useTeamStore.use.teamId(); - const { isMobile } = useSidebar(); - const pathname = usePathname(); - const [createProjectOpen, setCreateProjectOpen] = useState(false); - const projects = useQuery({ ...ProjectQueries.getProjects(teamId!), enabled: !!teamId }); - const projectList = projects.data?.items.slice(0, 6) ?? []; - const totalProjects = projects.data?.items.length ?? 0; - - if (!projects.data) { - return null; - } - - return ( - <> - - Проекты - - {projectList.map((project) => { - const canManage = Boolean(teamId && project.canEdit); - - return ( - - - - {projectIconCodeToEmoji(project.icon)} - {project.name} - - - - - - - Действия с проектом - - - - - e.preventDefault()}> - - Создать публичную ссылку - - - {project.status === 'archived' ? ( - - e.preventDefault()}> - - Восстановить - - - ) : ( - project.status !== 'template' && ( - - e.preventDefault()}> - - Архивировать - - - ) - )} - - - - ); - })} - - - - - Все проекты {!!totalProjects && `(${totalProjects})`} - - - - - setCreateProjectOpen(true)} - tooltip={'Добавить проект'} - > - - Добавить проект - - - - - - - ); -} diff --git a/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx new file mode 100644 index 0000000..63c09b7 --- /dev/null +++ b/src/widgets/app-sidebar/ui/projects/ProjectActions.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { TProject } from 'entities/project'; +import { ArchiveProjectDialog, RestoreProjectDialog } from 'features/projects/archive'; +import { ShareProjectDialog } from 'features/projects/share'; +import { Archive, Link2 } from 'lucide-react'; +import { ComponentProps, useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + useSidebar, +} from 'shared/ui'; + +interface ProjectActionsProps extends ComponentProps { + project: TProject.ProjectListItemResponse; + teamId?: string | null; +} + +export function ProjectActions({ project, teamId, ...props }: ProjectActionsProps) { + const { isMobile } = useSidebar(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [archiveOpen, setArchiveOpen] = useState(false); + const [restoreOpen, setRestoreOpen] = useState(false); + const canManage = Boolean(teamId && project.canEdit); + + const openDialog = (setDialogOpen: (open: boolean) => void) => (event: Event) => { + event.preventDefault(); + setDropdownOpen(false); + setDialogOpen(true); + }; + + return ( + <> + + + + + + Опубликовать + + {project.status === 'archived' ? ( + + + Восстановить + + ) : ( + project.status !== 'template' && ( + + + Архивировать + + ) + )} + + + + + {project.status === 'archived' ? ( + + ) : ( + project.status !== 'template' && ( + + ) + )} + + ); +} diff --git a/src/widgets/app-sidebar/ui/projects/Projects.tsx b/src/widgets/app-sidebar/ui/projects/Projects.tsx new file mode 100644 index 0000000..cfcb95c --- /dev/null +++ b/src/widgets/app-sidebar/ui/projects/Projects.tsx @@ -0,0 +1,16 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { ProjectsFallback } from './ProjectsFallback'; + +const ProjectsContent = dynamic( + () => import('./ProjectsContent').then((mod) => mod.ProjectsContent), + { + ssr: false, + loading: () => , + } +); + +export function Projects() { + return ; +} diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx new file mode 100644 index 0000000..9808d45 --- /dev/null +++ b/src/widgets/app-sidebar/ui/projects/ProjectsContent.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import { projectIconCodeToEmoji, ProjectQueries } from 'entities/project'; +import { useTeamStore } from 'entities/team'; +import { CreateProjectDialog } from 'features/projects/create'; +import { BriefcaseBusiness, ChevronRight, MoreHorizontal, Plus } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { routes } from 'shared/config'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from 'shared/ui'; +import { ProjectActions } from './ProjectActions'; + +export function ProjectsContent() { + const teamId = useTeamStore.use.teamId(); + const router = useRouter(); + const pathname = usePathname(); + const projects = useQuery({ ...ProjectQueries.getProjects(teamId!), enabled: !!teamId }); + const projectList = projects.data?.items.slice(0, 6) ?? []; + const totalProjects = projects.data?.items.length ?? 0; + + const { open, isMobile } = useSidebar(); + + const isAllowedToHighlight = !open && !isMobile; + + const handleClickTrigger = () => { + if (isAllowedToHighlight) { + router.push(routes.team.projects()); + } + }; + + if (!projects.data) { + return null; + } + + return ( + + + + + + Проекты + + + + + + {projectList.map((project) => ( + + + + {projectIconCodeToEmoji(project.icon)} {project.name} + + + + + + + + + ))} + + + + Новый проект + + + + {totalProjects > 0 ? ( + + + + Все проекты ({totalProjects}) + + + + ) : null} + + + + + ); +} diff --git a/src/widgets/app-sidebar/ui/projects/ProjectsFallback.tsx b/src/widgets/app-sidebar/ui/projects/ProjectsFallback.tsx new file mode 100644 index 0000000..e6014ab --- /dev/null +++ b/src/widgets/app-sidebar/ui/projects/ProjectsFallback.tsx @@ -0,0 +1,31 @@ +import { + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + Skeleton, +} from 'shared/ui'; + +export function ProjectsFallback() { + return ( + + + + + + + + {Array.from({ length: 2 }).map((_, index) => ( + + + + + ))} + + + + + + + ); +} diff --git a/src/widgets/app-sidebar/ui/teams/Team.tsx b/src/widgets/app-sidebar/ui/teams/Team.tsx new file mode 100644 index 0000000..4b2facd --- /dev/null +++ b/src/widgets/app-sidebar/ui/teams/Team.tsx @@ -0,0 +1,13 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { TeamFallback } from './TeamFallback'; + +const TeamContent = dynamic(() => import('./TeamContent').then((mod) => mod.TeamContent), { + ssr: false, + loading: () => , +}); + +export function Team() { + return ; +} diff --git a/src/widgets/app-sidebar/ui/Team.tsx b/src/widgets/app-sidebar/ui/teams/TeamContent.tsx similarity index 83% rename from src/widgets/app-sidebar/ui/Team.tsx rename to src/widgets/app-sidebar/ui/teams/TeamContent.tsx index 593a39e..974a57e 100644 --- a/src/widgets/app-sidebar/ui/Team.tsx +++ b/src/widgets/app-sidebar/ui/teams/TeamContent.tsx @@ -1,8 +1,9 @@ 'use client'; + import { InviteTeamMemberDialog } from 'features/teams/invite'; -import { UsersRound, ChevronRight, Plus } from 'lucide-react'; +import { ChevronRight, Plus, UsersRound } from 'lucide-react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { usePathname, useRouter } from 'next/navigation'; import { routes } from 'shared/config'; import { Collapsible, @@ -15,10 +16,9 @@ import { SidebarMenuSubItem, useSidebar, } from 'shared/ui'; -import { team } from '../config/sidebar'; -import { useRouter } from 'next/navigation'; +import { team } from '../../config/sidebar'; -export function Team() { +export function TeamContent() { const pathname = usePathname(); const router = useRouter(); const { open, isMobile } = useSidebar(); @@ -30,8 +30,9 @@ export function Team() { router.push(routes.team.members()); } }; + return ( - + ))} - - - Пригласить участника + + Добавить участника diff --git a/src/widgets/app-sidebar/ui/teams/TeamFallback.tsx b/src/widgets/app-sidebar/ui/teams/TeamFallback.tsx new file mode 100644 index 0000000..1ca25e5 --- /dev/null +++ b/src/widgets/app-sidebar/ui/teams/TeamFallback.tsx @@ -0,0 +1,27 @@ +import { + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + Skeleton, +} from 'shared/ui'; + +export function TeamFallback() { + return ( + + + + + + + + {Array.from({ length: 5 }).map((_, index) => ( + + + + + ))} + + + ); +} diff --git a/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx b/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx index 7916770..bec3e59 100644 --- a/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx +++ b/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx @@ -1,7 +1,10 @@ 'use client'; import { CreateTeamDialog } from 'features/teams/create'; +import { Plus } from 'lucide-react'; import Link from 'next/link'; +import { useState } from 'react'; import { routes } from 'shared/config'; +import { useIsMobile } from 'shared/lib/hooks'; import { DropdownMenu, DropdownMenuContent, @@ -15,9 +18,6 @@ import { import { useTeamsDropdown } from '../../model/useTeamsDropdown'; import { TeamItem } from './TeamItem'; import { TeamTrigger } from './TeamTrigger'; -import { useIsMobile } from 'shared/lib/hooks'; -import { Plus } from 'lucide-react'; -import { useState } from 'react'; export function TeamsDropdown() { const { open, setOpen, query, visibleTeams, teams, hasMoreTeams, switchTeam } = @@ -54,7 +54,7 @@ export function TeamsDropdown() { ))} {hasMoreTeams && ( - setOpen(false)}> + setOpen(false)}> Все команды ({teams.length}) diff --git a/src/widgets/error-state/index.ts b/src/widgets/error-state/index.ts new file mode 100644 index 0000000..0ac7db5 --- /dev/null +++ b/src/widgets/error-state/index.ts @@ -0,0 +1 @@ +export { ErrorState } from './ui/ErrorState'; diff --git a/src/widgets/error-state/ui/ErrorState.tsx b/src/widgets/error-state/ui/ErrorState.tsx new file mode 100644 index 0000000..8430015 --- /dev/null +++ b/src/widgets/error-state/ui/ErrorState.tsx @@ -0,0 +1,45 @@ +import { TriangleAlert } from 'lucide-react'; +import { + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from 'shared/ui'; + +interface ErrorStateProps { + title?: string; + description?: string; + actionLabel?: string; + onRetry?: () => void; + className?: string; +} + +export function ErrorState({ + title = 'Не удалось загрузить данные', + description = 'Попробуйте снова. Если ошибка повторится, перезагрузите страницу.', + actionLabel = 'Повторить', + onRetry, + className, +}: ErrorStateProps) { + return ( + + + + + + {title} + {description} + + {onRetry ? ( + + + + ) : null} + + ); +} diff --git a/src/widgets/nav-user/ui/NavUserContent.tsx b/src/widgets/nav-user/ui/NavUserContent.tsx index c4601b2..f29c76e 100644 --- a/src/widgets/nav-user/ui/NavUserContent.tsx +++ b/src/widgets/nav-user/ui/NavUserContent.tsx @@ -57,7 +57,7 @@ export function NavUserContent() { - + Мой профиль diff --git a/src/widgets/page-wrapper/index.ts b/src/widgets/page-wrapper/index.ts new file mode 100644 index 0000000..c093fd0 --- /dev/null +++ b/src/widgets/page-wrapper/index.ts @@ -0,0 +1 @@ +export { PageWrapper } from './ui/PageWrapper'; diff --git a/src/widgets/page-wrapper/ui/PageWrapper.tsx b/src/widgets/page-wrapper/ui/PageWrapper.tsx new file mode 100644 index 0000000..fcce9e6 --- /dev/null +++ b/src/widgets/page-wrapper/ui/PageWrapper.tsx @@ -0,0 +1,22 @@ +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from 'shared/ui'; + +interface PageWrapperProps extends React.ComponentProps { + title?: string; + description?: React.ReactNode; + action?: React.ReactNode; +} + +export function PageWrapper({ title, description, action, children, ...props }: PageWrapperProps) { + return ( + + {(title || description || action) && ( + + {title && {title}} + {description && {description}} + {action && {action}} + + )} + {children} + + ); +} diff --git a/src/widgets/sidebar-header-title/config/route-definitions.ts b/src/widgets/sidebar-header-title/config/route-definitions.ts new file mode 100644 index 0000000..5654112 --- /dev/null +++ b/src/widgets/sidebar-header-title/config/route-definitions.ts @@ -0,0 +1,31 @@ +import { routes } from 'shared/config'; +import type { RouteDefinition } from '../model/types'; + +export const routeDefinitions = [ + ['team.project.settings', (pathname) => pathname.endsWith('/settings'), 'Настройки проекта'], + [ + 'team.project.root', + (pathname) => + pathname.startsWith(routes.team.project.root('')) && !pathname.endsWith('/settings'), + 'Проект', + ], + ['user.teams', (pathname) => pathname === routes.user.teams(), 'Мои команды'], + ['home', (pathname) => pathname === routes.home(), 'Главная'], + ['user.root', (pathname) => pathname === routes.user.root(), 'Профиль'], + ['user.profile', (pathname) => pathname === routes.user.profile(), 'Мой профиль'], + ['user.security', (pathname) => pathname === routes.user.security(), 'Безопасность'], + ['user.notifications', (pathname) => pathname === routes.user.notifications(), 'Уведомления'], + ['team.root', (pathname) => pathname === routes.team.root(), 'Команда'], + ['team.members', (pathname) => pathname === routes.team.members(), 'Участники'], + ['team.invitations', (pathname) => pathname === routes.team.invitations(), 'Приглашения'], + ['team.roles', (pathname) => pathname === routes.team.roles(), 'Роли и права'], + ['team.settings', (pathname) => pathname === routes.team.settings(), 'Настройки'], + ['team.projects', (pathname) => pathname === routes.team.projects(), 'Проекты'], + ['auth.signin', (pathname) => pathname === routes.auth.signin(), 'Вход'], + ['auth.signup', (pathname) => pathname === routes.auth.signup(), 'Регистрация'], + [ + 'auth.forgotPassword', + (pathname) => pathname === routes.auth.forgotPassword(), + 'Восстановление пароля', + ], +] as const satisfies ReadonlyArray; diff --git a/src/widgets/sidebar-header-title/index.ts b/src/widgets/sidebar-header-title/index.ts new file mode 100644 index 0000000..c77b46a --- /dev/null +++ b/src/widgets/sidebar-header-title/index.ts @@ -0,0 +1 @@ +export { SidebarHeaderTitle } from './ui/SidebarHeaderTitle'; diff --git a/src/widgets/sidebar-header-title/model/types.ts b/src/widgets/sidebar-header-title/model/types.ts new file mode 100644 index 0000000..eb702d0 --- /dev/null +++ b/src/widgets/sidebar-header-title/model/types.ts @@ -0,0 +1,19 @@ +import { routes } from 'shared/config'; + +type Join = Prefix extends '' ? Key : `${Prefix}.${Key}`; + +type RouteKeyOf = { + [K in keyof T]: T[K] extends (...args: never[]) => unknown + ? Join> + : T[K] extends Record + ? RouteKeyOf>> + : never; +}[keyof T]; + +export type RouteKey = RouteKeyOf; + +export type RouteDefinition = readonly [ + routeKey: RouteKey, + matcher: (pathname: string) => boolean, + header: string, +]; diff --git a/src/widgets/sidebar-header-title/model/useSidebarHeaderTitle.ts b/src/widgets/sidebar-header-title/model/useSidebarHeaderTitle.ts new file mode 100644 index 0000000..14157f3 --- /dev/null +++ b/src/widgets/sidebar-header-title/model/useSidebarHeaderTitle.ts @@ -0,0 +1,11 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { routeDefinitions } from '../config/route-definitions'; + +export function useSidebarHeaderTitle() { + const pathname = usePathname() ?? ''; + const matchedRoute = routeDefinitions.find(([, matcher]) => matcher(pathname)); + + return matchedRoute?.[2] ?? 'Task Tracker'; +} diff --git a/src/widgets/sidebar-header-title/ui/SidebarHeaderTitle.tsx b/src/widgets/sidebar-header-title/ui/SidebarHeaderTitle.tsx new file mode 100644 index 0000000..bd377ef --- /dev/null +++ b/src/widgets/sidebar-header-title/ui/SidebarHeaderTitle.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { classNames } from 'shared/lib/utils'; +import { useSidebarHeaderTitle } from '../model/useSidebarHeaderTitle'; + +export function SidebarHeaderTitle({ + className, + ...props +}: Omit, 'children'>) { + const resolvedTitle = useSidebarHeaderTitle(); + + return ( +

+ {resolvedTitle} +

+ ); +} diff --git a/src/widgets/tabs-nav/model/types.ts b/src/widgets/tabs-nav/model/types.ts index 043b32c..6404556 100644 --- a/src/widgets/tabs-nav/model/types.ts +++ b/src/widgets/tabs-nav/model/types.ts @@ -5,5 +5,6 @@ import { Badge } from 'shared/ui'; export type TabNavItem = { key: Route; label: string; + matchPrefix?: boolean; badge?: { value: string | ReactNode; variant: ComponentProps['variant'] }; }; diff --git a/src/widgets/tabs-nav/ui/TabsNav.tsx b/src/widgets/tabs-nav/ui/TabsNav.tsx index 2281327..cb0739f 100644 --- a/src/widgets/tabs-nav/ui/TabsNav.tsx +++ b/src/widgets/tabs-nav/ui/TabsNav.tsx @@ -28,7 +28,9 @@ export function TabsNav({ className, tabs, ...props }: TabsNavProps) { {...props} > {tabs.map((tab) => { - const active = pathname === tab.key; + const active = tab.matchPrefix + ? (pathname ?? '').startsWith(tab.key) + : pathname === tab.key; return (