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:
@@ -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>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx
Normal file
33
apps/web/src/app/[locale]/dashboard/(user)/diagrams/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
131
apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx
Normal file
131
apps/web/src/modules/diagram/components/CreateDiagramDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web/src/modules/diagram/components/DiagramCard.tsx
Normal file
80
apps/web/src/modules/diagram/components/DiagramCard.tsx
Normal 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 };
|
||||
61
apps/web/src/modules/diagram/components/DiagramGrid.tsx
Normal file
61
apps/web/src/modules/diagram/components/DiagramGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/web/src/modules/diagram/components/EmptyDiagrams.tsx
Normal file
21
apps/web/src/modules/diagram/components/EmptyDiagrams.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user