feat: add Input, Separator, Sheet, and Sidebar components

- Implemented Input component for user input fields.
- Created Separator component for visual separation in UI.
- Added Sheet component for modal-like overlays with customizable content.
- Developed Sidebar component with collapsible functionality and mobile responsiveness.
- Introduced Skeleton component for loading placeholders.
- Implemented Tooltip component for contextual hints.
- Updated global CSS variables for sidebar theming.
- Added useIsMobile hook for responsive design handling.
- Modified projects route to include ProjectSidebar.
- Enhanced Tailwind CSS configuration for improved styling.
- Updated Vite preload configuration for custom entry file naming.
This commit is contained in:
Roberto Musso
2026-02-19 18:44:13 +01:00
parent 30fde857f4
commit 1206a73db8
22 changed files with 3325 additions and 245 deletions

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/renderer/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

1185
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,14 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/geist": "^5.2.8", "@fontsource/geist": "^5.2.8",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.161.1", "@tanstack/react-router": "^1.161.1",
"@trpc/client": "^11.10.0", "@trpc/client": "^11.10.0",

101
prd.json
View File

@@ -150,12 +150,13 @@
"title": "Client CRUD UI in Projects sidebar", "title": "Client CRUD UI in Projects sidebar",
"description": "As a user, I want to create, rename, and delete Clients and Sub-Clients from the Projects sidebar so that I can mirror real-world corporate structures.", "description": "As a user, I want to create, rename, and delete Clients and Sub-Clients from the Projects sidebar so that I can mirror real-world corporate structures.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"'New Client' button at top of Projects sidebar creates a top-level client via clients.create tRPC mutation", "'New Client' button at top of Projects sidebar uses shadcn/ui Button component; creates a top-level client via clients.create tRPC mutation",
"Each client item has a context menu (right-click or kebab icon) with: Rename, New Sub-Client, Delete", "Each client item has a context menu using shadcn/ui DropdownMenu (triggered by right-click or kebab icon) with items: Rename, New Sub-Client, Delete",
"Rename activates an inline editable field replacing the label; pressing Enter or blurring saves via clients.update", "Rename activates an inline editable field (shadcn/ui Input) replacing the label; pressing Enter or blurring saves via clients.update",
"'New Sub-Client' calls clients.create with parentId set to the selected client's id", "'New Sub-Client' calls clients.create with parentId set to the selected client's id",
"Delete shows a confirmation dialog; if the client has children or projects, warns the user and offers cascade-delete option", "Delete shows a shadcn/ui AlertDialog confirmation; if the client has children or projects, warns the user and offers cascade-delete option",
"Tree updates immediately after any mutation without full page reload", "Tree updates immediately after any mutation without full page reload",
"All interactive elements use shadcn/ui primitives: install via 'npx shadcn@latest add button input dropdown-menu alert-dialog' before implementing",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -168,15 +169,16 @@
"title": "Projects sidebar tree view and project CRUD UI", "title": "Projects sidebar tree view and project CRUD UI",
"description": "As a user, I want to see all clients, sub-clients, and projects in a collapsible tree and manage projects from the Projects section.", "description": "As a user, I want to see all clients, sub-clients, and projects in a collapsible tree and manage projects from the Projects section.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Tree renders hierarchy: Client (folder icon, bold) → Sub-Client (folder icon) → Project (circle icon)", "Tree renders hierarchy: Client (folder icon, bold) → Sub-Client (folder icon) → Project (circle icon); use shadcn/ui Collapsible for expand/collapse nodes",
"Clients and sub-clients have expand/collapse chevrons that work independently", "Clients and sub-clients have expand/collapse chevrons that work independently",
"Search input at the top of the Projects sidebar filters the tree in real-time by name (client, sub-client, or project)", "Search input at the top of the Projects sidebar uses shadcn/ui Input (via SidebarInput from the sidebar component); filters the tree in real-time by name",
"'+ New Project' button opens a dialog with: name input field + optional client dropdown (searchable, lists all clients)", "'+ New Project' shadcn/ui Button opens a shadcn/ui Dialog with: shadcn/ui Input for name + shadcn/ui Select (searchable) for optional client",
"Projects with no client appear under 'Internal / No Client' group", "Projects with no client appear under 'Internal / No Client' group",
"Project context menu has: Edit (re-parent to different client), Archive/Unarchive, Delete", "Project context menu uses shadcn/ui DropdownMenu with items: Edit (re-parent to different client), Archive/Unarchive, Delete",
"Archived projects hidden by default; a 'Show archived' toggle reveals them", "Archived projects hidden by default; a shadcn/ui Switch or toggle reveals them",
"Clicking a project node loads the Project Detail panel in the right pane", "Clicking a project node loads the Project Detail panel in the right pane",
"Active project highlighted in tree", "Active project highlighted in tree",
"Install shadcn/ui components via 'npx shadcn@latest add collapsible dialog select switch' before implementing",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -189,14 +191,15 @@
"title": "Global Tasks view UI", "title": "Global Tasks view UI",
"description": "As a user, I want a global task list where I can create, filter, search, and update tasks across all projects in one place.", "description": "As a user, I want a global task list where I can create, filter, search, and update tasks across all projects in one place.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"4 stat cards at top: Total Tasks, To Do, In Progress, Completed — each with a Lucide icon and count, reactively updated via tasks.list queries", "4 stat cards using shadcn/ui Card (Card, CardHeader, CardTitle, CardContent) at top: Total Tasks, To Do, In Progress, Completed — each with a Lucide icon and count, reactively updated via tasks.list queries",
"Search input filters tasks by title or description (case-insensitive, 300ms debounce)", "Search uses shadcn/ui Input; filters tasks by title or description (case-insensitive, 300ms debounce)",
"Status filter tabs: All | To Do | In Progress | Completed — active tab highlighted", "Status filter uses shadcn/ui Tabs (Tabs, TabsList, TabsTrigger): All | To Do | In Progress | Completed",
"'Order by' dropdown: Due Date | Priority | Created Date", "'Order by' uses shadcn/ui DropdownMenu: Due Date | Priority | Created Date",
"Task rows display: checkbox, title (bold 14px), description (muted 14px), priority chip (HIGH=red bg+up-arrow, MEDIUM=gray bg+right-arrow, LOW=green bg+down-arrow), due date chip (calendar icon + 'Due Mon DD'), breadcrumb (Client > Sub-Client > Project, chevron-separated), assignee (person icon + name)", "Task rows display: shadcn/ui Checkbox, title (bold 14px), description (muted 14px), priority chip using shadcn/ui Badge (HIGH=destructive variant, MEDIUM=secondary variant, LOW=outline variant with green), due date chip (calendar icon + 'Due Mon DD'), breadcrumb (Client > Sub-Client > Project, chevron-separated via shadcn/ui Breadcrumb if available), assignee (person icon + name)",
"Completed task rows have green-tinted background (#f0fdf4 or similar)", "Completed task rows have green-tinted background (#f0fdf4 or similar)",
"Clicking a checkbox calls tasks.update to set status='done' (or back to 'todo') immediately", "Clicking the shadcn/ui Checkbox calls tasks.update to set status='done' (or back to 'todo') immediately",
"'New Task' button opens a creation modal: title (required), description, priority (select), due date (date picker), project (optional searchable dropdown), assignee (optional text)", "'New Task' shadcn/ui Button opens a shadcn/ui Dialog modal: shadcn/ui Input for title (required), Textarea for description, Select for priority, Popover+Calendar for due date, Select for project (optional searchable), Input for assignee (optional)",
"Install shadcn/ui components via 'npx shadcn@latest add card tabs checkbox badge dialog textarea select popover calendar' before implementing",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -214,9 +217,10 @@
"Dot fill: dark (#171717) = isApproved=1 + status todo, green (#16a34a) = done/approved, dashed outline = isApproved=0 (pending AI suggestion)", "Dot fill: dark (#171717) = isApproved=1 + status todo, green (#16a34a) = done/approved, dashed outline = isApproved=0 (pending AI suggestion)",
"A vertical 'Today' marker line rendered at the current date", "A vertical 'Today' marker line rendered at the current date",
"Component uses ResizeObserver for responsive SVG width", "Component uses ResizeObserver for responsive SVG width",
"Clicking a checkpoint dot opens a Popover with: title, formatted date, and a Delete button (calls checkpoints.delete)", "Clicking a checkpoint dot opens a shadcn/ui Popover with: title, formatted date, and a shadcn/ui Button (variant=destructive, size=sm) for Delete (calls checkpoints.delete)",
"Global Timeline route (/timeline) renders GanttChart with all checkpoints from all projects, color-coded or grouped by project", "Global Timeline route (/timeline) renders GanttChart with all checkpoints from all projects, color-coded or grouped by project",
"'+ Add' button opens a dialog: title (required), date picker (required), project dropdown (required in global view)", "'+ Add' shadcn/ui Button opens a shadcn/ui Dialog: shadcn/ui Input for title (required), Popover+Calendar for date picker (required), Select for project dropdown (required in global view)",
"Install shadcn/ui components via 'npx shadcn@latest add popover calendar' before implementing (button, dialog, input, select already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -230,11 +234,12 @@
"description": "As a user, I want a project detail panel showing breadcrumb navigation, project name, stat cards, and an AI summary card.", "description": "As a user, I want a project detail panel showing breadcrumb navigation, project name, stat cards, and an AI summary card.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Right panel renders when a project is selected in the Projects tree", "Right panel renders when a project is selected in the Projects tree",
"Breadcrumb at top shows Client > Sub-Client path (chevron-separated) using client data joined from the project", "Breadcrumb at top uses shadcn/ui Breadcrumb (Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbSeparator) showing Client > Sub-Client path",
"Project name renders as H1 below the breadcrumb", "Project name renders as H1 below the breadcrumb",
"3 stat cards displayed horizontally: Notes (count from notes.list), Tasks Complete (done/total fraction from tasks.list), Checkpoints (approved/total fraction from checkpoints.list)", "3 stat cards using shadcn/ui Card displayed horizontally: Notes (count from notes.list), Tasks Complete (done/total fraction from tasks.list), Checkpoints (approved/total fraction from checkpoints.list)",
"AI Project Summary card shows: sparkle (sparkles) Lucide icon + placeholder text 'AI summary will appear here' when project.aiSummary is null/empty", "AI Project Summary card uses shadcn/ui Card with sparkle (sparkles) Lucide icon + placeholder text 'AI summary will appear here' when project.aiSummary is null/empty",
"When project.aiSummary is populated, the card displays the AI-generated text instead", "When project.aiSummary is populated, the card displays the AI-generated text instead",
"Install shadcn/ui components via 'npx shadcn@latest add breadcrumb' before implementing (card already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -248,10 +253,11 @@
"description": "As a user, I want a Kanban board inside the project detail view with drag-and-drop task management between status columns.", "description": "As a user, I want a Kanban board inside the project detail view with drag-and-drop task management between status columns.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed", "@hello-pangea/dnd installed; DragDropContext wraps 3 Droppable columns: To Do | In Progress | Completed",
"Each task card is a Draggable rendering: title, description (truncated), priority chip, due date, assignee string", "Each task card is a Draggable wrapped in a shadcn/ui Card rendering: title, description (truncated), priority as shadcn/ui Badge, due date chip, assignee string",
"Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)", "Dragging a card to another column calls tasks.update({ id, status }) via tRPC and the UI updates immediately (optimistic or on success)",
"'+ Add' button in each column header opens the new task modal with the column's status pre-selected", "'+ Add' shadcn/ui Button (variant=ghost, size=sm) in each column header opens the shadcn/ui Dialog new-task modal with the column's status pre-selected",
"Columns show a task count in their header", "Columns show a task count in their header using shadcn/ui Badge (variant=secondary)",
"All card content uses shadcn/ui primitives: Card, Badge, Button (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -265,10 +271,11 @@
"description": "As a user, I want to see the project's Gantt timeline and a list of its notes within the project detail scrollable view.", "description": "As a user, I want to see the project's Gantt timeline and a list of its notes within the project detail scrollable view.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints", "Project Detail view includes a 'Project Timeline' section using the GanttChart component (from US-012) scoped to the current project's checkpoints",
"'+ Add' button in the timeline section header opens the add-checkpoint dialog with the project pre-selected", "'+ Add' shadcn/ui Button (variant=outline, size=sm) in the timeline section header opens the add-checkpoint shadcn/ui Dialog with the project pre-selected",
"Notes section below Kanban shows a flat list: each row has note title + formatted createdAt date", "Notes section below Kanban shows a flat list using shadcn/ui Separator between rows: each row has note title + formatted createdAt date",
"'+ Add' button in notes header calls notes.create with a default title and navigates to /notes/:noteId", "'+ Add' shadcn/ui Button in notes header calls notes.create with a default title and navigates to /notes/:noteId",
"Clicking a note title navigates to /notes/:noteId", "Clicking a note title navigates to /notes/:noteId",
"All buttons/dialogs use shadcn/ui components (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -283,10 +290,11 @@
"acceptanceCriteria": [ "acceptanceCriteria": [
"@milkdown/react and @milkdown/preset-commonmark installed; Milkdown editor renders at route /notes/:noteId", "@milkdown/react and @milkdown/preset-commonmark installed; Milkdown editor renders at route /notes/:noteId",
"Supported Markdown: headings (H1-H6), bold, italic, inline code, code blocks, bullet lists, ordered lists, blockquotes", "Supported Markdown: headings (H1-H6), bold, italic, inline code, code blocks, bullet lists, ordered lists, blockquotes",
"Note title editable as a plain text input at the top of the page (separate from Milkdown content area)", "Note title editable as a shadcn/ui Input (variant borderless/ghost style) at the top of the page (separate from Milkdown content area)",
"Content auto-saves to SQLite via notes.update on Milkdown onChange event, debounced 500ms", "Content auto-saves to SQLite via notes.update on Milkdown onChange event, debounced 500ms",
"Unsaved indicator shown while save is pending (e.g., a dot or 'Saving...' label next to the title)", "Unsaved indicator shown using shadcn/ui Badge (variant=secondary, text 'Saving...') next to the title while save is pending",
"Back button or keyboard shortcut navigates to the previous route (project detail or projects list)", "Back button uses shadcn/ui Button (variant=ghost, size=icon) with ArrowLeft Lucide icon; navigates to the previous route",
"All UI chrome uses shadcn/ui components (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -322,8 +330,8 @@
"ai.setToken tRPC mutation accepts { token: string } and stores it via keytar.setPassword('adiuva', 'copilot-token', token)", "ai.setToken tRPC mutation accepts { token: string } and stores it via keytar.setPassword('adiuva', 'copilot-token', token)",
"ai.hasToken tRPC query returns a boolean indicating whether a token is stored", "ai.hasToken tRPC query returns a boolean indicating whether a token is stored",
"On app start, main process reads the token from keychain and initializes the GitHub Copilot SDK client", "On app start, main process reads the token from keychain and initializes the GitHub Copilot SDK client",
"A minimal Settings dialog (accessible from the sidebar bottom or a gear icon) allows the user to paste and save the token via ai.setToken", "Settings dialog uses shadcn/ui Dialog (DialogTrigger as a SidebarMenuButton with Settings/gear icon in the sidebar footer); dialog content uses shadcn/ui Input for token paste + shadcn/ui Button to save via ai.setToken",
"If no token is stored, AI-dependent features display a 'Configure API token in Settings' prompt instead of throwing an error", "If no token is stored, AI-dependent features display a prompt using shadcn/ui Card with a shadcn/ui Button linking to the Settings dialog instead of throwing an error",
"Typecheck passes" "Typecheck passes"
], ],
"priority": 18, "priority": 18,
@@ -352,13 +360,14 @@
"title": "Context-scoped AI chat UI", "title": "Context-scoped AI chat UI",
"description": "As a user, I want the AI chat (revealed by the Fluid Curtain) to display a context header, support message input, and stream AI responses.", "description": "As a user, I want the AI chat (revealed by the Fluid Curtain) to display a context header, support message input, and stream AI responses.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Chat panel shows a context header: 'Chatting about: [Project Name]' when opened from a project detail view, or 'Global workspace' when opened from other sections", "Chat panel shows a context header using shadcn/ui Badge (variant=outline): 'Chatting about: [Project Name]' when opened from a project detail view, or 'Global workspace' when opened from other sections",
"Chat input box: white background, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...', Send button (black bg, icon + 'Send' label) anchored bottom-right", "Chat input box uses shadcn/ui Textarea: white background, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg, Send Lucide icon + 'Send' label) anchored bottom-right",
"User messages appear as right-aligned message bubbles; AI responses as left-aligned bubbles", "User messages appear as right-aligned message bubbles using shadcn/ui Card; AI responses as left-aligned Cards",
"Streaming: AI response tokens appended to the current AI bubble as they arrive from ai.chat", "Streaming: AI response tokens appended to the current AI bubble as they arrive from ai.chat",
"A loading spinner or pulsing indicator shown while waiting for first token", "A loading spinner or pulsing indicator (shadcn/ui Skeleton) shown while waiting for first token",
"If ai.chat returns { error }, display the error message in a red-tinted bubble", "If ai.chat returns { error }, display the error message in a shadcn/ui Card with destructive border styling",
"Chat history is session-only — cleared when the curtain closes or the app restarts", "Chat history is session-only — cleared when the curtain closes or the app restarts",
"Install shadcn/ui components via 'npx shadcn@latest add textarea' before implementing (card, badge, button, skeleton already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -417,11 +426,12 @@
"title": "AI checkpoint suggestions UI", "title": "AI checkpoint suggestions UI",
"description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.", "description": "As a user, I want the AI to suggest timeline checkpoints from my meeting notes, which I can approve or reject directly in the timeline.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"'Suggest checkpoints' button in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project", "'Suggest checkpoints' shadcn/ui Button (variant=outline, sparkles Lucide icon) in the Project Detail timeline header calls ai.chat with a suggest_checkpoints intent for the current project",
"Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0", "Suggested checkpoints returned by @ProjectAgent are inserted into the checkpoints table via checkpoints.create with isAiSuggested=1, isApproved=0",
"Pending suggestions appear as dismissible cards above or below the GanttChart in the Project Detail timeline section (visually distinct: dashed border or muted style)", "Pending suggestions appear as shadcn/ui Card components (with dashed border via className 'border-dashed') above or below the GanttChart in the Project Detail timeline section",
"'Approve' button on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt", "'Approve' shadcn/ui Button (variant=default, size=sm) on each card calls checkpoints.update({ id, isApproved: 1 }); the checkpoint then appears as a normal dot on the Gantt",
"'Reject' button calls checkpoints.delete({ id }) and removes the card", "'Reject' shadcn/ui Button (variant=ghost, size=sm) calls checkpoints.delete({ id }) and removes the card",
"All UI uses shadcn/ui components (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
@@ -435,12 +445,13 @@
"description": "As a user, I want the Home screen to greet me with an AI-generated daily brief and pre-populated suggestion chips for quick queries.", "description": "As a user, I want the Home screen to greet me with an AI-generated daily brief and pre-populated suggestion chips for quick queries.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"Greeting rendered as '✦ Hello, {name}' in Geist Semibold 30px with -1px letter-spacing; name sourced from electron-store (defaults to 'there' if not set)", "Greeting rendered as '✦ Hello, {name}' in Geist Semibold 30px with -1px letter-spacing; name sourced from electron-store (defaults to 'there' if not set)",
"Top-right corner stat chip shows 'N Task due' where N = count of tasks with dueDate on or before end of today", "Top-right corner stat chip uses shadcn/ui Badge (variant=secondary) showing 'N Task due' where N = count of tasks with dueDate on or before end of today",
"On app open, ai.chat called with global context to generate a daily brief paragraph highlighting tasks due today/this week and recent project activity", "On app open, ai.chat called with global context to generate a daily brief paragraph highlighting tasks due today/this week and recent project activity",
"Brief displayed below greeting; bold key phrases rendered as <strong> (model wraps them in **markdown bold**)", "Brief displayed below greeting in a shadcn/ui Card; bold key phrases rendered as <strong> (model wraps them in **markdown bold**)",
"4 suggestion chips rendered in a 4-column flex row below the chat box; chips are pre-populated from the AI response (model returns a JSON array of { icon: string, label: string } suggestion prompts)", "4 suggestion chips rendered in a 4-column flex row below the chat box using shadcn/ui Button (variant=outline); each chip has a Lucide icon + short prompt text",
"Clicking a suggestion chip populates the chat input with the chip's prompt text", "Clicking a suggestion chip populates the chat input with the chip's prompt text",
"Chat box: white bg, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...', Send button (black) bottom-right", "Chat box uses shadcn/ui Textarea: white bg, border #d4d4d4, shadow-lg, min-height 109px, placeholder 'Ask me anything...'; Send uses shadcn/ui Button (black bg) bottom-right",
"All UI uses shadcn/ui components (already installed)",
"Typecheck passes", "Typecheck passes",
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],

