feat: implement Story 1.2 — organize diagrams into projects

Add project CRUD API (GET/POST/PATCH/DELETE) with ownership checks and
transactional delete. Add diagram list filtering by projectId and
unorganized query params with typed Zod query schema for Hono RPC
type safety. Create DiagramSidebar with Projects tree (expand/collapse,
inline rename) and Recent tab. Add project picker to CreateDiagramDialog.
Includes 15 schema validation tests (107 total passing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 21:45:16 +00:00
parent 392da385f4
commit 85e06c25c7
15 changed files with 1116 additions and 13 deletions

View File

@@ -1,15 +1,25 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Icons } from "@turbostarter/ui-web/icons";
import { api } from "~/lib/api/client";
import { DiagramGrid } from "~/modules/diagram/components/DiagramGrid";
import { DiagramSidebar } from "~/modules/diagram/components/sidebar/DiagramSidebar";
export default function DiagramsPage() {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: ["diagrams"],
queryKey: ["diagrams", selectedProjectId],
queryFn: async () => {
const res = await api.diagrams.$get();
const query: { projectId?: string; unorganized?: "true" } = {};
if (selectedProjectId === "unorganized") {
query.unorganized = "true";
} else if (selectedProjectId) {
query.projectId = selectedProjectId;
}
const res = await api.diagrams.$get({ query });
return await res.json();
},
});
@@ -26,8 +36,14 @@ export default function DiagramsPage() {
}
return (
<div className="@container h-full p-6">
<DiagramGrid diagrams={data?.data ?? []} isLoading={isLoading} />
<div className="flex h-full">
<DiagramSidebar
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
/>
<div className="@container flex-1 overflow-y-auto p-6">
<DiagramGrid diagrams={data?.data ?? []} isLoading={isLoading} />
</div>
</div>
);
}

View File

