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