317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link, useRouterState } from '@tanstack/react-router';
|
|
import { LayoutGroup } from 'framer-motion';
|
|
import {
|
|
House,
|
|
ChartGantt,
|
|
ClipboardCheck,
|
|
FolderKanban,
|
|
PanelLeft,
|
|
Settings,
|
|
Sparkles,
|
|
Check,
|
|
Sun,
|
|
Moon,
|
|
Monitor,
|
|
Palette
|
|
} from 'lucide-react';
|
|
import { trpc } from '@/lib/trpc';
|
|
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarHeader,
|
|
SidebarInset,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
useSidebar,
|
|
} from '@/components/ui/sidebar';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuPortal,
|
|
DropdownMenuSub,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSubContent,
|
|
DropdownMenuSubTrigger
|
|
} from '@/components/ui/dropdown-menu';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
|
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
|
import { useTheme } from '@/components/theme-provider';
|
|
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
|
|
|
const NAV_ITEMS = [
|
|
{ to: '/', icon: House, label: 'Home' },
|
|
{ to: '/timeline', icon: ChartGantt, label: 'Timeline' },
|
|
{ to: '/tasks', icon: ClipboardCheck, label: 'Tasks' },
|
|
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
|
] as const;
|
|
|
|
interface AppShellProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export function AppShell({ children }: AppShellProps) {
|
|
return (
|
|
<FloatingChatProvider>
|
|
<AppShellInner>{children}</AppShellInner>
|
|
</FloatingChatProvider>
|
|
);
|
|
}
|
|
|
|
function AppShellInner({ children }: AppShellProps) {
|
|
useDoubleClickAI();
|
|
|
|
const collapsedQuery = trpc.settings.getSidebarCollapsed.useQuery(undefined, {
|
|
staleTime: Infinity,
|
|
});
|
|
const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation();
|
|
|
|
const routerState = useRouterState();
|
|
const currentPath = routerState.location.pathname;
|
|
|
|
// Controlled open state (spec: "Controlled Sidebar" pattern)
|
|
// Default to collapsed (false) until the persisted preference loads
|
|
const [open, setOpen] = useState(() =>
|
|
collapsedQuery.data === undefined ? false : !collapsedQuery.data
|
|
);
|
|
|
|
const handleOpenChange = (value: boolean) => {
|
|
setOpen(value);
|
|
setSidebarCollapsedMutation.mutate({ collapsed: !value });
|
|
};
|
|
|
|
// AI token dialog state (shared between sidebar gear menu and AIChatPanel prompt)
|
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
|
const [tokenInput, setTokenInput] = useState('');
|
|
const [saved, setSaved] = useState(false);
|
|
const hasTokenQuery = trpc.ai.hasToken.useQuery();
|
|
const utils = trpc.useUtils();
|
|
const setTokenMutation = trpc.ai.setToken.useMutation({
|
|
onSuccess: () => {
|
|
setSaved(true);
|
|
setTokenInput('');
|
|
void utils.ai.hasToken.invalidate();
|
|
setTimeout(() => setSaved(false), 2000);
|
|
},
|
|
});
|
|
|
|
const isHomePage = currentPath === '/';
|
|
|
|
return (
|
|
<LayoutGroup>
|
|
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
|
|
<AppSidebar
|
|
currentPath={currentPath}
|
|
setTokenDialogOpen={setTokenDialogOpen}
|
|
/>
|
|
<SidebarInset>
|
|
{isHomePage ? (
|
|
<AIChatPanel
|
|
onOpenSettings={() => setTokenDialogOpen(true)}
|
|
isHomePage
|
|
/>
|
|
) : (
|
|
<div className="relative flex flex-col h-full">
|
|
{children}
|
|
</div>
|
|
)}
|
|
</SidebarInset>
|
|
</SidebarProvider>
|
|
|
|
{/* Floating AI Chat — portal to document.body */}
|
|
<FloatingChatPortal />
|
|
|
|
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
|
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
|
setTokenDialogOpen(open);
|
|
if (!open) { setTokenInput(''); setSaved(false); }
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>AI Provider</DialogTitle>
|
|
<DialogDescription>
|
|
Configure your AI provider credentials for chat, summaries, and suggestions.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">GitHub Copilot Token</label>
|
|
<Input
|
|
type="password"
|
|
placeholder="Paste your token here"
|
|
value={tokenInput}
|
|
onChange={(e) => setTokenInput(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Your token is stored securely in the OS keychain.
|
|
{hasTokenQuery.data === true && (
|
|
<span className="text-green-600 ml-1">A token is currently stored.</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
{saved && (
|
|
<span className="flex items-center gap-1 text-sm text-green-600 mr-auto">
|
|
<Check size={14} />
|
|
Saved
|
|
</span>
|
|
)}
|
|
<Button
|
|
disabled={!tokenInput.trim() || setTokenMutation.isPending}
|
|
onClick={() => setTokenMutation.mutate({ token: tokenInput.trim() })}
|
|
>
|
|
{setTokenMutation.isPending ? 'Saving...' : 'Save Token'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</LayoutGroup>
|
|
);
|
|
}
|
|
|
|
interface AppSidebarProps {
|
|
currentPath: string;
|
|
setTokenDialogOpen: (open: boolean) => void;
|
|
}
|
|
|
|
function AppSidebar({ currentPath, setTokenDialogOpen }: AppSidebarProps) {
|
|
const { toggleSidebar } = useSidebar();
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
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
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
className="text-primary-foreground"
|
|
>
|
|
<path
|
|
d="M12 2L13.5 8.5L20 10L13.5 11.5L12 18L10.5 11.5L4 10L10.5 8.5L12 2Z"
|
|
fill="currentColor"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="font-semibold text-sm text-foreground">
|
|
Adiuva
|
|
</span>
|
|
</div>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
{/* Nav */}
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu>
|
|
{NAV_ITEMS.map(({ to, icon: Icon, label }) => {
|
|
const isActive =
|
|
to === '/'
|
|
? currentPath === '/'
|
|
: currentPath.startsWith(to);
|
|
|
|
return (
|
|
<SidebarMenuItem key={to}>
|
|
<SidebarMenuButton
|
|
asChild
|
|
isActive={isActive}
|
|
tooltip={label}
|
|
>
|
|
<Link to={to}>
|
|
<Icon />
|
|
<span>{label}</span>
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
|
|
{/* Settings gear + Collapse toggle */}
|
|
<SidebarFooter>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton tooltip="Settings">
|
|
<Settings />
|
|
<span>Settings</span>
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="right" align="end" className="w-56">
|
|
<DropdownMenuItem onSelect={() => setTokenDialogOpen(true)}>
|
|
<Sparkles className="mr-2 size-4" />
|
|
AI Provider
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<Palette className="mr-2 size-4" />
|
|
<span>Theme</span>
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuPortal>
|
|
<DropdownMenuSubContent>
|
|
<DropdownMenuItem onSelect={() => setTheme('light')}>
|
|
<Sun className="mr-2 size-4" />
|
|
Light
|
|
{theme === 'light' && <Check className="ml-auto size-4" />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setTheme('dark')}>
|
|
<Moon className="mr-2 size-4" />
|
|
Dark
|
|
{theme === 'dark' && <Check className="ml-auto size-4" />}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setTheme('system')}>
|
|
<Monitor className="mr-2 size-4" />
|
|
System
|
|
{theme === 'system' && <Check className="ml-auto size-4" />}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuPortal>
|
|
</DropdownMenuSub>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
|
|
<PanelLeft />
|
|
<span>Collapse</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarFooter>
|
|
|
|
</Sidebar>
|
|
);
|
|
}
|