@@ -20,6 +20,11 @@ const menu = [
href: pathsConfig.dashboard.user.index,
icon: Icons.Home,
},
{
title: "diagrams",
href: pathsConfig.dashboard.user.diagrams,
icon: Icons.GitBranch,
},
{
title: "aiTools",
href: pathsConfig.apps.chat.index,

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
@@ -13,6 +13,13 @@ import {
import { Button } from "@turbostarter/ui-web/button";
import { Input } from "@turbostarter/ui-web/input";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { pathsConfig } from "~/config/paths";
@@ -31,11 +38,22 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [selectedType, setSelectedType] = useState<DiagramType>("flowchart");
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(undefined);
const router = useRouter();
const queryClient = useQueryClient();
const { data: projectsData } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const res = await api.projects.$get();
return await res.json();
},
});
const projects = projectsData?.data ?? [];
const createMutation = useMutation({
mutationFn: async (input: { title: string; type: DiagramType }) => {
mutationFn: async (input: { title: string; type: DiagramType; projectId?: string }) => {
const res = await api.diagrams.$post({ json: input });
return await res.json();
},
@@ -44,6 +62,7 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
setOpen(false);
setTitle("");
setSelectedType("flowchart");
setSelectedProjectId(undefined);
if (data.data) {
router.push(pathsConfig.dashboard.user.diagram(data.data.id));
}
@@ -56,7 +75,11 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
createMutation.mutate({ title: title.trim(), type: selectedType });
createMutation.mutate({
title: title.trim(),
type: selectedType,
projectId: selectedProjectId,
});
};
return (
@@ -80,6 +103,24 @@ export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Project</label>
<Select
value={selectedProjectId ?? "none"}
onValueChange={(v) => setSelectedProjectId(v === "none" ? undefined : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="No project (Unorganized)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No project (Unorganized)</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Diagram Type</label>
<div className="grid grid-cols-3 gap-2">

View File

@@ -10,7 +10,7 @@ export type DiagramResponse = Omit<SelectDiagram, "createdAt" | "updatedAt" | "d
deletedAt: string | null;
};
function timeAgo(date: Date): string {
export function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 0) return "just now";
if (seconds < 60) return "just now";

View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@turbostarter/ui-web/tabs";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { ProjectTree } from "./ProjectTree";
import { RecentList } from "./RecentList";
interface DiagramSidebarProps {
selectedProjectId: string | null;
onSelectProject: (projectId: string | null) => void;
}
export function DiagramSidebar({ selectedProjectId, onSelectProject }: DiagramSidebarProps) {
const [isCreating, setIsCreating] = useState(false);
const [newProjectName, setNewProjectName] = useState("");
const queryClient = useQueryClient();
const createProjectMutation = useMutation({
mutationFn: async (name: string) => {
const res = await api.projects.$post({ json: { name } });
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setIsCreating(false);
setNewProjectName("");
toast.success("Project created");
},
onError: () => {
toast.error("Failed to create project");
},
});
const handleCreateProject = () => {
if (!newProjectName.trim()) return;
createProjectMutation.mutate(newProjectName.trim());
};
return (
<div className="flex h-full w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold">Diagrams</h2>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setIsCreating(true)}
title="New Project"
>
<Icons.Plus className="h-4 w-4" />
</Button>
</div>
{isCreating && (
<div className="border-b p-2">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreateProject();
}}
className="flex gap-1"
>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="h-7 text-xs"
autoFocus
onKeyDown={(e) => {
if (e.key === "Escape") {
setIsCreating(false);
setNewProjectName("");
}
}}
/>
<Button type="submit" size="icon" className="h-7 w-7 shrink-0" disabled={!newProjectName.trim()}>
<Icons.Check className="h-3 w-3" />
</Button>
</form>
</div>
)}
<Tabs defaultValue="projects" className="flex flex-1 flex-col overflow-hidden">
<TabsList className="mx-2 mt-2 grid w-auto grid-cols-2">
<TabsTrigger value="projects" className="text-xs">Projects</TabsTrigger>
<TabsTrigger value="recent" className="text-xs">Recent</TabsTrigger>
</TabsList>
<TabsContent value="projects" className="flex-1 overflow-y-auto p-2">
<ProjectTree selectedProjectId={selectedProjectId} onSelectProject={onSelectProject} />
</TabsContent>
<TabsContent value="recent" className="flex-1 overflow-y-auto p-2">
<RecentList />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@turbostarter/ui-web/alert-dialog";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
interface ProjectContextMenuProps {
project: { id: string; name: string };
onStartRename: () => void;
}
export function ProjectContextMenu({ project, onStartRename }: ProjectContextMenuProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: async () => {
const res = await api.projects[":id"].$delete({
param: { id: project.id },
});
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
setShowDeleteDialog(false);
toast.success("Project deleted");
},
onError: () => {
toast.error("Failed to delete project");
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100">
<Icons.MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={onStartRename}>
<Icons.Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
variant="destructive"
>
<Icons.Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project &ldquo;{project.name}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
Diagrams in this project will be moved to Unorganized. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { pathsConfig } from "~/config/paths";
import { diagramTypeConfig } from "../DiagramCard";
import { ProjectContextMenu } from "./ProjectContextMenu";
import type { DiagramResponse } from "../DiagramCard";
interface ProjectTreeProps {
selectedProjectId: string | null;
onSelectProject: (projectId: string | null) => void;
}
export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const { data: projectsData } = useQuery({
queryKey: ["projects"],
queryFn: async () => {
const res = await api.projects.$get();
return await res.json();
},
});
const { data: allDiagrams } = useQuery({
queryKey: ["diagrams"],
queryFn: async () => {
const res = await api.diagrams.$get({ query: {} });
return await res.json();
},
});
const renameMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const res = await api.projects[":id"].$patch({
param: { id },
json: { name },
});
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
setRenamingId(null);
toast.success("Project renamed");
},
onError: () => {
toast.error("Failed to rename project");
},
});
const projects = projectsData?.data ?? [];
const diagrams = (allDiagrams?.data ?? []) as DiagramResponse[];
const toggleExpand = (projectId: string) => {
setExpandedProjects(prev => {
const next = new Set(prev);
if (next.has(projectId)) next.delete(projectId);
else next.add(projectId);
return next;
});
};
const getDiagramsForProject = (projectId: string) =>
diagrams.filter(d => d.projectId === projectId);
const unorganizedCount = diagrams.filter(d => !d.projectId).length;
const handleRename = (projectId: string, originalName: string) => {
if (!renameName.trim() || renameName.trim() === originalName) {
setRenamingId(null);
return;
}
renameMutation.mutate({ id: projectId, name: renameName.trim() });
};
const itemClass = (isActive: boolean) =>
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer hover:bg-accent/50 ${
isActive ? "bg-accent text-accent-foreground" : ""
}`;
return (
<div className="space-y-1">
{/* All Diagrams */}
<button
className={itemClass(selectedProjectId === null)}
onClick={() => onSelectProject(null)}
>
<Icons.LayoutDashboard className="h-4 w-4 shrink-0" />
<span className="truncate">All Diagrams</span>
<span className="ml-auto text-xs text-muted-foreground">{diagrams.length}</span>
</button>
{/* Unorganized */}
<button
className={itemClass(selectedProjectId === "unorganized")}
onClick={() => onSelectProject("unorganized")}
>
<Icons.Inbox className="h-4 w-4 shrink-0" />
<span className="truncate">Unorganized</span>
<span className="ml-auto text-xs text-muted-foreground">{unorganizedCount}</span>
</button>
{/* Separator */}
{projects.length > 0 && <div className="my-2 border-t" />}
{/* Projects */}
{projects.map((proj) => {
const projectDiagrams = getDiagramsForProject(proj.id);
const isExpanded = expandedProjects.has(proj.id);
const isActive = selectedProjectId === proj.id;
const isRenaming = renamingId === proj.id;
return (
<div key={proj.id}>
<div className="group flex items-center">
<button
className="p-0.5 hover:bg-accent rounded"
onClick={(e) => {
e.stopPropagation();
toggleExpand(proj.id);
}}
>
{isExpanded ? (
<Icons.ChevronDown className="h-3 w-3" />
) : (
<Icons.ChevronRight className="h-3 w-3" />
)}
</button>
{isRenaming ? (
<div className="flex-1 px-1">
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onBlur={() => handleRename(proj.id, proj.name)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(proj.id, proj.name);
if (e.key === "Escape") setRenamingId(null);
}}
className="h-7 text-xs"
autoFocus
/>
</div>
) : (
<button
className={`${itemClass(isActive)} flex-1`}
onClick={() => onSelectProject(proj.id)}
>
<Icons.FolderOpen className="h-4 w-4 shrink-0" />
<span className="truncate">{proj.name}</span>
<span className="ml-auto text-xs text-muted-foreground">
{projectDiagrams.length}
</span>
</button>
)}
{!isRenaming && (
<ProjectContextMenu
project={proj}
onStartRename={() => {
setRenameName(proj.name);
setRenamingId(proj.id);
}}
/>
)}
</div>
{/* Expanded diagram list */}
{isExpanded && projectDiagrams.length > 0 && (
<div className="ml-6 space-y-0.5">
{projectDiagrams.map((d) => {
const config = diagramTypeConfig[d.type];
const TypeIcon = config.icon;
return (
<button
key={d.id}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-accent/50 cursor-pointer"
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
>
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
<span className="truncate">{d.title}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Icons } from "@turbostarter/ui-web/icons";
import { api } from "~/lib/api/client";
import { pathsConfig } from "~/config/paths";
import { diagramTypeConfig, timeAgo } from "../DiagramCard";
import type { DiagramResponse } from "../DiagramCard";
export function RecentList() {
const router = useRouter();
const { data, isLoading } = useQuery({
queryKey: ["diagrams"],
queryFn: async () => {
const res = await api.diagrams.$get({ query: {} });
return await res.json();
},
});
const diagrams = (data?.data ?? []) as DiagramResponse[];
if (isLoading) {
return (
<div className="flex justify-center p-4">
<Icons.Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
if (diagrams.length === 0) {
return (
<p className="p-2 text-center text-xs text-muted-foreground">
No diagrams yet
</p>
);
}
return (
<div className="space-y-0.5">
{diagrams.map((d) => {
const config = diagramTypeConfig[d.type];
const TypeIcon = config.icon;
return (
<button
key={d.id}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent/50 cursor-pointer"
onClick={() => router.push(pathsConfig.dashboard.user.diagram(d.id))}
>
<TypeIcon className={`h-4 w-4 shrink-0 ${config.color}`} />
<span className="flex-1 truncate text-left">{d.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{d.updatedAt ? timeAgo(new Date(d.updatedAt)) : "just now"}
</span>
</button>
);
})}
</div>
);
}