diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df1148ab..49b35280 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -87,8 +87,11 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
+ '@radix-ui/react-alert-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dialog':
- specifier: ^1.1.4
+ specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.4
@@ -100,11 +103,14 @@ importers:
specifier: ^2.1.4
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-slot':
- specifier: ^1.1.1
+ specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.9)(react@19.2.3)
'@radix-ui/react-toast':
specifier: ^1.2.4
version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.2.8
+ version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-query':
specifier: ^5.62.0
version: 5.90.20(react@19.2.3)
@@ -693,6 +699,19 @@ packages:
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+ '@radix-ui/react-alert-dialog@1.1.15':
+ resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
@@ -964,6 +983,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-tooltip@1.2.8':
+ resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -2962,6 +2994,20 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.9
+ '@types/react-dom': 19.2.3(@types/react@19.2.9)
+
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -3246,6 +3292,26 @@ snapshots:
'@types/react': 19.2.9
'@types/react-dom': 19.2.3(@types/react@19.2.9)
+ '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.2.3)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.9
+ '@types/react-dom': 19.2.3(@types/react@19.2.9)
+
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.9)(react@19.2.3)':
dependencies:
react: 19.2.3
diff --git a/web/components.json b/web/components.json
new file mode 100644
index 00000000..9f7ff54d
--- /dev/null
+++ b/web/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/web/package.json b/web/package.json
index 02d4ef72..5e2b7156 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,17 +12,19 @@
"clean": "rm -rf dist"
},
"dependencies": {
- "@veritas-kanban/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
- "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.62.0",
+ "@veritas-kanban/shared": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx
index aef1367f..93afb978 100644
--- a/web/src/components/board/KanbanBoard.tsx
+++ b/web/src/components/board/KanbanBoard.tsx
@@ -1,6 +1,8 @@
import { useTasks, useTasksByStatus, useUpdateTask } from '@/hooks/useTasks';
import { KanbanColumn } from './KanbanColumn';
-import type { TaskStatus } from '@veritas-kanban/shared';
+import { TaskDetailPanel } from '@/components/task/TaskDetailPanel';
+import { Skeleton } from '@/components/ui/skeleton';
+import type { TaskStatus, Task } from '@veritas-kanban/shared';
import {
DndContext,
DragEndEvent,
@@ -13,7 +15,6 @@ import {
} from '@dnd-kit/core';
import { useState } from 'react';
import { TaskCard } from '@/components/task/TaskCard';
-import type { Task } from '@veritas-kanban/shared';
const COLUMNS: { id: TaskStatus; title: string }[] = [
{ id: 'todo', title: 'To Do' },
@@ -22,11 +23,48 @@ const COLUMNS: { id: TaskStatus; title: string }[] = [
{ id: 'done', title: 'Done' },
];
+function LoadingSkeleton() {
+ return (
+
+ {COLUMNS.map(column => (
+
+
+
+
+
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
export function KanbanBoard() {
const { data: tasks, isLoading, error } = useTasks();
const tasksByStatus = useTasksByStatus(tasks);
const updateTask = useUpdateTask();
const [activeTask, setActiveTask] = useState(null);
+ const [selectedTask, setSelectedTask] = useState(null);
+ const [detailOpen, setDetailOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -61,47 +99,75 @@ export function KanbanBoard() {
}
};
+ const handleTaskClick = (task: Task) => {
+ setSelectedTask(task);
+ setDetailOpen(true);
+ };
+
+ const handleDetailClose = (open: boolean) => {
+ setDetailOpen(open);
+ if (!open) {
+ // Small delay to allow animation to complete
+ setTimeout(() => setSelectedTask(null), 200);
+ }
+ };
+
+ // Keep selected task in sync with updated data
+ const currentSelectedTask = selectedTask
+ ? tasks?.find(t => t.id === selectedTask.id) || selectedTask
+ : null;
+
if (isLoading) {
- return (
-
- );
+ return ;
}
if (error) {
return (
-
- Error loading tasks: {error.message}
+
+
+ Error loading tasks
+
+
+ {error.message}
+
);
}
return (
-
-
- {COLUMNS.map(column => (
-
- ))}
-
-
-
- {activeTask ? (
-
- ) : null}
-
-
+ <>
+
+
+ {COLUMNS.map(column => (
+
+ ))}
+
+
+
+ {activeTask ? (
+
+ ) : null}
+
+
+
+
+ >
);
}
diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx
index 2b63357a..0b9ce0c2 100644
--- a/web/src/components/board/KanbanColumn.tsx
+++ b/web/src/components/board/KanbanColumn.tsx
@@ -7,6 +7,7 @@ interface KanbanColumnProps {
id: TaskStatus;
title: string;
tasks: Task[];
+ onTaskClick?: (task: Task) => void;
}
const columnColors: Record
= {
@@ -16,16 +17,16 @@ const columnColors: Record = {
'done': 'border-t-green-500',
};
-export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) {
+export function KanbanColumn({ id, title, tasks, onTaskClick }: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
@@ -39,12 +40,19 @@ export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) {
{tasks.length === 0 ? (
-
- No tasks
+
+ {isOver ? 'Drop here' : 'No tasks'}
) : (
tasks.map(task => (
-
+
onTaskClick?.(task)}
+ />
))
)}
diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx
index d4988153..a04b4aa5 100644
--- a/web/src/components/task/TaskCard.tsx
+++ b/web/src/components/task/TaskCard.tsx
@@ -1,11 +1,18 @@
import { useDraggable } from '@dnd-kit/core';
import { cn } from '@/lib/utils';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
import type { Task, TaskType, TaskPriority } from '@veritas-kanban/shared';
import { Code, Search, FileText, Zap } from 'lucide-react';
interface TaskCardProps {
task: Task;
isDragging?: boolean;
+ onClick?: () => void;
}
const typeIcons: Record
= {
@@ -28,8 +35,8 @@ const priorityColors: Record = {
low: 'bg-slate-500/20 text-slate-400',
};
-export function TaskCard({ task, isDragging }: TaskCardProps) {
- const { attributes, listeners, setNodeRef, transform } = useDraggable({
+export function TaskCard({ task, isDragging, onClick }: TaskCardProps) {
+ const { attributes, listeners, setNodeRef, transform, isDragging: isCurrentlyDragging } = useDraggable({
id: task.id,
});
@@ -39,49 +46,74 @@ export function TaskCard({ task, isDragging }: TaskCardProps) {
}
: undefined;
+ const handleClick = () => {
+ // Don't open detail panel if we're dragging
+ if (isCurrentlyDragging || isDragging) return;
+ onClick?.();
+ };
+
return (
-
-
-
- {typeIcons[task.type]}
-
-
-
- {task.title}
-
+
+
+
+
+
+
+ {typeIcons[task.type]}
+
+
+
+ {task.title}
+
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
+
+
+ {task.project && (
+
+ {task.project}
+
+ )}
+
+ {task.priority}
+
+ {task.tags && task.tags.length > 0 && (
+
+ +{task.tags.length} tags
+
+ )}
+
+
+
+
+ {task.title}
{task.description && (
-
- {task.description}
-
+ {task.description}
)}
-
-
-
-
- {task.project && (
-
- {task.project}
-
- )}
-
- {task.priority}
-
-
-
+
+
+
);
}
diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx
new file mode 100644
index 00000000..802a715b
--- /dev/null
+++ b/web/src/components/task/TaskDetailPanel.tsx
@@ -0,0 +1,313 @@
+import { useEffect, useState, useCallback } from 'react';
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks';
+import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react';
+import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared';
+
+interface TaskDetailPanelProps {
+ task: Task | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+const typeIcons: Record = {
+ code: ,
+ research: ,
+ content: ,
+ automation: ,
+};
+
+const typeLabels: Record = {
+ code: 'Code',
+ research: 'Research',
+ content: 'Content',
+ automation: 'Automation',
+};
+
+const statusLabels: Record = {
+ 'todo': 'To Do',
+ 'in-progress': 'In Progress',
+ 'review': 'Review',
+ 'done': 'Done',
+};
+
+const priorityLabels: Record = {
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+};
+
+function useDebouncedSave(task: Task | null, updateTask: ReturnType) {
+ const [localTask, setLocalTask] = useState(task);
+ const [isDirty, setIsDirty] = useState(false);
+
+ // Sync with external task when it changes
+ useEffect(() => {
+ setLocalTask(task);
+ setIsDirty(false);
+ }, [task]);
+
+ // Debounced save
+ useEffect(() => {
+ if (!isDirty || !localTask) return;
+
+ const timeout = setTimeout(() => {
+ updateTask.mutate({
+ id: localTask.id,
+ input: {
+ title: localTask.title,
+ description: localTask.description,
+ type: localTask.type,
+ status: localTask.status,
+ priority: localTask.priority,
+ project: localTask.project,
+ tags: localTask.tags,
+ },
+ });
+ setIsDirty(false);
+ }, 500);
+
+ return () => clearTimeout(timeout);
+ }, [localTask, isDirty, updateTask]);
+
+ const updateField = useCallback((field: K, value: Task[K]) => {
+ setLocalTask(prev => prev ? { ...prev, [field]: value } : null);
+ setIsDirty(true);
+ }, []);
+
+ return { localTask, updateField, isDirty };
+}
+
+export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelProps) {
+ const updateTask = useUpdateTask();
+ const deleteTask = useDeleteTask();
+ const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask);
+
+ // Handle Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && open) {
+ onOpenChange(false);
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [open, onOpenChange]);
+
+ const handleDelete = async () => {
+ if (!task) return;
+ await deleteTask.mutateAsync(task.id);
+ onOpenChange(false);
+ };
+
+ if (!localTask) return null;
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ });
+ };
+
+ return (
+
+
+
+
+ {typeIcons[localTask.type]}
+ {typeLabels[localTask.type]} Task
+ {isDirty && (
+ Saving...
+ )}
+
+
+ updateField('title', e.target.value)}
+ className="text-xl font-semibold border-0 px-0 focus-visible:ring-0 bg-transparent"
+ placeholder="Task title..."
+ />
+
+
+
+
+ {/* Description */}
+
+
+
+
+ {/* Status, Type, Priority grid */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Project */}
+
+
+ updateField('project', e.target.value || undefined)}
+ placeholder="Add to a project..."
+ />
+
+
+ {/* Tags */}
+
+
+ {
+ const tags = e.target.value
+ .split(',')
+ .map(t => t.trim())
+ .filter(Boolean);
+ updateField('tags', tags.length > 0 ? tags : undefined);
+ }}
+ placeholder="Add tags (comma-separated)..."
+ />
+
+
+ {/* Metadata */}
+
+
+
+ Created: {formatDate(localTask.created)}
+
+
+
+ Updated: {formatDate(localTask.updated)}
+
+
+ ID: {localTask.id}
+
+
+
+ {/* Delete button */}
+
+
+
+
+
+
+
+ Delete this task?
+
+ This will permanently delete "{localTask.title}". This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx
new file mode 100644
index 00000000..fa2b4429
--- /dev/null
+++ b/web/src/components/ui/alert-dialog.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
index 9143eea8..65d4fcd9 100644
--- a/web/src/components/ui/button.tsx
+++ b/web/src/components/ui/button.tsx
@@ -1,52 +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';
+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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ "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 hover:bg-primary/90',
- destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
- outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
- secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
- ghost: 'hover:bg-accent hover:text-accent-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
+ 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-10 px-4 py-2',
- sm: 'h-9 rounded-md px-3',
- lg: 'h-11 rounded-md px-8',
- icon: 'h-10 w-10',
+ 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',
+ variant: "default",
+ size: "default",
},
}
-);
+)
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
- asChild?: boolean;
+ asChild?: boolean
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : 'button';
+ const Comp = asChild ? Slot : "button"
return (
- );
+ )
}
-);
-Button.displayName = 'Button';
+)
+Button.displayName = "Button"
-export { Button, buttonVariants };
+export { Button, buttonVariants }
diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx
new file mode 100644
index 00000000..02942db5
--- /dev/null
+++ b/web/src/components/ui/sheet.tsx
@@ -0,0 +1,138 @@
+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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, 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,
+ VariantProps {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/web/src/components/ui/skeleton.tsx b/web/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..d7e45f7b
--- /dev/null
+++ b/web/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/web/src/components/ui/tooltip.tsx b/web/src/components/ui/tooltip.tsx
new file mode 100644
index 00000000..28e19183
--- /dev/null
+++ b/web/src/components/ui/tooltip.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+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,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/web/src/globals.css b/web/src/globals.css
index a2caaf0b..91bdcdc6 100644
--- a/web/src/globals.css
+++ b/web/src/globals.css
@@ -5,43 +5,57 @@
@layer base {
:root {
--background: 0 0% 100%;
- --foreground: 240 10% 3.9%;
+ --foreground: 0 0% 3.9%;
--card: 0 0% 100%;
- --card-foreground: 240 10% 3.9%;
- --primary: 240 5.9% 10%;
+ --card-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
- --secondary: 240 4.8% 95.9%;
- --secondary-foreground: 240 5.9% 10%;
- --muted: 240 4.8% 95.9%;
- --muted-foreground: 240 3.8% 46.1%;
- --accent: 240 4.8% 95.9%;
- --accent-foreground: 240 5.9% 10%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
- --border: 240 5.9% 90%;
- --input: 240 5.9% 90%;
- --ring: 240 5.9% 10%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
--radius: 0.5rem;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
}
.dark {
- --background: 240 10% 3.9%;
+ --background: 0 0% 3.9%;
--foreground: 0 0% 98%;
- --card: 240 10% 3.9%;
+ --card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
- --primary-foreground: 240 5.9% 10%;
- --secondary: 240 3.7% 15.9%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
- --muted: 240 3.7% 15.9%;
- --muted-foreground: 240 5% 64.9%;
- --accent: 240 3.7% 15.9%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
- --border: 240 3.7% 15.9%;
- --input: 240 3.7% 15.9%;
- --ring: 240 4.9% 83.9%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
}
}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index 9ad0df42..bd0c391d 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -1,6 +1,6 @@
-import { type ClassValue, clsx } from 'clsx';
-import { twMerge } from 'tailwind-merge';
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
+ return twMerge(clsx(inputs))
}
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 40f21e36..7940f6d7 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -1,49 +1,60 @@
/** @type {import('tailwindcss').Config} */
export default {
- darkMode: 'class',
+ darkMode: ['class', 'class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
- extend: {
- colors: {
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))',
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))',
- },
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))',
- },
- },
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
- },
- },
+ extend: {
+ colors: {
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))'
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))'
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))'
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))'
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))'
+ },
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))'
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))'
+ },
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))'
+ }
+ },
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)'
+ }
+ }
},
plugins: [require('tailwindcss-animate')],
};