Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/app/layouts/SidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { ComponentProps } from 'react';
import { TeamSlugSync } from 'features/teams/active-team';
import { ComponentProps } from 'react';
import { Separator, SidebarInset, SidebarProvider, SidebarTrigger } from 'shared/ui';
import { AppSidebar } from 'widgets/app-sidebar';
import { NavUser } from 'widgets/nav-user';
import { Notifications } from 'widgets/notifications';
import { QuickCreate } from 'widgets/quick-create';

export function SidebarLayout({ children, ...props }: ComponentProps<typeof SidebarProvider>) {
return (
<SidebarProvider {...props}>
<TeamSlugSync />
<AppSidebar />
<SidebarInset className="min-h-screen">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 self-center! data-[orientation=vertical]:h-6"
/>
<header className="bg-background sticky top-0 z-50 flex h-14 shrink-0 items-center justify-between gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="self-center! data-[orientation=vertical]:h-6"
/>
</div>
<div className="flex items-center gap-4">
<QuickCreate />
<Notifications />
<NavUser />
</div>
</header>
<div className="h-full overflow-x-hidden">{children}</div>
</SidebarInset>
Expand Down
2 changes: 1 addition & 1 deletion src/shared/ui/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
<p
data-slot="item-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-sm leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4',
'text-muted-foreground [&>a:hover]:text-primary line-clamp-2 text-left text-xs leading-normal font-normal group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
Expand Down
1 change: 1 addition & 0 deletions src/widgets/nav-user/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NavUser } from './ui/NavUser';
13 changes: 13 additions & 0 deletions src/widgets/nav-user/ui/NavUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import dynamic from 'next/dynamic';
import { NavUserFallback } from './NavUserFallback';

const NavUserContent = dynamic(() => import('./NavUserContent').then((mod) => mod.NavUserContent), {
ssr: false,
loading: () => <NavUserFallback />,
});

export function NavUser() {
return <NavUserContent />;
}
77 changes: 77 additions & 0 deletions src/widgets/nav-user/ui/NavUserContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import { useSuspenseQuery } from '@tanstack/react-query';
import { UserAvatar, UserQueries } from 'entities/user';
import { SignOut } from 'features/auth/sign-out';
import { LogOut, UserRoundIcon } from 'lucide-react';
import Link from 'next/link';
import { routes } from 'shared/config';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
SidebarMenuButton,
} from 'shared/ui';

export function NavUserContent() {
const query = useSuspenseQuery(UserQueries.getMe());

const {
email,
profile: { avatar, firstName, lastName },
} = query.data;

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground size-10 rounded-full p-0.5"
>
<UserAvatar
wrap={{ className: 'size-9' }}
src={avatar?.small}
alt={firstName}
fallback={{ firstName, lastName }}
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserAvatar src={avatar?.small} alt={firstName} fallback={{ firstName, lastName }} />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{firstName}</span>
<span className="truncate text-xs">{email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link href={routes.profile.me()}>
<DropdownMenuItem>
<UserRoundIcon />
Мой профиль
</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<SignOut asChild>
<DropdownMenuItem className="text-destructive">
<LogOut className="size-4" />
Выйти
</DropdownMenuItem>
</SignOut>
</DropdownMenuContent>
</DropdownMenu>
);
}
5 changes: 5 additions & 0 deletions src/widgets/nav-user/ui/NavUserFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Skeleton } from 'shared/ui';

export function NavUserFallback() {
return <Skeleton className="size-9 shrink-0 rounded-full" />;
}
1 change: 1 addition & 0 deletions src/widgets/notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Notifications } from './ui/Notifications';
13 changes: 13 additions & 0 deletions src/widgets/notifications/ui/Notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import dynamic from 'next/dynamic';
import { NotificationsFallback } from './NotificationsFallback';

const NotificationsContent = dynamic(
() => import('./NotificationsContent').then((mod) => mod.NotificationsContent),
{ ssr: false, loading: () => <NotificationsFallback /> }
);

export function Notifications() {
return <NotificationsContent />;
}
68 changes: 68 additions & 0 deletions src/widgets/notifications/ui/NotificationsContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { useQuery } from '@tanstack/react-query';
import { Bell } from 'lucide-react';
import { useState } from 'react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
Spinner,
} from 'shared/ui';

const notificationsQueryKey = ['notifications'];

async function getNotifications() {
// TODO: Replace mocked request with real notifications endpoint.
await new Promise((resolve) => setTimeout(resolve, 400));

return [];
}

export function NotificationsContent() {
const [open, setOpen] = useState(false);
const query = useQuery({
queryKey: notificationsQueryKey,
queryFn: getNotifications,
enabled: open,
});

return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-10 rounded-full"
aria-label="Уведомления"
>
<Bell className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 rounded-lg p-3">
{query.isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
<Spinner className="size-4" />
Загрузка уведомлений...
</div>
) : (
<Empty className="border-0 p-3">
<EmptyHeader>
<EmptyMedia variant="icon">
<Bell />
</EmptyMedia>
<EmptyTitle>Уведомлений нет</EmptyTitle>
<EmptyDescription>Новые уведомления появятся здесь.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
5 changes: 5 additions & 0 deletions src/widgets/notifications/ui/NotificationsFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Skeleton } from 'shared/ui';

export function NotificationsFallback() {
return <Skeleton className="size-10 shrink-0 rounded-full" />;
}
2 changes: 1 addition & 1 deletion src/widgets/page-layout/ui/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function PageLayout({
children,
}: PageLayoutProps) {
return (
<main className="min-h-screen bg-white">
<main className="bg-white">
<div className="mx-auto px-6 py-10 lg:px-8">
<header>
<div className="flex items-center gap-3">
Expand Down
1 change: 1 addition & 0 deletions src/widgets/quick-create/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { QuickCreate } from './ui/QuickCreate';
91 changes: 91 additions & 0 deletions src/widgets/quick-create/ui/QuickCreate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';

import { useTeamStore } from 'entities/team';
import { CreateProjectDialog } from 'features/projects/create';
import { CreateTeamDialog } from 'features/teams/create';
import { FolderPlus, Plus, UsersRound } from 'lucide-react';
import { useState } from 'react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from 'shared/ui';

export function QuickCreate() {
const slug = useTeamStore.use.slug();
const [open, setOpen] = useState(false);
const [createTeamOpen, setCreateTeamOpen] = useState(false);
const [createProjectOpen, setCreateProjectOpen] = useState(false);

return (
<div>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
className="size-10 rounded-full"
variant="outline"
size="icon"
aria-label="Быстрое создание"
>
<Plus className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setOpen(false);
setCreateTeamOpen(true);
}}
>
<Item className="flex-nowrap p-0">
<ItemMedia className="bg-primary/20 rounded-full p-2">
<UsersRound className="text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>Команда</ItemTitle>
<ItemDescription className="whitespace-nowrap">
Создать новую команду
</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!slug}
onSelect={(e) => {
e.preventDefault();
setOpen(false);
setCreateProjectOpen(true);
}}
>
<Item className="flex-nowrap p-0">
<ItemMedia className="bg-primary/20 rounded-full p-2">
<FolderPlus className="text-muted-foreground" />
</ItemMedia>
<ItemContent>
<ItemTitle>Проект</ItemTitle>
<ItemDescription className="whitespace-nowrap">
Создать новый проект
</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateTeamDialog dialog={{ open: createTeamOpen, onOpenChange: setCreateTeamOpen }} />
<CreateProjectDialog
dialog={{ open: createProjectOpen, onOpenChange: setCreateProjectOpen }}
/>
</div>
);
}
Loading