10 Commits

Author SHA1 Message Date
Roberto Musso
1206a73db8 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.
2026-02-19 18:44:13 +01:00
Roberto Musso
30fde857f4 chore: mark US-008 complete in prd.json and update progress log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:05:29 +01:00
Roberto Musso
939d503f3a feat: US-008 — Checkpoint and Note tRPC procedures (CRUD)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:05:06 +01:00
Roberto Musso
3d6459850c chore: mark US-007 complete in prd.json and update progress log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:02:41 +01:00
Roberto Musso
9cff6a4126 feat: US-007 — Task tRPC procedures (CRUD + filtering)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 17:02:09 +01:00
Roberto Musso
bdfaab85b6 chore: mark US-006 complete in prd.json and update progress log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:59:06 +01:00
Roberto Musso
fa1fd2b9f5 feat: US-006 — Project tRPC procedures (CRUD)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:58:46 +01:00
Roberto Musso
14e0a3aca6 chore: mark US-005 complete in prd.json and update progress log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:56:48 +01:00
Roberto Musso
1794ab0416 feat: US-005 — Client tRPC procedures (CRUD)
Implemented full CRUD for the clients router:
- clients.list: returns all clients ordered by name
- clients.create: inserts with UUID + createdAt timestamp
- clients.update: partial update of name and/or industry
- clients.delete: guard check — returns error payload if client has
  sub-clients or projects (does not delete)
- clients.deleteWithCascade: BFS recursion to collect all descendant
  clients, nulls projectId on orphaned tasks, then deletes projects
  and all collected clients in order

Imports added: eq, asc, inArray from drizzle-orm; getDb and schema
tables (clients, projects, tasks) from db module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:56:14 +01:00
Roberto Musso
7c063e5aab chore: mark US-004 complete in prd.json and update progress log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 16:51:18 +01:00
24 changed files with 3729 additions and 280 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",

121
prd.json
View File