View File

@@ -8,8 +8,21 @@ import {
PanelLeft, PanelLeft,
ChevronDown, ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
useSidebar,
} from '@/components/ui/sidebar';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' }, { to: '/', icon: House, label: 'Home' },
@@ -28,101 +41,23 @@ export function AppShell({ children }: AppShellProps) {
}); });
const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation(); const setSidebarCollapsedMutation = trpc.settings.setSidebarCollapsed.useMutation();
// localCollapsed tracks user toggles after load; null means "use server value"
const [localCollapsed, setLocalCollapsed] = useState<boolean | null>(null);
const routerState = useRouterState(); const routerState = useRouterState();
const currentPath = routerState.location.pathname; const currentPath = routerState.location.pathname;
const collapsed = localCollapsed !== null ? localCollapsed : (collapsedQuery.data ?? false); // Controlled open state (spec: "Controlled Sidebar" pattern)
const [open, setOpen] = useState(() =>
collapsedQuery.data === undefined ? true : !collapsedQuery.data
);
const handleToggle = () => { const handleOpenChange = (value: boolean) => {
const next = !collapsed; setOpen(value);
setLocalCollapsed(next); setSidebarCollapsedMutation.mutate({ collapsed: !value });
setSidebarCollapsedMutation.mutate({ collapsed: next });
}; };
return ( return (
<div className="flex h-screen w-screen overflow-hidden bg-background"> <SidebarProvider open={open} onOpenChange={handleOpenChange}>
{/* Sidebar */} <AppSidebar currentPath={currentPath} />
<aside <SidebarInset className="overflow-hidden">
className={cn(
'flex flex-col h-full bg-sidebar border-r border-sidebar-border transition-all duration-200 overflow-hidden shrink-0',
collapsed ? 'w-16' : 'w-60',
)}
>
{/* Logo */}
<div
className={cn(
'flex items-center gap-3 px-3 py-3 shrink-0',
collapsed && 'justify-center',
)}
>
<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>
{!collapsed && (
<span className="font-semibold text-sm text-foreground">
Adiuva
</span>
)}
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 px-2 flex-1 mt-2">
{NAV_ITEMS.map(({ to, icon: Icon, label }) => {
const isActive =
to === '/'
? currentPath === '/'
: currentPath.startsWith(to);
return (
<Link
key={to}
to={to}
className={cn(
'flex items-center gap-2 h-8 px-3 rounded-md text-sm text-sidebar-foreground transition-colors',
'hover:bg-sidebar-accent',
isActive && 'bg-sidebar-accent font-medium',
)}
>
<Icon size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{label}</span>}
</Link>
);
})}
</nav>
{/* Collapse toggle */}
<div className="px-2 pb-3 shrink-0">
<button
onClick={handleToggle}
className={cn(
'flex items-center gap-2 h-8 px-3 rounded-md text-sm text-sidebar-foreground w-full',
'hover:bg-sidebar-accent transition-colors',
collapsed && 'justify-center',
)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<PanelLeft size={16} className="shrink-0" />
{!collapsed && <span>Collapse</span>}
</button>
</div>
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 overflow-hidden relative">
{children} {children}
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */} {/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
@@ -137,7 +72,87 @@ export function AppShell({ children }: AppShellProps) {
<ChevronDown size={10} className="text-muted-foreground/30" /> <ChevronDown size={10} className="text-muted-foreground/30" />
</div> </div>
</div> </div>
</main> </SidebarInset>
</div> </SidebarProvider>
);
}
function AppSidebar({ currentPath }: { currentPath: string }) {
const { toggleSidebar } = useSidebar();
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>
{/* Collapse toggle — spec: useSidebar() + custom trigger */}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton onClick={toggleSidebar} tooltip="Toggle Sidebar">
<PanelLeft />
<span>Collapse</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
); );
} }

