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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{project.name}”?</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx
Normal file
203
apps/web/src/modules/diagram/components/sidebar/ProjectTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user