@@ -69,8 +69,8 @@
"Verify in browser using dev-browser skill" "Verify in browser using dev-browser skill"
], ],
"priority": 4, "priority": 4,
"passes": false, "passes": true,
"notes": "" "notes": "Completed: electron-store@8 sidebar collapse persistence via settings tRPC router, @fontsource/geist replacing Google Fonts CDN, right-edge 'keep scrolling for AI' label in all views, ESLint fixed with eslint-import-resolver-typescript"
}, },
{ {
"id": "US-005", "id": "US-005",
@@ -86,8 +86,8 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 5, "priority": 5,
"passes": false, "passes": true,
"notes": "" "notes": "Completed: clients.list (ordered by name), clients.create (UUID + createdAt), clients.update (partial), clients.delete (guard returns error payload if children exist), clients.deleteWithCascade (BFS recursive — nulls orphaned tasks.projectId, deletes projects, then clients). All queries use .all()/.run() for drizzle better-sqlite3 sync driver."
}, },
{ {
"id": "US-006", "id": "US-006",
@@ -104,8 +104,8 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 6, "priority": 6,
"passes": false, "passes": true,
"notes": "" "notes": "Completed: projects.list (filters by clientId + includeArchived via drizzle and()), projects.listAll (id+name only), projects.get (returns null if not found), projects.create (UUID + status='active' + createdAt), projects.update (partial set object), projects.delete (nulls tasks.projectId then deletes project)"
}, },
{ {
"id": "US-007", "id": "US-007",
@@ -121,8 +121,8 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 7, "priority": 7,
"passes": false, "passes": true,
"notes": "" "notes": "Completed: tasks.list (LEFT JOIN projects+clients+parentClients for breadcrumb fields, and() filters for projectId/status/search via like(), orderBy CASE for priority), tasks.create (UUID + createdAt + defaults), tasks.update (partial set), tasks.delete. alias() from drizzle-orm/sqlite-core for self-join."
}, },
{ {
"id": "US-008", "id": "US-008",
@@ -142,20 +142,21 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 8, "priority": 8,
"passes": false, "passes": true,
"notes": "" "notes": "Completed: checkpoints.list (ordered by date, optional projectId filter), checkpoints.create (UUID + createdAt + defaults for isAiSuggested/isApproved), checkpoints.update (partial set), checkpoints.delete. notes.list (returns id/projectId/title/createdAt/updatedAt — no content), notes.get (full record or null), notes.create (UUID + createdAt + updatedAt), notes.update (partial set, always updates updatedAt), notes.delete."
}, },
{ {
"id": "US-009", "id": "US-009",
"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

@@ -1,14 +1,43 @@
## Codebase Patterns ## Codebase Patterns
- `alias(table, 'alias_name')` from `drizzle-orm/sqlite-core` enables self-joins (e.g., clients → parentClients for hierarchy)
- `sql<T>\`CASE WHEN ... THEN ... ELSE ... END\`` for conditional SELECT fields (e.g., clientName vs subClientName based on parentId)
- `or(like(col1, pattern), like(col2, pattern))` for multi-column search; SQLite LIKE on NULL columns safely returns NULL (falsy) so OR is safe
- Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflict with electron-forge's externalize-deps plugin - Vite configs use `.mts` extension (not `.ts`) to avoid ESM/CJS conflict with electron-forge's externalize-deps plugin
- electron-trpc uses `exposeElectronTRPC()` in preload and `createIPCHandler({ router, windows })` in main; renderer uses `ipcLink()` from `electron-trpc/renderer` - electron-trpc uses `exposeElectronTRPC()` in preload and `createIPCHandler({ router, windows })` in main; renderer uses `ipcLink()` from `electron-trpc/renderer`
- appRouter lives at `src/main/router/index.ts`; renderer client at `src/renderer/lib/trpc.ts` - appRouter lives at `src/main/router/index.ts`; renderer client at `src/renderer/lib/trpc.ts`
- `@/*` path alias maps to `src/renderer/*` (configured in tsconfig.json paths) - `@/*` path alias maps to `src/renderer/*` (configured in tsconfig.json paths)
- Drizzle ORM with better-sqlite3 (sync driver): SELECT queries MUST end with `.all()` to execute; INSERT/UPDATE/DELETE MUST end with `.run()`
- `inArray(column, values)` works with nullable columns when values is `string[]` (TypeScript covariance allows string[] → (string | null)[])
- All DB tables use `CREATE TABLE IF NOT EXISTS` for non-destructive migrations - All DB tables use `CREATE TABLE IF NOT EXISTS` for non-destructive migrations
- All IDs are UUIDs generated via `crypto.randomUUID()` - All IDs are UUIDs generated via `crypto.randomUUID()`
- TypeScript strict mode + noUncheckedIndexedAccess enabled; always account for possible undefined on array access - TypeScript strict mode + noUncheckedIndexedAccess enabled; always account for possible undefined on array access
- electron-store@8 (CJS) used for app settings; use lazy init pattern `getStore()` like `getDb()` to avoid calling before app ready
- ESLint uses `eslint-import-resolver-typescript` to resolve `@/*` aliases; configured in `.eslintrc.json` under `settings.import/resolver`
- App settings (sidebar state, etc.) exposed via `settings` tRPC sub-router for type-safe renderer access
--- ---
## 2026-02-19 - US-008
- What was implemented:
- Full `checkpointsRouter` replacing stubs in `src/main/router/index.ts`
- Full `notesRouter` replacing stubs in `src/main/router/index.ts`
- Added `checkpoints` and `notes` to the schema import
- `checkpoints.list`: optional `projectId` filter, ordered by `asc(checkpoints.date)`
- `checkpoints.create`: inserts with UUID, createdAt=Date.now(), defaults isAiSuggested/isApproved to 0
- `checkpoints.update`: partial set for title/date/isApproved
- `checkpoints.delete`: deletes by id, returns `{ success: true }`
- `notes.list`: returns `{ id, projectId, title, createdAt, updatedAt }` only — no content (performance)
- `notes.get`: returns full record or null via `.all()[0] ?? null` pattern
- `notes.create`: inserts with UUID, createdAt=updatedAt=Date.now()
- `notes.update`: partial set, always sets updatedAt=Date.now() regardless of which fields changed
- `notes.delete`: deletes by id, returns `{ success: true }`
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `notes.update` must always set `updatedAt` — build the set object with updatedAt outside the conditional block
- `notes.list` intentionally excludes `content` column for performance; use `notes.get` for full record
- `checkpoints.projectId` is `.notNull()` in schema (unlike tasks.projectId which is nullable) — no null coalescing needed
---
## 2026-02-19 - US-003 ## 2026-02-19 - US-003
- What was implemented: - What was implemented:
- Installed: electron-trpc, @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod - Installed: electron-trpc, @trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, zod
@@ -26,3 +55,73 @@
- The TRPCProvider must wrap QueryClientProvider (or be a sibling); both need the same queryClient instance - The TRPCProvider must wrap QueryClientProvider (or be a sibling); both need the same queryClient instance
- Stub routers return empty arrays or null — they will be replaced in US-005 through US-008 - Stub routers return empty arrays or null — they will be replaced in US-005 through US-008
--- ---
## 2026-02-19 - US-004
- What was implemented:
- Installed: electron-store@8 (CJS-compatible, for persistent app settings), @fontsource/geist (self-hosted Geist font), eslint-import-resolver-typescript (ESLint path alias fix)
- Created `src/main/store.ts` with lazy `getStore()` pattern using electron-store
- Added `settings` tRPC sub-router with `getSidebarCollapsed` query and `setSidebarCollapsed` mutation
- Updated `src/renderer/components/layout/AppShell.tsx` to: persist sidebar collapse via tRPC, add right-edge 'keep scrolling for AI' vertical label with ChevronDown icon
- Updated `src/renderer/globals.css`: replaced Google Fonts CDN with @fontsource/geist imports (weights 400/500/600)
- Updated `index.html`: removed Google Fonts CDN links
- Updated `.eslintrc.json`: added eslint-import-resolver-typescript to fix @/* alias resolution (fixed all 7 pre-existing lint errors)
- Files changed: .eslintrc.json, index.html, package.json, package-lock.json, src/main/router/index.ts, src/main/store.ts (new), src/renderer/components/layout/AppShell.tsx, src/renderer/globals.css
- **Learnings for future iterations:**
- Use electron-store@8 (not v9+) — v9+ is ESM-only and breaks with CommonJS main process
- electron-store must NOT be initialized at module import time (before app.ready); use lazy `getStore()` like `getDb()` pattern
- For sidebar/UI state loaded from IPC: use `localState ?? queryData ?? default` pattern to avoid flash while query resolves
- @fontsource packages are the npm equivalent of Google Fonts — import weight-specific CSS files (e.g., `@fontsource/geist/400.css`)
- ESLint `import/no-unresolved` requires `eslint-import-resolver-typescript` with `alwaysTryTypes: true` to resolve TypeScript path aliases
- The `writingMode: 'vertical-rl'` + `transform: 'rotate(180deg)'` CSS pattern creates bottom-to-top text for vertical affordance labels
---
## 2026-02-19 - US-006
- What was implemented:
- Full `projectsRouter` replacing stubs in `src/main/router/index.ts`
- Added `and` to drizzle-orm imports
- `projects.list`: uses `and()` with optional conditions for `clientId` filter and archived filter (defaults to active only)
- `projects.listAll`: returns only `{ id, name }` columns for dropdown use
- `projects.get`: `.all()` then `result[0] ?? null` pattern for nullable single-record lookup
- `projects.create`: inserts with UUID, status='active', createdAt=Date.now()
- `projects.update`: partial set object — only sets defined fields
- `projects.delete`: nulls `tasks.projectId` for all tasks in the project, then deletes the project
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `and(...conditions)` from drizzle-orm accepts `(SQL | undefined)[]` — pass `undefined` for optional conditions and drizzle filters them out automatically
- For nullable single-record queries: use `.all()` and `result[0] ?? null` (strict mode forbids `.get()` direct null return without this pattern)
- `and()` returns `SQL<unknown> | undefined` which `.where()` accepts directly (no extra wrapping needed)
---
## 2026-02-19 - US-005
- What was implemented:
- Full clients tRPC router replacing stubs in `src/main/router/index.ts`
- Added imports: `eq`, `asc`, `inArray` from `drizzle-orm`; `getDb` from `../db`; `clients`, `projects`, `tasks` from `../db/schema`
- `clients.list`: `db.select().from(clients).orderBy(asc(clients.name)).all()`
- `clients.create`: inserts with `crypto.randomUUID()` + `Date.now()` via `.run()`
- `clients.update`: partial update — only sets fields that are defined in input, skips if no-op
- `clients.delete`: checks for child clients and child projects; returns `{ error: string }` payload if any exist; otherwise deletes and returns `{ success: true }`
- `clients.deleteWithCascade`: BFS loop collects all descendant client IDs, finds their projects, nulls `projectId` on orphaned tasks, deletes projects, then deletes all clients
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- Drizzle ORM with better-sqlite3 sync driver: SELECT must call `.all()` to get an array; INSERT/UPDATE/DELETE must call `.run()` to execute — NOT calling these causes TypeScript errors (query builder ≠ result)
- `inArray(nullableColumn, string[])` is TypeScript-safe because `string[]` is assignable to `(string | null)[]` via covariance
- Guard against empty arrays before using `inArray` — while `allClientIds` is never empty (starts with input.id), `projectIds` could be empty; guarded with `if (projectIds.length > 0)` block
- `@typescript-eslint/no-non-null-assertion` is configured as a warning (not error) in this project — `queue.shift()!` is fine after a `length > 0` check
---
## 2026-02-19 - US-007
- What was implemented:
- Full `tasksRouter` replacing stubs in `src/main/router/index.ts`
- Added imports: `or`, `like`, `sql` from `drizzle-orm`; `alias` from `drizzle-orm/sqlite-core`
- `tasks.list`: LEFT JOINs projects → clients → parentClients (alias for self-join); CASE WHEN for clientName/subClientName breadcrumb fields; `and()` with optional conditions for projectId/status/search; `like()` OR search on title+description; CASE expression for priority ordering
- `tasks.create`: inserts with UUID, defaults (status='todo', priority='medium'), createdAt=Date.now()
- `tasks.update`: partial set object — only sets defined fields
- `tasks.delete`: deletes by id, returns `{ success: true }`
- Files changed: `src/main/router/index.ts`, `prd.json`, `progress.txt`
- **Learnings for future iterations:**
- `alias(table, 'alias_name')` is from `drizzle-orm/sqlite-core` (NOT `drizzle-orm`) for SQLite self-joins
- `sql<T>\`CASE WHEN ${col} IS NOT NULL THEN ${alias.col} ELSE ${col} END\`` for conditional field selection using drizzle template literals
- `or(like(col1, pattern), like(col2, pattern))` composes safely — null columns evaluate to NULL (falsy) in WHERE
- For priority ordering: `asc(sql\`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END\`)` puts high priority first
---

View File

@@ -1,5 +1,9 @@
import { initTRPC } from '@trpc/server'; import { initTRPC } from '@trpc/server';
import { z } from 'zod'; import { z } from 'zod';
import { eq, asc, inArray, and, or, like, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/sqlite-core';
import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes } from '../db/schema';
import { getStore } from '../store'; import { getStore } from '../store';
const t = initTRPC.create(); const t = initTRPC.create();
@@ -12,34 +16,126 @@ const healthRouter = router({
ping: publicProcedure.query(() => 'pong' as const), ping: publicProcedure.query(() => 'pong' as const),
}); });
// Stub routers — full implementations come in later user stories
const clientsRouter = router({ const clientsRouter = router({
list: publicProcedure.query(() => []), list: publicProcedure.query(() => {
return getDb().select().from(clients).orderBy(asc(clients.name)).all();
}),
create: publicProcedure create: publicProcedure
.input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() })) .input(z.object({ name: z.string(), parentId: z.string().optional(), industry: z.string().optional() }))
.mutation(() => null), .mutation(({ input }) => {
const id = crypto.randomUUID();
const now = Date.now();
getDb().insert(clients).values({
id,
name: input.name,
parentId: input.parentId ?? null,
industry: input.industry ?? null,
createdAt: now,
}).run();
return { id };
}),
update: publicProcedure update: publicProcedure
.input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() })) .input(z.object({ id: z.string(), name: z.string().optional(), industry: z.string().optional() }))
.mutation(() => null), .mutation(({ input }) => {
const set: Partial<{ name: string; industry: string | null }> = {};
if (input.name !== undefined) set.name = input.name;
if (input.industry !== undefined) set.industry = input.industry;
if (Object.keys(set).length > 0) {
getDb().update(clients).set(set).where(eq(clients.id, input.id)).run();
}
return null;
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
const db = getDb();
const childClients = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, input.id)).all();
if (childClients.length > 0) {
return { error: 'This client has sub-clients. Use cascade delete to remove all.' };
}
const childProjects = db.select({ id: projects.id }).from(projects).where(eq(projects.clientId, input.id)).all();
if (childProjects.length > 0) {
return { error: 'This client has projects. Use cascade delete to remove all.' };
}
db.delete(clients).where(eq(clients.id, input.id)).run();
return { success: true as const };
}),
deleteWithCascade: publicProcedure deleteWithCascade: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
const db = getDb();
// Recursively collect all descendant client IDs (BFS)
const allClientIds: string[] = [input.id];
const queue: string[] = [input.id];
while (queue.length > 0) {
const currentId = queue.shift()!;
const children = db.select({ id: clients.id }).from(clients).where(eq(clients.parentId, currentId)).all();
for (const child of children) {
allClientIds.push(child.id);
queue.push(child.id);
}
}
// Find all projects belonging to these clients
const clientProjects = db.select({ id: projects.id }).from(projects).where(inArray(projects.clientId, allClientIds)).all();
const projectIds = clientProjects.map((p) => p.id);
if (projectIds.length > 0) {
// Null out projectId on orphaned tasks
db.update(tasks).set({ projectId: null }).where(inArray(tasks.projectId, projectIds)).run();
// Delete the projects
db.delete(projects).where(inArray(projects.id, projectIds)).run();
}
// Delete all collected clients
db.delete(clients).where(inArray(clients.id, allClientIds)).run();
return { success: true as const };
}),
}); });
const projectsRouter = router({ const projectsRouter = router({
list: publicProcedure list: publicProcedure
.input(z.object({ clientId: z.string().optional(), includeArchived: z.boolean().optional() }).optional()) .input(z.object({ clientId: z.string().optional(), includeArchived: z.boolean().optional() }).optional())
.query(() => []), .query(({ input }) => {
listAll: publicProcedure.query(() => [] as Array<{ id: string; name: string }>), const where = and(
input?.clientId !== undefined ? eq(projects.clientId, input.clientId) : undefined,
!input?.includeArchived ? eq(projects.status, 'active') : undefined,
);
return getDb().select().from(projects).where(where).orderBy(asc(projects.name)).all();
}),
listAll: publicProcedure.query(() => {
return getDb().select({ id: projects.id, name: projects.name }).from(projects).orderBy(asc(projects.name)).all();
}),
get: publicProcedure get: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(() => null), .query(({ input }) => {
const result = getDb().select().from(projects).where(eq(projects.id, input.id)).all();
return result[0] ?? null;
}),
create: publicProcedure create: publicProcedure
.input(z.object({ name: z.string(), clientId: z.string().optional() })) .input(z.object({ name: z.string(), clientId: z.string().optional() }))
.mutation(() => null), .mutation(({ input }) => {
const id = crypto.randomUUID();
const now = Date.now();
getDb().insert(projects).values({
id,
name: input.name,
clientId: input.clientId ?? null,
status: 'active',
createdAt: now,
}).run();
return { id };
}),
update: publicProcedure update: publicProcedure
.input(z.object({ .input(z.object({
id: z.string(), id: z.string(),
@@ -48,10 +144,28 @@ const projectsRouter = router({
status: z.enum(['active', 'archived']).optional(), status: z.enum(['active', 'archived']).optional(),
aiSummary: z.string().optional(), aiSummary: z.string().optional(),
})) }))
.mutation(() => null), .mutation(({ input }) => {
const set: Partial<{ name: string; clientId: string | null; status: 'active' | 'archived'; aiSummary: string | null }> = {};
if (input.name !== undefined) set.name = input.name;
if (input.clientId !== undefined) set.clientId = input.clientId;
if (input.status !== undefined) set.status = input.status;
if (input.aiSummary !== undefined) set.aiSummary = input.aiSummary;
if (Object.keys(set).length > 0) {
getDb().update(projects).set(set).where(eq(projects.id, input.id)).run();
}
return null;
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
const db = getDb();
// Null out projectId on tasks belonging to this project
db.update(tasks).set({ projectId: null }).where(eq(tasks.projectId, input.id)).run();
// Delete the project
db.delete(projects).where(eq(projects.id, input.id)).run();
return { success: true as const };
}),
}); });
const tasksRouter = router({ const tasksRouter = router({
@@ -62,7 +176,53 @@ const tasksRouter = router({
search: z.string().optional(), search: z.string().optional(),
orderBy: z.enum(['dueDate', 'priority', 'createdAt']).optional(), orderBy: z.enum(['dueDate', 'priority', 'createdAt']).optional(),
}).optional()) }).optional())
.query(() => []), .query(({ input }) => {
const db = getDb();
const parentClients = alias(clients, 'parent_clients');
const searchTerm = input?.search?.trim();
const conditions = and(
input?.projectId !== undefined ? eq(tasks.projectId, input.projectId) : undefined,
input?.status !== undefined ? eq(tasks.status, input.status) : undefined,
searchTerm
? or(
like(tasks.title, `%${searchTerm}%`),
like(tasks.description, `%${searchTerm}%`),
)
: undefined,
);
const orderByClause =
input?.orderBy === 'dueDate'
? asc(tasks.dueDate)
: input?.orderBy === 'priority'
? asc(sql`CASE ${tasks.priority} WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`)
: asc(tasks.createdAt);
return db
.select({
id: tasks.id,
projectId: tasks.projectId,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
assignee: tasks.assignee,
dueDate: tasks.dueDate,
createdAt: tasks.createdAt,
projectName: projects.name,
clientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${parentClients.name} ELSE ${clients.name} END`,
subClientName: sql<string | null>`CASE WHEN ${clients.parentId} IS NOT NULL THEN ${clients.name} ELSE NULL END`,
})
.from(tasks)
.leftJoin(projects, eq(tasks.projectId, projects.id))
.leftJoin(clients, eq(projects.clientId, clients.id))
.leftJoin(parentClients, eq(clients.parentId, parentClients.id))
.where(conditions)
.orderBy(orderByClause)
.all();
}),
create: publicProcedure create: publicProcedure
.input(z.object({ .input(z.object({
title: z.string(), title: z.string(),
@@ -73,7 +233,23 @@ const tasksRouter = router({
dueDate: z.number().optional(), dueDate: z.number().optional(),
projectId: z.string().optional(), projectId: z.string().optional(),
})) }))
.mutation(() => null), .mutation(({ input }) => {
const id = crypto.randomUUID();
const now = Date.now();
getDb().insert(tasks).values({
id,
title: input.title,
description: input.description ?? null,
status: input.status ?? 'todo',
priority: input.priority ?? 'medium',
assignee: input.assignee ?? null,
dueDate: input.dueDate ?? null,
projectId: input.projectId ?? null,
createdAt: now,
}).run();
return { id };
}),
update: publicProcedure update: publicProcedure
.input(z.object({ .input(z.object({
id: z.string(), id: z.string(),
@@ -85,16 +261,45 @@ const tasksRouter = router({
dueDate: z.number().optional(), dueDate: z.number().optional(),
projectId: z.string().optional(), projectId: z.string().optional(),
})) }))
.mutation(() => null), .mutation(({ input }) => {
const set: Partial<{
title: string;
description: string | null;
status: string;
priority: string;
assignee: string | null;
dueDate: number | null;
projectId: string | null;
}> = {};
if (input.title !== undefined) set.title = input.title;
if (input.description !== undefined) set.description = input.description;
if (input.status !== undefined) set.status = input.status;
if (input.priority !== undefined) set.priority = input.priority;
if (input.assignee !== undefined) set.assignee = input.assignee;
if (input.dueDate !== undefined) set.dueDate = input.dueDate;
if (input.projectId !== undefined) set.projectId = input.projectId;
if (Object.keys(set).length > 0) {
getDb().update(tasks).set(set).where(eq(tasks.id, input.id)).run();
}
return null;
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
getDb().delete(tasks).where(eq(tasks.id, input.id)).run();
return { success: true as const };
}),
}); });
const checkpointsRouter = router({ const checkpointsRouter = router({
list: publicProcedure list: publicProcedure
.input(z.object({ projectId: z.string().optional() }).optional()) .input(z.object({ projectId: z.string().optional() }).optional())
.query(() => []), .query(({ input }) => {
const where = input?.projectId !== undefined ? eq(checkpoints.projectId, input.projectId) : undefined;
return getDb().select().from(checkpoints).where(where).orderBy(asc(checkpoints.date)).all();
}),
create: publicProcedure create: publicProcedure
.input(z.object({ .input(z.object({
projectId: z.string(), projectId: z.string(),
@@ -103,7 +308,21 @@ const checkpointsRouter = router({
isAiSuggested: z.number().optional(), isAiSuggested: z.number().optional(),
isApproved: z.number().optional(), isApproved: z.number().optional(),
})) }))
.mutation(() => null), .mutation(({ input }) => {
const id = crypto.randomUUID();
const now = Date.now();
getDb().insert(checkpoints).values({
id,
projectId: input.projectId,
title: input.title,
date: input.date,
isAiSuggested: input.isAiSuggested ?? 0,
isApproved: input.isApproved ?? 0,
createdAt: now,
}).run();
return { id };
}),
update: publicProcedure update: publicProcedure
.input(z.object({ .input(z.object({
id: z.string(), id: z.string(),
@@ -111,28 +330,79 @@ const checkpointsRouter = router({
date: z.number().optional(), date: z.number().optional(),
isApproved: z.number().optional(), isApproved: z.number().optional(),
})) }))
.mutation(() => null), .mutation(({ input }) => {
const set: Partial<{ title: string; date: number; isApproved: number }> = {};
if (input.title !== undefined) set.title = input.title;
if (input.date !== undefined) set.date = input.date;
if (input.isApproved !== undefined) set.isApproved = input.isApproved;
if (Object.keys(set).length > 0) {
getDb().update(checkpoints).set(set).where(eq(checkpoints.id, input.id)).run();
}
return null;
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
getDb().delete(checkpoints).where(eq(checkpoints.id, input.id)).run();
return { success: true as const };
}),
}); });
const notesRouter = router({ const notesRouter = router({
list: publicProcedure list: publicProcedure
.input(z.object({ projectId: z.string().optional() }).optional()) .input(z.object({ projectId: z.string().optional() }).optional())
.query(() => []), .query(({ input }) => {
const where = input?.projectId !== undefined ? eq(notes.projectId, input.projectId) : undefined;
return getDb()
.select({ id: notes.id, projectId: notes.projectId, title: notes.title, createdAt: notes.createdAt, updatedAt: notes.updatedAt })
.from(notes)
.where(where)
.orderBy(asc(notes.createdAt))
.all();
}),
get: publicProcedure get: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(() => null), .query(({ input }) => {
const result = getDb().select().from(notes).where(eq(notes.id, input.id)).all();
return result[0] ?? null;
}),
create: publicProcedure create: publicProcedure
.input(z.object({ title: z.string(), content: z.string(), projectId: z.string().optional() })) .input(z.object({ title: z.string(), content: z.string(), projectId: z.string().optional() }))
.mutation(() => null), .mutation(({ input }) => {
const id = crypto.randomUUID();
const now = Date.now();
getDb().insert(notes).values({
id,
title: input.title,
content: input.content,
projectId: input.projectId ?? null,
createdAt: now,
updatedAt: now,
}).run();
return { id };
}),
update: publicProcedure update: publicProcedure
.input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional() })) .input(z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional() }))
.mutation(() => null), .mutation(({ input }) => {
const set: Partial<{ title: string; content: string; updatedAt: number }> = {};
if (input.title !== undefined) set.title = input.title;
if (input.content !== undefined) set.content = input.content;
// Always update updatedAt
set.updatedAt = Date.now();
getDb().update(notes).set(set).where(eq(notes.id, input.id)).run();
return null;
}),
delete: publicProcedure delete: publicProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(() => null), .mutation(({ input }) => {
getDb().delete(notes).where(eq(notes.id, input.id)).run();
return { success: true as const };
}),
}); });
const settingsRouter = router({ const settingsRouter = router({

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,42 +1,52 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./index.html', './src/renderer/**/*.{ts,tsx}'], darkMode: ['class'],
content: ['./index.html', './src/renderer/**/*.{ts,tsx}'],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ['Geist', 'Inter', 'system-ui', 'sans-serif'], sans: [
}, 'Geist',
colors: { 'Inter',
border: 'hsl(var(--border))', 'system-ui',
input: 'hsl(var(--input))', 'sans-serif'
ring: 'hsl(var(--ring))', ]
background: 'hsl(var(--background))', },
foreground: 'hsl(var(--foreground))', colors: {
primary: { border: 'hsl(var(--border))',
DEFAULT: 'hsl(var(--primary))', input: 'hsl(var(--input))',
foreground: 'hsl(var(--primary-foreground))', ring: 'hsl(var(--ring))',
}, background: 'hsl(var(--background))',
secondary: { foreground: 'hsl(var(--foreground))',
DEFAULT: 'hsl(var(--secondary))', primary: {
foreground: 'hsl(var(--secondary-foreground))', DEFAULT: 'hsl(var(--primary))',
}, foreground: 'hsl(var(--primary-foreground))'
muted: { },
DEFAULT: 'hsl(var(--muted))', secondary: {
foreground: 'hsl(var(--muted-foreground))', DEFAULT: 'hsl(var(--secondary))',
}, foreground: 'hsl(var(--secondary-foreground))'
sidebar: { },
DEFAULT: 'hsl(var(--sidebar))', muted: {
border: 'hsl(var(--sidebar-border))', DEFAULT: 'hsl(var(--muted))',
accent: 'hsl(var(--sidebar-accent))', foreground: 'hsl(var(--muted-foreground))'
foreground: 'hsl(var(--sidebar-foreground))', },
}, sidebar: {
}, DEFAULT: 'hsl(var(--sidebar-background))',
borderRadius: { border: 'hsl(var(--sidebar-border))',
lg: 'var(--radius)', accent: 'hsl(var(--sidebar-accent))',
md: 'calc(var(--radius) - 2px)', foreground: 'hsl(var(--sidebar-foreground))',
sm: 'calc(var(--radius) - 4px)', 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: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
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',
},
},
},
});