feat: implement Story 1.1 — create and view diagrams

Add diagram/project DB schema, CRUD API, dashboard pages with grid/card/
empty state, create dialog with type selector, editor placeholder, and
26 schema validation tests. Includes code review fixes: soft-delete
filter on GET /:id, error handling, keyboard accessibility, type-safe
API response types, and error states on pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-22 23:54:50 +00:00
parent da3368fbdb
commit 392da385f4
20 changed files with 3785 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Icons } from "@turbostarter/ui-web/icons";
import { api } from "~/lib/api/client";
export default function DiagramEditorPage() {
const params = useParams<{ id: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ["diagram", params.id],
queryFn: async () => {
const res = await api.diagrams[":id"].$get({ param: { id: params.id } });
return await res.json();
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Icons.Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<Icons.AlertTriangle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground">
Failed to load diagram. It may have been deleted or you don't have access.
</p>
</div>
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<Icons.LayoutDashboard className="h-16 w-16 text-muted-foreground/30" />
<div className="text-center">
<h1 className="text-xl font-semibold">{data?.data?.title ?? "Diagram"}</h1>
<p className="mt-2 text-sm text-muted-foreground">
The diagram editor canvas will be implemented in Epic 2.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
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";
export default function DiagramsPage() {
const { data, isLoading, isError } = useQuery({
queryKey: ["diagrams"],
queryFn: async () => {
const res = await api.diagrams.$get();
return await res.json();
},
});
if (isError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6">
<Icons.AlertTriangle className="h-8 w-8 text-destructive" />
<p className="text-sm text-muted-foreground">
Failed to load diagrams. Please try again later.
</p>
</div>
);
}
return (
<div className="@container h-full p-6">
<DiagramGrid diagrams={data?.data ?? []} isLoading={isLoading} />
</div>
);
}

View File

@@ -77,6 +77,8 @@ const pathsConfig = {
index: DASHBOARD_PREFIX,
ai: `${DASHBOARD_PREFIX}/ai`,
vocabulary: `${DASHBOARD_PREFIX}/vocabulary`,
diagrams: `${DASHBOARD_PREFIX}/diagrams`,
diagram: (id: string) => `${DASHBOARD_PREFIX}/diagram/${id}`,
settings: {
index: `${DASHBOARD_PREFIX}/settings`,
security: `${DASHBOARD_PREFIX}/settings/security`,

View File

@@ -0,0 +1,131 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@turbostarter/ui-web/dialog";
import { Button } from "@turbostarter/ui-web/button";
import { Input } from "@turbostarter/ui-web/input";
import { Icons } from "@turbostarter/ui-web/icons";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { pathsConfig } from "~/config/paths";
import { diagramTypeConfig } from "./DiagramCard";
import type { ReactNode } from "react";
const diagramTypes = ["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"] as const;
type DiagramType = (typeof diagramTypes)[number];
interface CreateDiagramDialogProps {
children: ReactNode;
}
export function CreateDiagramDialog({ children }: CreateDiagramDialogProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [selectedType, setSelectedType] = useState<DiagramType>("flowchart");
const router = useRouter();
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: async (input: { title: string; type: DiagramType }) => {
const res = await api.diagrams.$post({ json: input });
return await res.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
setOpen(false);
setTitle("");
setSelectedType("flowchart");
if (data.data) {
router.push(pathsConfig.dashboard.user.diagram(data.data.id));
}
},
onError: () => {
toast.error("Failed to create diagram. Please try again.");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
createMutation.mutate({ title: title.trim(), type: selectedType });
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create New Diagram</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label htmlFor="diagram-title" className="text-sm font-medium">
Title
</label>
<Input
id="diagram-title"
placeholder="My diagram"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Diagram Type</label>
<div className="grid grid-cols-3 gap-2">
{diagramTypes.map((type) => {
const config = diagramTypeConfig[type];
const TypeIcon = config.icon;
const isSelected = selectedType === type;
return (
<button
key={type}
type="button"
onClick={() => setSelectedType(type)}
className={`flex flex-col items-center gap-1.5 rounded-lg border-2 p-3 text-sm transition-colors ${
isSelected
? "border-primary bg-primary/5"
: "border-transparent bg-muted/50 hover:bg-muted"
}`}
>
<TypeIcon className={`h-5 w-5 ${config.color}`} />
<span className="font-medium">{config.label}</span>
</button>
);
})}
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={!title.trim() || createMutation.isPending}
>
{createMutation.isPending && (
<Icons.Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,80 @@
import { Card, CardContent, CardHeader, CardTitle } from "@turbostarter/ui-web/card";
import { Badge } from "@turbostarter/ui-web/badge";
import { Icons } from "@turbostarter/ui-web/icons";
import type { SelectDiagram } from "@turbostarter/db/schema";
export type DiagramResponse = Omit<SelectDiagram, "createdAt" | "updatedAt" | "deletedAt"> & {
createdAt: string | null;
updatedAt: string | null;
deletedAt: string | null;
};
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";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years}y ago`;
}
const diagramTypeConfig = {
bpmn: { label: "BPMN", icon: Icons.Workflow, color: "text-blue-500" },
er: { label: "E-R", icon: Icons.Database, color: "text-violet-500" },
orgchart: { label: "Org Chart", icon: Icons.UsersRound, color: "text-green-500" },
architecture: { label: "Architecture", icon: Icons.Server, color: "text-neutral-500" },
sequence: { label: "Sequence", icon: Icons.ArrowRightLeft, color: "text-amber-500" },
flowchart: { label: "Flowchart", icon: Icons.GitBranch, color: "text-rose-500" },
} as const;
interface DiagramCardProps {
diagram: DiagramResponse;
onClick?: () => void;
}
export function DiagramCard({ diagram, onClick }: DiagramCardProps) {
const config = diagramTypeConfig[diagram.type];
const TypeIcon = config.icon;
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
onClick={onClick}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium truncate">{diagram.title}</CardTitle>
<TypeIcon className={`h-4 w-4 shrink-0 ${config.color}`} />
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">
{config.label}
</Badge>
<span className="text-xs text-muted-foreground">
{diagram.updatedAt
? timeAgo(new Date(diagram.updatedAt))
: "just now"}
</span>
</div>
</CardContent>
</Card>
);
}
export { diagramTypeConfig };

View File

@@ -0,0 +1,61 @@
"use client";
import { useRouter } from "next/navigation";
import { DiagramCard } from "./DiagramCard";
import { EmptyDiagrams } from "./EmptyDiagrams";
import { CreateDiagramDialog } from "./CreateDiagramDialog";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import type { DiagramResponse } from "./DiagramCard";
interface DiagramGridProps {
diagrams: DiagramResponse[];
isLoading: boolean;
}
export function DiagramGrid({ diagrams, isLoading }: DiagramGridProps) {
const router = useRouter();
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Icons.Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Diagrams</h1>
<p className="text-muted-foreground">
Create and manage your system diagrams
</p>
</div>
<CreateDiagramDialog>
<Button>
<Icons.Plus className="mr-2 h-4 w-4" />
New Diagram
</Button>
</CreateDiagramDialog>
</div>
{diagrams.length === 0 ? (
<EmptyDiagrams />
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{diagrams.map((diagram) => (
<DiagramCard
key={diagram.id}
diagram={diagram}
onClick={() => router.push(pathsConfig.dashboard.user.diagram(diagram.id))}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { Icons } from "@turbostarter/ui-web/icons";
import { CreateDiagramDialog } from "./CreateDiagramDialog";
import { Button } from "@turbostarter/ui-web/button";
export function EmptyDiagrams() {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<Icons.LayoutDashboard className="h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-semibold">No diagrams yet</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm">
Create your first diagram to start designing system architectures, workflows, and more with AI assistance.
</p>
<CreateDiagramDialog>
<Button className="mt-6">
<Icons.Plus className="mr-2 h-4 w-4" />
Create your first diagram
</Button>
</CreateDiagramDialog>
</div>
);
}