Replaced by ContextualChatProvider + AdiuvaTriggerButton in M4. Pre-1.0 clean removal — no deprecation period.
668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
import { useMemo, useRef, useState } from 'react';
|
|
import { Link, useRouterState, useNavigate, useLocation } from '@tanstack/react-router';
|
|
import { LayoutGroup } from 'framer-motion';
|
|
import { ContextualChatProvider, useContextualChat } from '@/context/ContextualChatContext';
|
|
import { ContextualSidebar } from '@/components/ai/ContextualSidebar';
|
|
import { AdiuvaTriggerButton } from '@/components/ai/AdiuvaTriggerButton';
|
|
import { HeaderProvider, useHeader } from '@/context/HeaderContext';
|
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
|
import type { PanelSize } from 'react-resizable-panels';
|
|
import {
|
|
House,
|
|
ChartGantt,
|
|
ClipboardCheck,
|
|
FolderKanban,
|
|
Settings,
|
|
LogOut,
|
|
Sun,
|
|
Moon,
|
|
Monitor,
|
|
ChevronsUpDown,
|
|
SquarePen,
|
|
Folder,
|
|
ChevronRight,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { trpc } from '@/lib/trpc';
|
|
import { useNotify } from '@/hooks/useNotify';
|
|
import { useTheme } from '@/components/theme-provider';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarMenuSub,
|
|
SidebarMenuSubButton,
|
|
SidebarMenuSubItem,
|
|
SidebarProvider,
|
|
SidebarTrigger,
|
|
useSidebar,
|
|
} from '@/components/ui/sidebar';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuPortal,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
|
import { ExpandedClientsProvider, useExpandedClients } from '@/context/ExpandedClientsContext';
|
|
import { TaskBriefingProvider, useTaskBriefing } from '@/context/TaskBriefingContext';
|
|
import { LoginForm } from '@/components/auth/LoginForm';
|
|
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
const NAV_ITEMS = [
|
|
{ to: '/', icon: House, labelKey: 'nav.home' },
|
|
{ to: '/timeline', icon: ChartGantt, labelKey: 'nav.timeline' },
|
|
{ to: '/tasks', icon: ClipboardCheck, labelKey: 'nav.tasks' },
|
|
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
|
] as const;
|
|
|
|
const SIDEBAR_SIZE_KEY = 'chat.sidebar.size';
|
|
const SIDEBAR_SIZE_MIN = 22;
|
|
const SIDEBAR_SIZE_MAX = 60;
|
|
const SIDEBAR_SIZE_DEFAULT = 38;
|
|
|
|
function readSidebarSize(): number {
|
|
if (typeof window === 'undefined') return SIDEBAR_SIZE_DEFAULT;
|
|
const v = window.localStorage.getItem(SIDEBAR_SIZE_KEY);
|
|
if (!v) return SIDEBAR_SIZE_DEFAULT;
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return SIDEBAR_SIZE_DEFAULT;
|
|
return Math.max(SIDEBAR_SIZE_MIN, Math.min(SIDEBAR_SIZE_MAX, n));
|
|
}
|
|
|
|
function MainArea({ children }: { children: React.ReactNode }) {
|
|
const loc = useLocation();
|
|
const isHome = loc.pathname === '/';
|
|
const { open } = useContextualChat();
|
|
// Read once per mount of the open state. When the user reopens the sidebar
|
|
// we want the most recent persisted size, so we key the PanelGroup on
|
|
// `open` so it remounts each open/close cycle.
|
|
const initialSize = useMemo(() => readSidebarSize(), [open]);
|
|
|
|
if (isHome || !open) {
|
|
return <>{children}</>;
|
|
}
|
|
|
|
return (
|
|
<ResizablePanelGroup
|
|
key={`sidebar-open-${initialSize}`}
|
|
orientation="horizontal"
|
|
className="h-full w-full"
|
|
>
|
|
<ResizablePanel defaultSize={`${100 - initialSize}%`} minSize="30%">
|
|
{children}
|
|
</ResizablePanel>
|
|
<ResizableHandle
|
|
withHandle
|
|
className="bg-border/40 hover:bg-border/70 transition-colors after:w-3! cursor-col-resize"
|
|
/>
|
|
<ResizablePanel
|
|
defaultSize={`${initialSize}%`}
|
|
minSize={`${SIDEBAR_SIZE_MIN}%`}
|
|
maxSize={`${SIDEBAR_SIZE_MAX}%`}
|
|
onResize={(panelSize: PanelSize) => {
|
|
const clamped = Math.max(
|
|
SIDEBAR_SIZE_MIN,
|
|
Math.min(SIDEBAR_SIZE_MAX, panelSize.asPercentage),
|
|
);
|
|
window.localStorage.setItem(SIDEBAR_SIZE_KEY, String(clamped));
|
|
}}
|
|
>
|
|
<div className="h-full w-full">
|
|
<ContextualSidebar />
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
);
|
|
}
|
|
|
|
interface AppShellProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function AppShell({ children }: AppShellProps) {
|
|
return (
|
|
<ExpandedClientsProvider>
|
|
<TaskBriefingProvider>
|
|
<HeaderProvider>
|
|
<div className="flex w-full h-full">
|
|
<AppShellInner>{children}</AppShellInner>
|
|
</div>
|
|
</HeaderProvider>
|
|
</TaskBriefingProvider>
|
|
</ExpandedClientsProvider>
|
|
);
|
|
}
|
|
|
|
function AppShellInner({ children }: AppShellProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const authStatusQuery = trpc.auth.status.useQuery(undefined, {
|
|
staleTime: 5 * 60 * 1000,
|
|
retry: false,
|
|
});
|
|
|
|
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
|
staleTime: Infinity,
|
|
});
|
|
const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation();
|
|
|
|
const routerState = useRouterState();
|
|
const currentPath = routerState.location.pathname;
|
|
|
|
const [open, setOpen] = useState(() =>
|
|
collapsedQuery.data === undefined ? false : !collapsedQuery.data
|
|
);
|
|
|
|
const handleOpenChange = (value: boolean) => {
|
|
setOpen(value);
|
|
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
|
};
|
|
|
|
const taskBriefing = useTaskBriefing();
|
|
const chatActionsRef = useRef<{ clear: () => void } | null>(null);
|
|
const [homeChatHasMessages, setHomeChatHasMessages] = useState(false);
|
|
|
|
const isHomePage = currentPath === '/';
|
|
const isSettingsPage = currentPath.startsWith('/settings');
|
|
|
|
// Derive the page label from the current path for the breadcrumb
|
|
const matchedItem = NAV_ITEMS.find(
|
|
(item) => item.to !== '/' && currentPath.startsWith(item.to),
|
|
);
|
|
const routeLabel = matchedItem ? t(matchedItem.labelKey) : (currentPath.startsWith('/settings') ? t('nav.settings') : '');
|
|
|
|
// Dynamic label/extras published by child pages (e.g. ProjectDetail)
|
|
const { label: dynamicLabel, extras: headerExtras, leftExtras, rightExtras } = useHeader();
|
|
const pageLabel = dynamicLabel ?? routeLabel;
|
|
|
|
// All non-home, non-settings routes show the shared AppShell header.
|
|
// Projects and notes previously managed their own header; they now receive
|
|
// the shared header (with SidebarTrigger + AdiuvaTriggerButton) from here.
|
|
const showHeader = !isSettingsPage && !isHomePage;
|
|
|
|
if (authStatusQuery.data?.authenticated === false) {
|
|
return <LoginForm />;
|
|
}
|
|
|
|
if (
|
|
authStatusQuery.data?.profile &&
|
|
authStatusQuery.data.profile.onboardingCompletedAt == null
|
|
) {
|
|
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
|
|
}
|
|
|
|
return (
|
|
<LayoutGroup>
|
|
<SidebarProvider open={open} onOpenChange={handleOpenChange} className="h-full">
|
|
<AppSidebar
|
|
currentPath={currentPath}
|
|
profile={authStatusQuery.data?.profile ?? null}
|
|
/>
|
|
<SidebarInset className="min-w-0 min-h-0 overflow-x-hidden">
|
|
{isHomePage ? (
|
|
<div className="relative flex-1 min-h-0">
|
|
{!taskBriefing.isOpen && (
|
|
<div className="absolute top-[10px] left-[8px] z-10 flex items-center gap-1 rounded-lg bg-background/60 backdrop-blur-md px-1 py-1">
|
|
<SidebarTrigger />
|
|
{homeChatHasMessages && (
|
|
<>
|
|
<Separator orientation="vertical" className="data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px mx-1" />
|
|
<button
|
|
onClick={() => chatActionsRef.current?.clear()}
|
|
aria-label="New conversation"
|
|
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-accent"
|
|
>
|
|
<SquarePen size={16} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
<AIChatPanel isHomePage actionsRef={chatActionsRef} onHasMessagesChange={setHomeChatHasMessages} />
|
|
</div>
|
|
) : (
|
|
<ContextualChatProvider>
|
|
{/* MainArea wraps EVERYTHING (header + content) so the contextual
|
|
sidebar, when open, spans the full SidebarInset height. The
|
|
left ResizablePanel contains the header + scrollable body;
|
|
the right panel is the sidebar.
|
|
The inner overflow-hidden div scopes sticky elements (e.g.
|
|
ProjectTabBar) below the header without sliding behind it. */}
|
|
<MainArea>
|
|
<div className="flex flex-col h-full min-w-0">
|
|
{showHeader && (
|
|
<header className="flex h-14 shrink-0 items-center gap-2">
|
|
<div className="flex flex-1 items-center gap-2 px-3">
|
|
<SidebarTrigger />
|
|
<Separator orientation="vertical" className={`data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:h-4${leftExtras ? '' : ' mr-2'}`} />
|
|
{leftExtras ?? (
|
|
<h4 className="text-sm font-medium text-foreground">{pageLabel}</h4>
|
|
)}
|
|
{headerExtras}
|
|
<div className="flex-1" />
|
|
{rightExtras}
|
|
<AdiuvaTriggerButton />
|
|
</div>
|
|
</header>
|
|
)}
|
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</MainArea>
|
|
</ContextualChatProvider>
|
|
)}
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
|
|
</LayoutGroup>
|
|
);
|
|
}
|
|
|
|
interface AppSidebarProps {
|
|
currentPath: string;
|
|
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
|
}
|
|
|
|
function AppSidebar({ currentPath, profile }: AppSidebarProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Sidebar collapsible="icon">
|
|
{/* Logo */}
|
|
<SidebarHeader>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton size="lg" asChild>
|
|
<div className="cursor-default">
|
|
<div className="size-7 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" width="18" height="18">
|
|
<path d="M32,4 L48,32 L16,32 Z" fill="#040404" opacity="0.85"/>
|
|
<path d="M16,32 L48,32 L32,60 Z" fill="#040404"/>
|
|
<line x1="16" y1="32" x2="48" y2="32" stroke="#040404" strokeWidth="0.5" opacity="0.12"/>
|
|
<circle cx="32" cy="32" r="2.5" fill="#040404" opacity="0.18"/>
|
|
</svg>
|
|
</div>
|
|
<span className="font-semibold text-sm text-foreground">
|
|
adiuv<span className="font-bold text-primary">AI</span>
|
|
</span>
|
|
</div>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
{/* Nav */}
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{NAV_ITEMS.map(({ to, icon: Icon, labelKey }) => {
|
|
const isActive =
|
|
to === '/'
|
|
? currentPath === '/'
|
|
: currentPath.startsWith(to);
|
|
const label = t(labelKey);
|
|
|
|
return (
|
|
<SidebarMenuItem key={to}>
|
|
<SidebarMenuButton
|
|
asChild
|
|
isActive={isActive}
|
|
tooltip={label}
|
|
>
|
|
<Link to={to}>
|
|
<Icon />
|
|
<span>{label}</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<NavProjects />
|
|
</SidebarContent>
|
|
|
|
{/* User avatar + dropdown */}
|
|
<SidebarFooter>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<NavUser profile={profile} currentPath={currentPath} />
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarFooter>
|
|
</Sidebar>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NavProjects — clients + projects tree in the sidebar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const NO_CLIENT_KEY = '__no_client__';
|
|
|
|
function NavProjects() {
|
|
const { state } = useSidebar();
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const routerState = useRouterState();
|
|
const currentPath = routerState.location.pathname;
|
|
const currentProjectId = useMemo(() => {
|
|
const params = new URLSearchParams(routerState.location.search);
|
|
return params.get('projectId') ?? undefined;
|
|
}, [routerState.location.search]);
|
|
|
|
const { expandedClients, toggleClient, expandClients } = useExpandedClients();
|
|
|
|
const { data: projectList = [] } = trpc.projects.list.useQuery({ includeArchived: false });
|
|
const { data: clientList = [] } = trpc.clients.list.useQuery();
|
|
|
|
const topLevelClients = useMemo(() => clientList.filter((c) => !c.parentId), [clientList]);
|
|
|
|
const subClientsByParent = useMemo(() => {
|
|
const m = new Map<string, typeof clientList>();
|
|
for (const c of clientList) {
|
|
if (c.parentId) {
|
|
const arr = m.get(c.parentId);
|
|
if (arr) arr.push(c);
|
|
else m.set(c.parentId, [c]);
|
|
}
|
|
}
|
|
return m;
|
|
}, [clientList]);
|
|
|
|
const projectsByClient = useMemo(() => {
|
|
const m = new Map<string, typeof projectList>();
|
|
for (const p of projectList) {
|
|
const key = p.clientId ?? NO_CLIENT_KEY;
|
|
const arr = m.get(key);
|
|
if (arr) arr.push(p);
|
|
else m.set(key, [p]);
|
|
}
|
|
return m;
|
|
}, [projectList]);
|
|
|
|
function handleSelectProject(projectId: string) {
|
|
void navigate({ to: '/projects', search: { projectId } });
|
|
}
|
|
|
|
if (state === 'collapsed') return null;
|
|
if (currentPath.startsWith('/projects')) return null;
|
|
if (projectList.length === 0 && clientList.length === 0) return null;
|
|
|
|
const isProjectsActive = currentPath.startsWith('/projects');
|
|
const unassignedProjects = projectsByClient.get(NO_CLIENT_KEY) ?? [];
|
|
|
|
return (
|
|
<>
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>{t('projects.projects')}</SidebarGroupLabel>
|
|
<SidebarMenu>
|
|
{topLevelClients.map((client) => {
|
|
const isExpanded = expandedClients.has(client.id);
|
|
const directProjects = projectsByClient.get(client.id) ?? [];
|
|
const subClients = subClientsByParent.get(client.id) ?? [];
|
|
const hasChildren = directProjects.length > 0 || subClients.length > 0;
|
|
|
|
return (
|
|
<Collapsible
|
|
key={client.id}
|
|
open={isExpanded}
|
|
onOpenChange={() => toggleClient(client.id)}
|
|
asChild
|
|
>
|
|
<SidebarMenuItem>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarMenuButton tooltip={client.name}>
|
|
<Folder />
|
|
<span>{client.name}</span>
|
|
{hasChildren && (
|
|
<ChevronRight
|
|
className={cn(
|
|
'ml-auto transition-transform duration-200',
|
|
isExpanded && 'rotate-90',
|
|
)}
|
|
/>
|
|
)}
|
|
</SidebarMenuButton>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<SidebarMenuSub>
|
|
{subClients.map((subClient) => {
|
|
const subIsExpanded = expandedClients.has(subClient.id);
|
|
const subProjects = projectsByClient.get(subClient.id) ?? [];
|
|
|
|
return (
|
|
<Collapsible
|
|
key={subClient.id}
|
|
open={subIsExpanded}
|
|
onOpenChange={() => toggleClient(subClient.id)}
|
|
asChild
|
|
>
|
|
<SidebarMenuSubItem>
|
|
<CollapsibleTrigger asChild>
|
|
<SidebarMenuSubButton>
|
|
<Folder />
|
|
<span>{subClient.name}</span>
|
|
{subProjects.length > 0 && (
|
|
<ChevronRight
|
|
className={cn(
|
|
'ml-auto size-3 transition-transform duration-200',
|
|
subIsExpanded && 'rotate-90',
|
|
)}
|
|
/>
|
|
)}
|
|
</SidebarMenuSubButton>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<SidebarMenuSub>
|
|
{subProjects.map((p) => (
|
|
<SidebarMenuSubItem key={p.id}>
|
|
<SidebarMenuSubButton
|
|
isActive={isProjectsActive && currentProjectId === p.id}
|
|
onClick={() => handleSelectProject(p.id)}
|
|
>
|
|
<span>{p.name}</span>
|
|
</SidebarMenuSubButton>
|
|
</SidebarMenuSubItem>
|
|
))}
|
|
</SidebarMenuSub>
|
|
</CollapsibleContent>
|
|
</SidebarMenuSubItem>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
{directProjects.map((p) => (
|
|
<SidebarMenuSubItem key={p.id}>
|
|
<SidebarMenuSubButton
|
|
isActive={isProjectsActive && currentProjectId === p.id}
|
|
onClick={() => handleSelectProject(p.id)}
|
|
>
|
|
<span>{p.name}</span>
|
|
</SidebarMenuSubButton>
|
|
</SidebarMenuSubItem>
|
|
))}
|
|
</SidebarMenuSub>
|
|
</CollapsibleContent>
|
|
</SidebarMenuItem>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
|
|
{unassignedProjects.map((p) => (
|
|
<SidebarMenuItem key={p.id}>
|
|
<SidebarMenuButton
|
|
isActive={isProjectsActive && currentProjectId === p.id}
|
|
onClick={() => handleSelectProject(p.id)}
|
|
tooltip={p.name}
|
|
>
|
|
<span>{p.name}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
</SidebarGroup>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NavUser — avatar with dropdown (inspired by shadcn sidebar-07)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function NavUser({
|
|
profile,
|
|
currentPath,
|
|
}: {
|
|
profile: { email: string; name?: string | null; surname?: string | null; tier: string; avatarUrl?: string | null } | null;
|
|
currentPath: string;
|
|
}) {
|
|
const { isMobile } = useSidebar();
|
|
const { theme, setTheme } = useTheme();
|
|
const { t } = useTranslation();
|
|
const logoutMutation = trpc.auth.logout.useMutation();
|
|
const { notify } = useNotify();
|
|
const utils = trpc.useUtils();
|
|
|
|
const email = profile?.email ?? 'User';
|
|
const displayName = [profile?.name, profile?.surname].filter(Boolean).join(' ') || email?.split('@')[0];
|
|
const initials = profile?.name && profile?.surname
|
|
? `${profile.name[0]}${profile.surname[0]}`.toUpperCase()
|
|
: (email?.split('@')[0] ?? 'US').slice(0, 2).toUpperCase();
|
|
|
|
function handleLogout() {
|
|
logoutMutation.mutate(undefined, {
|
|
onSuccess: () => {
|
|
notify('info', 'toast.auth.loggedOut');
|
|
void utils.auth.status.invalidate();
|
|
},
|
|
});
|
|
}
|
|
|
|
const themeOptions = [
|
|
{ value: 'light' as const, label: 'Light', icon: Sun },
|
|
{ value: 'dark' as const, label: 'Dark', icon: Moon },
|
|
{ value: 'system' as const, label: 'System', icon: Monitor },
|
|
];
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton
|
|
size="lg"
|
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
>
|
|
<Avatar className="size-8 rounded-lg">
|
|
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
|
<AvatarFallback className="rounded-lg text-xs">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
<span className="truncate font-medium">{displayName}</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{email}
|
|
</span>
|
|
</div>
|
|
<ChevronsUpDown className="ml-auto size-4" />
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
|
side={isMobile ? 'bottom' : 'right'}
|
|
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">
|
|
<Avatar className="size-8 rounded-lg">
|
|
{profile?.avatarUrl && <AvatarImage src={profile.avatarUrl} alt={displayName} />}
|
|
<AvatarFallback className="rounded-lg text-xs">
|
|
{initials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
<span className="truncate font-medium">
|
|
{displayName}
|
|
</span>
|
|
<span className="truncate text-xs text-muted-foreground">
|
|
{email}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/settings">
|
|
<Settings className="mr-2 size-4" />
|
|
{t('nav.settings')}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
{theme === 'dark' ? (
|
|
<Moon className="mr-2 size-4" />
|
|
) : theme === 'light' ? (
|
|
<Sun className="mr-2 size-4" />
|
|
) : (
|
|
<Monitor className="mr-2 size-4" />
|
|
)}
|
|
Theme
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuPortal>
|
|
<DropdownMenuSubContent>
|
|
{themeOptions.map(({ value, label, icon: Icon }) => (
|
|
<DropdownMenuItem
|
|
key={value}
|
|
onClick={() => setTheme(value)}
|
|
>
|
|
<Icon className="mr-2 size-4" />
|
|
{label}
|
|
{theme === value && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
Active
|
|
</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuPortal>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={handleLogout} disabled={logoutMutation.isPending}>
|
|
<LogOut className="mr-2 size-4" />
|
|
{t('settings.signOut')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|