feat: implement Story 1.4 — recent view and drag-and-drop organization

Add sortOrder column to diagrams, extend PATCH endpoint with projectId
and sortOrder fields, add POST /diagrams/reorder bulk endpoint with
ownership verification and duplicate ID validation. Enhance RecentList
with lastAiMessage preview subtitle. Implement @dnd-kit drag-and-drop
in ProjectTree sidebar with cross-project moves, intra-project reorder,
optimistic updates, drag overlay, drop indicators, and keyboard/pointer
sensor support. Context-aware GET ordering: sortOrder for project views,
updatedAt for recent/all views. 141 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 23:21:09 +00:00
parent e9cd685d3d
commit 098f4968be
14 changed files with 3366 additions and 363 deletions

View File

@@ -16,6 +16,9 @@
"dependencies": {
"@ai-sdk/react": "2.0.86",
"@anaralabs/lector": "3.7.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@formatjs/intl-localematcher": "0.6.2",
"@hookform/resolvers": "5.2.2",
"@next/bundle-analyzer": "16.0.10",

View File

@@ -3,6 +3,24 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
DragOverlay,
KeyboardSensor,
closestCenter,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { toast } from "sonner";
@@ -12,18 +30,86 @@ import { diagramTypeConfig } from "../DiagramCard";
import { ProjectContextMenu } from "./ProjectContextMenu";
import type { DiagramResponse } from "../DiagramCard";
import type { DragStartEvent, DragEndEvent, DragOverEvent } from "@dnd-kit/core";
interface ProjectTreeProps {
selectedProjectId: string | null;
onSelectProject: (projectId: string | null) => void;
}
function SortableDiagramItem({
diagram: d,
onClick,
}: {
diagram: DiagramResponse;
onClick: () => void;
}) {
const config = diagramTypeConfig[d.type];
const TypeIcon = config.icon;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({
id: d.id,
data: { type: "diagram", diagram: d, sourceProjectId: d.projectId },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-accent/50 cursor-grab active:cursor-grabbing"
onClick={onClick}
>
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
<span className="truncate">{d.title}</span>
</button>
);
}
function DroppableProject({
projectId,
isOver,
children,
}: {
projectId: string | null;
isOver: boolean;
children: React.ReactNode;
}) {
const { setNodeRef } = useDroppable({
id: `project-${projectId ?? "unorganized"}`,
data: { type: "project", projectId },
});
return (
<div
ref={setNodeRef}
className={isOver ? "rounded-md ring-2 ring-primary/50" : ""}
>
{children}
</div>
);
}
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 [activeDiagram, setActiveDiagram] = useState<DiagramResponse | null>(null);
const [overProjectId, setOverProjectId] = useState<string | null | undefined>(undefined);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const { data: projectsData } = useQuery({
queryKey: ["projects"],
@@ -41,6 +127,39 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
},
});
const moveDiagramMutation = useMutation({
mutationFn: async ({ id, projectId }: { id: string; projectId: string | null }) => {
const res = await api.diagrams[":id"].$patch({
param: { id },
json: { projectId },
});
if (!res.ok) throw new Error("Failed to move diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
},
onError: () => {
toast.error("Failed to move diagram");
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
},
});
const reorderMutation = useMutation({
mutationFn: async (items: { id: string; sortOrder: number }[]) => {
const res = await api.diagrams.reorder.$post({ json: { items } });
if (!res.ok) throw new Error("Failed to reorder");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
},
onError: () => {
toast.error("Failed to reorder diagrams");
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
},
});
const renameMutation = useMutation({
mutationFn: async ({ id, name }: { id: string; name: string }) => {
const res = await api.projects[":id"].$patch({
@@ -63,7 +182,7 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
const diagrams = (allDiagrams?.data ?? []) as DiagramResponse[];
const toggleExpand = (projectId: string) => {
setExpandedProjects(prev => {
setExpandedProjects((prev) => {
const next = new Set(prev);
if (next.has(projectId)) next.delete(projectId);
else next.add(projectId);
@@ -72,9 +191,9 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
};
const getDiagramsForProject = (projectId: string) =>
diagrams.filter(d => d.projectId === projectId);
diagrams.filter((d) => d.projectId === projectId);
const unorganizedCount = diagrams.filter(d => !d.projectId).length;
const unorganizedDiagrams = diagrams.filter((d) => !d.projectId);
const handleRename = (projectId: string, originalName: string) => {
if (!renameName.trim() || renameName.trim() === originalName) {
@@ -89,115 +208,261 @@ export function ProjectTree({ selectedProjectId, onSelectProject }: ProjectTreeP
isActive ? "bg-accent text-accent-foreground" : ""
}`;
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current;
if (data?.type === "diagram") {
setActiveDiagram(data.diagram as DiagramResponse);
}
}
function handleDragOver(event: DragOverEvent) {
const over = event.over;
if (!over) {
setOverProjectId(undefined);
return;
}
const overData = over.data.current;
if (overData?.type === "project") {
setOverProjectId(overData.projectId as string | null);
} else if (overData?.type === "diagram") {
setOverProjectId(
(overData.diagram as DiagramResponse).projectId ?? undefined,
);
} else {
setOverProjectId(undefined);
}
}
function handleDragEnd(event: DragEndEvent) {
setActiveDiagram(null);
setOverProjectId(undefined);
const { active, over } = event;
if (!over || !active.data.current) return;
const activeData = active.data.current;
const overData = over.data.current;
if (activeData.type !== "diagram") return;
const draggedDiagram = activeData.diagram as DiagramResponse;
const sourceProjectId = activeData.sourceProjectId as string | null;
// Cross-project drag: dropped on a project droppable
if (overData?.type === "project") {
const targetProjectId = overData.projectId as string | null;
if (sourceProjectId !== targetProjectId) {
// Optimistic update
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
if (!old) return old;
return {
...old,
data: old.data.map((d) =>
d.id === draggedDiagram.id ? { ...d, projectId: targetProjectId } : d,
),
};
});
moveDiagramMutation.mutate({ id: draggedDiagram.id, projectId: targetProjectId });
}
return;
}
// Intra-project reorder: dropped on another diagram in same project
if (overData?.type === "diagram") {
const overDiagram = overData.diagram as DiagramResponse;
const targetProjectId = overDiagram.projectId;
if (sourceProjectId !== targetProjectId) {
// Cross-project via dropping on diagram item
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
if (!old) return old;
return {
...old,
data: old.data.map((d) =>
d.id === draggedDiagram.id ? { ...d, projectId: targetProjectId } : d,
),
};
});
moveDiagramMutation.mutate({ id: draggedDiagram.id, projectId: targetProjectId });
return;
}
// Same project reorder
const projectDiagrams = diagrams.filter(
(d) => d.projectId === sourceProjectId,
);
const oldIndex = projectDiagrams.findIndex((d) => d.id === active.id);
const newIndex = projectDiagrams.findIndex((d) => d.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(projectDiagrams, oldIndex, newIndex);
const items = reordered.map((d, i) => ({ id: d.id, sortOrder: i }));
// Optimistic update
queryClient.setQueryData(["diagrams"], (old: { data: DiagramResponse[] } | undefined) => {
if (!old) return old;
const sortMap = new Map(items.map((item) => [item.id, item.sortOrder]));
return {
...old,
data: old.data.map((d) =>
sortMap.has(d.id) ? { ...d, sortOrder: sortMap.get(d.id)! } : d,
),
};
});
reorderMutation.mutate(items);
}
}
}
const dragOverlayDiagram = activeDiagram;
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>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<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>
{/* Unorganized — droppable */}
<DroppableProject
projectId={null}
isOver={overProjectId === null}
>
<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">
{unorganizedDiagrams.length}
</span>
</button>
</DroppableProject>
{/* Separator */}
{projects.length > 0 && <div className="my-2 border-t" />}
{/* 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;
{/* 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>
) : (
return (
<DroppableProject
key={proj.id}
projectId={proj.id}
isOver={overProjectId === proj.id}
>
<div className="group flex items-center">
<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);
className="p-0.5 hover:bg-accent rounded"
onClick={(e) => {
e.stopPropagation();
toggleExpand(proj.id);
}}
/>
)}
</div>
>
{isExpanded ? (
<Icons.ChevronDown className="h-3 w-3" />
) : (
<Icons.ChevronRight className="h-3 w-3" />
)}
</button>
{/* 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>
);
})}
{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 with sortable */}
{isExpanded && projectDiagrams.length > 0 && (
<div className="ml-6 space-y-0.5">
<SortableContext
items={projectDiagrams.map((d) => d.id)}
strategy={verticalListSortingStrategy}
>
{projectDiagrams.map((d) => (
<SortableDiagramItem
key={d.id}
diagram={d}
onClick={() =>
router.push(pathsConfig.dashboard.user.diagram(d.id))
}
/>
))}
</SortableContext>
</div>
)}
</DroppableProject>
);
})}
</div>
{/* Drag overlay */}
<DragOverlay>
{dragOverlayDiagram ? (
<div className="flex items-center gap-2 rounded-md bg-background/90 px-2 py-1 text-xs shadow-md ring-1 ring-border">
{(() => {
const config = diagramTypeConfig[dragOverlayDiagram.type];
const TypeIcon = config.icon;
return (
<>
<TypeIcon className={`h-3 w-3 shrink-0 ${config.color}`} />
<span className="truncate">{dragOverlayDiagram.title}</span>
</>
);
})()}
</div>
);
})}
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -46,12 +46,21 @@ export function RecentList() {
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"
className="flex w-full items-start 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">
<TypeIcon className={`h-4 w-4 shrink-0 mt-0.5 ${config.color}`} />
<div className="flex-1 min-w-0">
<span className="block truncate text-left">{d.title}</span>
{d.lastAiMessage && (
<span className="block truncate text-xs text-muted-foreground">
{d.lastAiMessage.length > 60
? `${d.lastAiMessage.slice(0, 60)}...`
: d.lastAiMessage}
</span>
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground mt-0.5">
{d.updatedAt ? timeAgo(new Date(d.updatedAt)) : "just now"}
</span>
</button>