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

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

View File

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