View File

@@ -0,0 +1,402 @@
import { useState, useCallback } from 'react';
import {
Folder,
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Edit2,
FolderPlus,
Trash2,
AlertTriangle,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty';
type ProjectFlat = {
id: string;
name: string;
parentId: string | null;
industry: string | null;
createdAt: number;
};
type ProjectNode = ProjectFlat & { children: ProjectNode[] };
function buildTree(projects: ProjectFlat[]): ProjectNode[] {
const map = new Map<string, ProjectNode>();
for (const c of projects) map.set(c.id, { ...c, children: [] });
const roots: ProjectNode[] = [];
for (const c of projects) {
const node = map.get(c.id)!;
if (c.parentId) {
const parent = map.get(c.parentId);
if (parent) parent.children.push(node);
else roots.push(node);
} else {
roots.push(node);
}
}
return roots;
}
type DeleteDialog = {
id: string;
name: string;
stage: 'confirm' | 'cascade-warn';
errorMessage?: string;
};
export function ProjectSidebar() {
const utils = trpc.useUtils();
const { data: projects = [] } = trpc.clients.list.useQuery();
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialog | null>(null);
// Callback ref: auto-focus + select when rename input mounts
const renameInputCallback = useCallback((el: HTMLInputElement | null) => {
if (el) {
el.focus();
el.select();
}
}, []);
const createMutation = trpc.clients.create.useMutation({
onSuccess: () => { void utils.clients.list.invalidate(); },
});
const updateMutation = trpc.clients.update.useMutation({
onSuccess: () => { void utils.clients.list.invalidate(); },
});
const deleteMutation = trpc.clients.delete.useMutation({
onSuccess: (result) => {
if ('error' in result) {
setDeleteDialog((prev) =>
prev ? { ...prev, stage: 'cascade-warn', errorMessage: result.error } : null,
);
} else {
setDeleteDialog(null);
void utils.clients.list.invalidate();
}
},
});
const cascadeDeleteMutation = trpc.clients.deleteWithCascade.useMutation({
onSuccess: () => {
setDeleteDialog(null);
void utils.clients.list.invalidate();
},
});
const tree = buildTree(projects as ProjectFlat[]);
function toggleExpanded(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function handleNewProject() {
createMutation.mutate(
{ name: 'New Project' },
{
onSuccess: (result) => {
setRenaming({ id: result.id, value: 'New Project' });
},
},
);
}
function handleNewSubProject(parentId: string) {
createMutation.mutate(
{ name: 'New Sub-Project', parentId },
{
onSuccess: (result) => {
setExpanded((prev) => new Set([...prev, parentId]));
setRenaming({ id: result.id, value: 'New Sub-Project' });
},
},
);
}
function handleRenameStart(id: string, name: string) {
setRenaming({ id, value: name });
}
function handleRenameSave() {
if (!renaming) return;
const trimmed = renaming.value.trim();
if (trimmed) {
updateMutation.mutate({ id: renaming.id, name: trimmed });
}
setRenaming(null);
}
function handleRenameKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleRenameSave();
if (e.key === 'Escape') setRenaming(null);
}
function handleDeleteClick(id: string, name: string) {
setDeleteDialog({ id, name, stage: 'confirm' });
}
function handleDeleteConfirm() {
if (!deleteDialog) return;
deleteMutation.mutate({ id: deleteDialog.id });
}
function handleCascadeDelete() {
if (!deleteDialog) return;
cascadeDeleteMutation.mutate({ id: deleteDialog.id });
}
function renderNode(node: ProjectNode, depth = 0) {
const isExpanded = expanded.has(node.id);
const isRenaming = renaming?.id === node.id;
const hasChildren = node.children.length > 0;
return (
<Collapsible
key={node.id}
open={isExpanded}
onOpenChange={() => hasChildren && toggleExpanded(node.id)}
>
<div
className="group relative flex items-center h-7 rounded-md text-sm cursor-default hover:bg-accent transition-colors"
style={{ paddingLeft: `${8 + depth * 16}px`, paddingRight: '4px' }}
>
{/* Expand/collapse chevron */}
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-4 p-0 hover:bg-transparent text-muted-foreground"
tabIndex={-1}
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />
) : (
<span className="size-4 inline-block" />
)}
</Button>
</CollapsibleTrigger>
<Folder className="size-3.5 shrink-0 text-muted-foreground mr-1.5" />
{isRenaming ? (
<Input
ref={renameInputCallback}
className="flex-1 min-w-0 h-5 text-sm px-1 py-0"
value={renaming.value}
onChange={(e) => setRenaming({ id: node.id, value: e.target.value })}
onBlur={handleRenameSave}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="flex-1 min-w-0 truncate text-foreground">{node.name}</span>
)}
{/* Kebab menu */}
{!isRenaming && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'size-5 p-0 ml-1 text-muted-foreground hover:bg-muted shrink-0',
'opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100',
)}
tabIndex={-1}
>
<MoreHorizontal className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[152px]">
<DropdownMenuItem onClick={() => handleRenameStart(node.id, node.name)}>
<Edit2 />
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleNewSubProject(node.id)}>
<FolderPlus />
New Sub-Project
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleDeleteClick(node.id, node.name)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Children */}
<CollapsibleContent>
{node.children.map((child) => renderNode(child, depth + 1))}
</CollapsibleContent>
</Collapsible>
);
}
return (
<div className="flex flex-col h-full border-r border-border w-60 shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 shrink-0">
<h4 className="text-lg font-semibold text-foreground">
Projects
</h4>
<Button
variant="outline"
size="icon"
onClick={handleNewProject}
disabled={createMutation.isPending}
aria-label="New Project"
>
<Plus />
</Button>
</div>
{/* Project tree */}
<div className="flex-1 overflow-y-auto py-1 px-1">
{tree.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Folder />
</EmptyMedia>
<EmptyTitle>No project yet</EmptyTitle>
<EmptyDescription>
Get started by adding your first project.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
variant="outline"
size="sm"
onClick={handleNewProject}
disabled={createMutation.isPending}
>
<Plus className="mr-1 h-4 w-4" />
Add Project
</Button>
</EmptyContent>
</Empty>
) : (
tree.map((node) => renderNode(node))
)}
</div>
{/* Delete confirmation — AlertDialog */}
<AlertDialog
open={!!deleteDialog}
onOpenChange={(open) => {
if (!open && !deleteMutation.isPending && !cascadeDeleteMutation.isPending) {
setDeleteDialog(null);
}
}}
>
<AlertDialogContent>
{deleteDialog?.stage === 'cascade-warn' ? (
<>
<AlertDialogHeader>
<div className="flex items-start gap-3">
<AlertTriangle className="size-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<AlertDialogTitle>Cannot delete safely</AlertDialogTitle>
<AlertDialogDescription className="mt-1">
{deleteDialog.errorMessage}
</AlertDialogDescription>
</div>
</div>
<AlertDialogDescription>
Force-delete &ldquo;{deleteDialog?.name}&rdquo; along with all its sub-projects?
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cascadeDeleteMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleCascadeDelete}
disabled={cascadeDeleteMutation.isPending}
>
{cascadeDeleteMutation.isPending ? 'Deleting\u2026' : 'Force Delete All'}
</AlertDialogAction>
</AlertDialogFooter>
</>
) : (
<>
<AlertDialogHeader>
<AlertDialogTitle>
Delete &ldquo;{deleteDialog?.name}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConfirm}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting\u2026' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</>
)}
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,771 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
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
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -23,9 +23,14 @@
/* Sidebar tokens — matching Figma exactly */ /* Sidebar tokens — matching Figma exactly */
--sidebar: 0 0% 98%; --sidebar: 0 0% 98%;
--sidebar-border: 0 0% 89.8%; --sidebar-border: 220 13% 91%;
--sidebar-accent: 0 0% 96.1%; --sidebar-accent: 240 4.8% 95.9%;
--sidebar-foreground: 0 0% 25.1%; --sidebar-foreground: 240 5.3% 26.1%;
--sidebar-background: 0 0% 98%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
* { * {
@@ -47,4 +52,14 @@
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
.dark {
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
} }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -1,4 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { ProjectSidebar } from '@/components/projects/ProjectSidebar';
export const Route = createFileRoute('/projects')({ export const Route = createFileRoute('/projects')({
component: ProjectsPage, component: ProjectsPage,
@@ -6,8 +7,11 @@ export const Route = createFileRoute('/projects')({
function ProjectsPage() { function ProjectsPage() {
return ( return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm"> <div className="flex h-full overflow-hidden">
Projects coming in US-006 <ProjectSidebar />
<div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">
Select a project to view details
</div>
</div> </div>
); );
} }

View File

@@ -1,10 +1,16 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ['class'],
content: ['./index.html', './src/renderer/**/*.{ts,tsx}'], content: ['./index.html', './src/renderer/**/*.{ts,tsx}'],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Geist', 'Inter', 'system-ui', 'sans-serif'], sans: [
'Geist',
'Inter',
'system-ui',
'sans-serif'
]
}, },
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
@@ -14,29 +20,33 @@ module.exports = {
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))', foreground: 'hsl(var(--primary-foreground))'
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))', foreground: 'hsl(var(--secondary-foreground))'
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))', foreground: 'hsl(var(--muted-foreground))'
}, },
sidebar: { sidebar: {
DEFAULT: 'hsl(var(--sidebar))', DEFAULT: 'hsl(var(--sidebar-background))',
border: 'hsl(var(--sidebar-border))', border: 'hsl(var(--sidebar-border))',
accent: 'hsl(var(--sidebar-accent))', accent: 'hsl(var(--sidebar-accent))',
foreground: 'hsl(var(--sidebar-foreground))', foreground: 'hsl(var(--sidebar-foreground))',
}, primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
ring: 'hsl(var(--sidebar-ring))'
}
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)', sm: 'calc(var(--radius) - 4px)'
}, }
}, }
}, },
plugins: [], plugins: [],
}; };

View File

@@ -1,4 +1,12 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
// https://vitejs.dev/config // https://vitejs.dev/config
export default defineConfig({}); export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'preload.js',
},
},
},
});