a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
diff --git a/src/shared/ui/Item.tsx b/src/shared/ui/Item.tsx
index 59d22c3..b5c4460 100644
--- a/src/shared/ui/Item.tsx
+++ b/src/shared/ui/Item.tsx
@@ -110,7 +110,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
) {
export {
Item,
- ItemMedia,
- ItemContent,
ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemFooter,
ItemGroup,
+ ItemHeader,
+ ItemMedia,
ItemSeparator,
ItemTitle,
- ItemDescription,
- ItemHeader,
- ItemFooter,
};
diff --git a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx
index b8dc90a..4248fde 100644
--- a/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx
+++ b/src/shared/ui/floating-save-bar/FloatingSaveBar.tsx
@@ -5,11 +5,13 @@ export function FloatingSaveBar({
onSave,
onDiscard,
pending = false,
+ disabledSave = false,
}: {
visible: boolean;
onSave: () => void;
onDiscard: () => void;
pending?: boolean;
+ disabledSave?: boolean;
}) {
if (visible) {
return (
@@ -30,7 +32,7 @@ export function FloatingSaveBar({
-
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 9d44cd3..761f1d8 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -17,7 +17,7 @@ export * from './Sonner';
export * from './InputOtp';
export * from './Spinner';
export * from './Sheet';
-export * from './Sidebar';
+export * from './sidebar';
export * from './Skeleton';
export * from './Tooltip';
export * from './DropdownMenu';
@@ -35,3 +35,4 @@ export * from './search/Search';
export * from './option-group/OptionGroup';
export * from './card-section/CardSection';
export * from './Select';
+export * from './Empty';
diff --git a/src/shared/ui/Sidebar.tsx b/src/shared/ui/sidebar/Sidebar.tsx
similarity index 97%
rename from src/shared/ui/Sidebar.tsx
rename to src/shared/ui/sidebar/Sidebar.tsx
index 84d2470..8ee6b5c 100644
--- a/src/shared/ui/Sidebar.tsx
+++ b/src/shared/ui/sidebar/Sidebar.tsx
@@ -3,24 +3,24 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
-
import { useIsMobile } from 'shared/lib/hooks';
import { cn } from 'shared/lib/utils';
-import { Button } from './button/Button';
-import { Input } from './Input';
-import { Separator } from './Separator';
-import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './Sheet';
-import { Skeleton } from './Skeleton';
-import { Tooltip, TooltipContent, TooltipTrigger } from './Tooltip';
+import { Button } from '../button/Button';
+import { Input } from '../Input';
+import { Separator } from '../Separator';
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../Sheet';
+import { Skeleton } from '../Skeleton';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../Tooltip';
+import {
+ SIDEBAR_COOKIE_MAX_AGE,
+ SIDEBAR_COOKIE_NAME,
+ SIDEBAR_KEYBOARD_SHORTCUT,
+ SIDEBAR_WIDTH,
+ SIDEBAR_WIDTH_ICON,
+ SIDEBAR_WIDTH_MOBILE,
+} from './const';
import { PanelLeftIcon } from 'lucide-react';
-const SIDEBAR_COOKIE_NAME = 'sidebar_state';
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
-const SIDEBAR_WIDTH = '16rem';
-const SIDEBAR_WIDTH_MOBILE = '18rem';
-const SIDEBAR_WIDTH_ICON = '3rem';
-const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
-
type SidebarContextProps = {
state: 'expanded' | 'collapsed';
open: boolean;
diff --git a/src/shared/ui/sidebar/const.ts b/src/shared/ui/sidebar/const.ts
new file mode 100644
index 0000000..9e59823
--- /dev/null
+++ b/src/shared/ui/sidebar/const.ts
@@ -0,0 +1,6 @@
+export const SIDEBAR_COOKIE_NAME = 'sidebar_state';
+export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+export const SIDEBAR_WIDTH = '16rem';
+export const SIDEBAR_WIDTH_MOBILE = '18rem';
+export const SIDEBAR_WIDTH_ICON = '3rem';
+export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
diff --git a/src/shared/ui/sidebar/index.ts b/src/shared/ui/sidebar/index.ts
new file mode 100644
index 0000000..a229f19
--- /dev/null
+++ b/src/shared/ui/sidebar/index.ts
@@ -0,0 +1,2 @@
+export { SIDEBAR_COOKIE_NAME } from './const';
+export * from './Sidebar';
diff --git a/src/widgets/app-sidebar/model/const.ts b/src/widgets/app-sidebar/model/const.ts
new file mode 100644
index 0000000..20dd557
--- /dev/null
+++ b/src/widgets/app-sidebar/model/const.ts
@@ -0,0 +1 @@
+export const MAX_VISIBLE_TEAMS = 5;
diff --git a/src/widgets/app-sidebar/model/useTeamHotkeys.ts b/src/widgets/app-sidebar/model/useTeamHotkeys.ts
new file mode 100644
index 0000000..1f2889f
--- /dev/null
+++ b/src/widgets/app-sidebar/model/useTeamHotkeys.ts
@@ -0,0 +1,17 @@
+import type { TUser } from 'entities/user';
+import { useEffect } from 'react';
+
+export function useTeamHotkeys(teams: TUser.UserTeamResponse[], onSelect: (slug: string) => void) {
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (!(e.metaKey || e.ctrlKey)) return;
+ const index = parseInt(e.key, 10) - 1;
+ if (index >= 0 && index < teams.length) {
+ e.preventDefault();
+ onSelect(teams[index].slug);
+ }
+ };
+ window.addEventListener('keydown', handler);
+ return () => window.removeEventListener('keydown', handler);
+ }, [teams, onSelect]);
+}
diff --git a/src/widgets/app-sidebar/model/useTeamsDropdown.ts b/src/widgets/app-sidebar/model/useTeamsDropdown.ts
new file mode 100644
index 0000000..956ffab
--- /dev/null
+++ b/src/widgets/app-sidebar/model/useTeamsDropdown.ts
@@ -0,0 +1,30 @@
+import { useQuery } from '@tanstack/react-query';
+import { UserQueries } from 'entities/user';
+import { useSwitchTeam } from 'features/teams/active-team';
+import { useMemo, useState } from 'react';
+import { MAX_VISIBLE_TEAMS } from './const';
+import { useTeamHotkeys } from './useTeamHotkeys';
+
+export function useTeamsDropdown() {
+ const [open, setOpen] = useState(false);
+
+ const query = useQuery(UserQueries.getMyTeams());
+ const teams = useMemo(() => query.data ?? [], [query.data]);
+
+ const { switchTeam } = useSwitchTeam({ teams });
+
+ const visibleTeams = teams.slice(0, MAX_VISIBLE_TEAMS);
+ const hasMoreTeams = teams.length > MAX_VISIBLE_TEAMS;
+
+ useTeamHotkeys(visibleTeams, switchTeam);
+
+ return {
+ open,
+ setOpen,
+ query,
+ visibleTeams,
+ teams,
+ hasMoreTeams,
+ switchTeam,
+ };
+}
diff --git a/src/widgets/app-sidebar/ui/AppSidebar.tsx b/src/widgets/app-sidebar/ui/AppSidebar.tsx
index 9c6a591..69d6f79 100644
--- a/src/widgets/app-sidebar/ui/AppSidebar.tsx
+++ b/src/widgets/app-sidebar/ui/AppSidebar.tsx
@@ -1,13 +1,9 @@
'use client';
-import {
- AudioWaveform,
- ChevronRight,
- Command,
- GalleryVerticalEnd,
- UserRound,
- UsersRound,
-} from 'lucide-react';
+import { InviteTeamMemberDialog } from 'features/teams/invite';
+import { ChevronRight, SquarePlusIcon, UserRound, UsersRound } from 'lucide-react';
+import Link from 'next/link';
+import { routes } from 'shared/config';
import {
Collapsible,
CollapsibleContent,
@@ -18,6 +14,7 @@ import {
SidebarGroup,
SidebarHeader,
SidebarMenu,
+ SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
@@ -25,77 +22,41 @@ import {
SidebarMenuSubItem,
SidebarRail,
} from 'shared/ui';
-import { TeamSwitcher } from './TeamSwitcher';
import { NavUser } from './NavUser';
-import { routes } from 'shared/config';
-import Link from 'next/link';
-
-const data = {
- teams: [
- {
- name: 'Task Tracker Frontend',
- logo: GalleryVerticalEnd,
- plan: 'Enterprise',
- },
- {
- name: 'Task Tracker Backend',
- logo: AudioWaveform,
- plan: 'Startup',
- },
- {
- name: 'Task Tracker Devops',
- logo: Command,
- plan: 'Free',
- },
- ],
-};
+import { TeamsDropdown } from './teams/TeamsDropdown';
const team = [
- { url: routes.team.members(), title: 'Участники' },
- { url: routes.team.invites(), title: 'Приглашения' },
- { url: routes.team.roles(), title: 'Роли' },
- { url: routes.team.settings(), title: 'Настройки' },
+ {
+ url: routes.team.members(),
+ title: 'Участники',
+ action: (
+
+
+
+ ),
+ },
+ { url: routes.team.invitations(), title: 'Приглашения', action: null },
+ { url: routes.team.roles(), title: 'Роли', action: null },
+ { url: routes.team.settings(), title: 'Настройки', action: null },
];
-const profile = [
- { url: routes.profile.me(), title: 'Пользователь' },
- { url: routes.profile.security(), title: 'Безопасность' },
- { url: routes.profile.notifications(), title: 'Уведомления' },
-];
-
-export function AppSidebar({ ...props }: React.ComponentProps
) {
+export function AppSidebar({ ...props }: Omit, 'children'>) {
return (
-
+
-
-
-
-
-
- Профиль
-
-
-
-
-
- {profile.map((subItem) => (
-
-
-
- {subItem.title}
-
-
-
- ))}
-
-
-
-
+
+
+
+
+ Мой профиль
+
+
+
@@ -114,6 +75,9 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
{subItem.title}
+ {subItem.action ? (
+ {subItem.action}
+ ) : null}
))}
diff --git a/src/widgets/app-sidebar/ui/TeamSwitcher.tsx b/src/widgets/app-sidebar/ui/TeamSwitcher.tsx
deleted file mode 100644
index 2d243d5..0000000
--- a/src/widgets/app-sidebar/ui/TeamSwitcher.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-'use client';
-
-import * as React from 'react';
-import { ChevronsUpDown, Plus } from 'lucide-react';
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from 'shared/ui';
-
-export function TeamSwitcher({
- teams,
-}: {
- teams: {
- name: string;
- logo: React.ElementType;
- plan: string;
- }[];
-}) {
- const { isMobile } = useSidebar();
- const [activeTeam, setActiveTeam] = React.useState(teams[0]);
-
- if (!activeTeam) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
- {activeTeam.name}
- {activeTeam.plan}
-
-
-
-
-
- Teams
- {teams.map((team, index) => (
- setActiveTeam(team)}
- className="gap-2 p-2"
- >
-
-
-
- {team.name}
- ⌘{index + 1}
-
- ))}
-
-
-
- Add team
-
-
-
-
-
- );
-}
diff --git a/src/widgets/app-sidebar/ui/teams/TeamItem.skeleton.tsx b/src/widgets/app-sidebar/ui/teams/TeamItem.skeleton.tsx
new file mode 100644
index 0000000..49a5033
--- /dev/null
+++ b/src/widgets/app-sidebar/ui/teams/TeamItem.skeleton.tsx
@@ -0,0 +1,20 @@
+import { Item, ItemActions, ItemContent, Skeleton } from 'shared/ui';
+
+export function TeamItemSkeleton() {
+ return (
+ <>
+
+ -
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/widgets/app-sidebar/ui/teams/TeamItem.tsx b/src/widgets/app-sidebar/ui/teams/TeamItem.tsx
new file mode 100644
index 0000000..d71a67e
--- /dev/null
+++ b/src/widgets/app-sidebar/ui/teams/TeamItem.tsx
@@ -0,0 +1,29 @@
+import { TeamAvatar } from 'entities/team';
+import type { TUser } from 'entities/user';
+import { ComponentProps, ReactNode } from 'react';
+import { Item, ItemActions, ItemContent } from 'shared/ui';
+
+interface ITeamItemProps extends Partial<
+ Pick
+> {
+ action?: ReactNode;
+}
+
+export function TeamItem(props: ITeamItemProps & Omit, 'children'>) {
+ const { avatar, name, description, action, ...itemProps } = props;
+
+ return (
+ <>
+
+ -
+
+
+ {name ?? 'no data'}
+ {description && {description}}
+
+
+ {props.action && {action}}
+
+ >
+ );
+}
diff --git a/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
new file mode 100644
index 0000000..6fced57
--- /dev/null
+++ b/src/widgets/app-sidebar/ui/teams/TeamTrigger.tsx
@@ -0,0 +1,44 @@
+import { TUser } from 'entities/user';
+import { UseQueryResult } from '@tanstack/react-query';
+import { TeamItem } from './TeamItem';
+import { TeamItemSkeleton } from './TeamItem.skeleton';
+import { ChevronsUpDown } from 'lucide-react';
+import { useMemo } from 'react';
+import { useTeamStore } from 'entities/team';
+
+interface TeamTriggerProps {
+ query: UseQueryResult;
+}
+
+export function TeamTrigger({ query }: TeamTriggerProps) {
+ const slug = useTeamStore.use.slug();
+
+ const activeTeam = useMemo(() => {
+ if (query.data) {
+ return query.data.find((d) => d.slug === slug);
+ }
+ }, [slug, query.data]);
+
+ if (query.isPending) {
+ return ;
+ }
+
+ if (query.isError) {
+ return (
+
+ );
+ }
+
+ if (!activeTeam) {
+ return } />;
+ }
+
+ return (
+ }
+ />
+ );
+}
diff --git a/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx b/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx
new file mode 100644
index 0000000..f1de64c
--- /dev/null
+++ b/src/widgets/app-sidebar/ui/teams/TeamsDropdown.tsx
@@ -0,0 +1,80 @@
+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 {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+ SidebarMenuButton,
+ useSidebar,
+} from 'shared/ui';
+import { useTeamsDropdown } from '../../model/useTeamsDropdown';
+import { TeamItem } from './TeamItem';
+import { TeamTrigger } from './TeamTrigger';
+
+export function TeamsDropdown() {
+ const { isMobile } = useSidebar();
+ const [createTeamOpen, setCreateTeamOpen] = useState(false);
+ const { open, setOpen, query, visibleTeams, teams, hasMoreTeams, switchTeam } =
+ useTeamsDropdown();
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Команды
+ {visibleTeams.map((team, index) => (
+ switchTeam(team.slug)} className="p-0">
+ ⌘{index + 1}}
+ />
+
+ ))}
+ {hasMoreTeams && (
+
+ setOpen(false)}>
+ Все команды ({teams.length})
+
+
+ )}
+
+ {
+ e.preventDefault();
+ setOpen(false);
+ setCreateTeamOpen(true);
+ }}
+ >
+
+
+ Создать команду
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/widgets/tabs-nav/index.ts b/src/widgets/tabs-nav/index.ts
new file mode 100644
index 0000000..408ab4f
--- /dev/null
+++ b/src/widgets/tabs-nav/index.ts
@@ -0,0 +1,2 @@
+export { type TabNavItem } from './model/types';
+export { TabsNav } from './ui/TabsNav';
diff --git a/src/widgets/tabs-nav/model/types.ts b/src/widgets/tabs-nav/model/types.ts
new file mode 100644
index 0000000..043b32c
--- /dev/null
+++ b/src/widgets/tabs-nav/model/types.ts
@@ -0,0 +1,9 @@
+import type { Route } from 'next';
+import { ComponentProps, ReactNode } from 'react';
+import { Badge } from 'shared/ui';
+
+export type TabNavItem = {
+ key: Route;
+ label: string;
+ badge?: { value: string | ReactNode; variant: ComponentProps['variant'] };
+};
diff --git a/src/pages/profile/ui/TabsNav.tsx b/src/widgets/tabs-nav/ui/TabsNav.tsx
similarity index 57%
rename from src/pages/profile/ui/TabsNav.tsx
rename to src/widgets/tabs-nav/ui/TabsNav.tsx
index 54628c7..2281327 100644
--- a/src/pages/profile/ui/TabsNav.tsx
+++ b/src/widgets/tabs-nav/ui/TabsNav.tsx
@@ -1,21 +1,23 @@
'use client';
-import { ComponentProps } from 'react';
-import type { Route } from 'next';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-import { routes } from 'shared/config';
+import { ComponentProps } from 'react';
import { classNames } from 'shared/lib/utils';
+import { Badge } from 'shared/ui';
+import { TabNavItem } from '../model/types';
-const tabs: { key: Route; label: string }[] = [
- { key: routes.profile.me(), label: 'Пользователь' },
- { key: routes.profile.security(), label: 'Безопасность' },
- { key: routes.profile.notifications(), label: 'Уведомления' },
-];
+interface TabsNavProps extends Omit, 'children'> {
+ tabs: TabNavItem[];
+}
-export function TabsNav({ className, ...props }: ComponentProps<'div'>) {
+export function TabsNav({ className, tabs, ...props }: TabsNavProps) {
const pathname = usePathname();
+ if (tabs.length === 0) {
+ return null;
+ }
+
return (
) {
key={tab.key}
href={tab.key}
className={classNames(
- 'relative p-3 text-sm font-medium whitespace-nowrap transition-colors duration-200',
+ 'relative space-x-1 p-3 text-sm font-medium whitespace-nowrap transition-colors duration-200',
{},
[active ? 'hover:cursor-default' : 'hover:text-muted-foreground']
)}
>
- {tab.label}
+ {tab.label}
+ {tab.badge && (
+
+ {tab.badge.value}
+
+ )}
{active && }
);