diff --git a/prisma/migrations/20260405073828_add_folder_model/migration.sql b/prisma/migrations/20260405073828_add_folder_model/migration.sql new file mode 100644 index 00000000..b5ccd199 --- /dev/null +++ b/prisma/migrations/20260405073828_add_folder_model/migration.sql @@ -0,0 +1,25 @@ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "folderId" INTEGER; + +-- AlterTable +ALTER TABLE "Media" ADD COLUMN "folderId" INTEGER; + +-- CreateTable +CREATE TABLE "Folder" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "parentId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Folder_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Media" ADD CONSTRAINT "Media_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 57607e41..d158c8b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,8 @@ model Document { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt archivedAt DateTime? + folderId Int? + folder Folder? @relation("FolderDocuments", fields: [folderId], references: [id]) } model Version { @@ -35,6 +37,8 @@ model Media { size Int contentType String? createdAt DateTime @default(now()) + folderId Int? + folder Folder? @relation("FolderMedia", fields: [folderId], references: [id]) } model Route { @@ -44,4 +48,16 @@ model Route { document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +model Folder { + id Int @id @default(autoincrement()) + name String + parentId Int? + parent Folder? @relation("FolderNesting", fields: [parentId], references: [id]) + children Folder[] @relation("FolderNesting") + documents Document[] @relation("FolderDocuments") + media Media[] @relation("FolderMedia") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } \ No newline at end of file diff --git a/src/app/editor/DocumentList.tsx b/src/app/editor/DocumentList.tsx index c959439b..082cb1e7 100644 --- a/src/app/editor/DocumentList.tsx +++ b/src/app/editor/DocumentList.tsx @@ -1,30 +1,111 @@ "use client"; -import type { ReactNode } from "react"; +import { Fragment, type ReactNode } from "react"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { Archive, ArchiveRestore, ChevronDown, CopyPlus, FileText, Pencil } from "lucide-react"; +import { useEffect, useState, useTransition } from "react"; +import { + Archive, + ArchiveRestore, + ChevronDown, + ChevronRight, + CopyPlus, + FileText, + Folder as FolderIcon, + FolderInput, + Pencil, + Trash2, +} from "lucide-react"; import { archiveDocumentAction, createDocumentAction, duplicateDocumentAction, + moveDocumentAction, renameDocumentAction, unarchiveDocumentAction, } from "../../lib/documents/actions"; +import { + createFolderAction, + deleteFolderAction, + moveFolderAction, + renameFolderAction, +} from "../../lib/folders/actions"; import { runAction } from "./runAction"; import { getDocumentName } from "../../lib/documents/utils"; import { getEditorUrl } from "../../lib/editor-url"; import { ResourceCard, NewResourceCard } from "./ResourceCard"; +import { MoveToFolderDialog, getDescendantIds } from "./MoveToFolderDialog"; +import type { MoveTarget } from "./MoveToFolderDialog"; +import type { FolderSummary } from "@/lib/folders/queries"; import { formatRelativeTime } from "@/lib/format"; import { Button } from "@/components/ui/button"; import { useDialogs } from "@/components/ui/dialog-provider"; +import { Card, CardContent, CardFooter } from "@/components/ui/card"; import { Collapsible, CollapsibleTrigger, CollapsibleContent, } from "@/components/ui/collapsible"; -type DocumentItem = { id: number; name: string | null; lastModified: Date | null }; +type DocumentItem = { + id: number; + name: string | null; + lastModified: Date | null; + folderId: number | null; +}; + +// --- Breadcrumbs --- + +function FolderBreadcrumbs({ + folders, + currentFolderId, + onNavigate, +}: { + folders: FolderSummary[]; + currentFolderId: number | null; + onNavigate: (folderId: number | null) => void; +}) { + const path: { id: number | null; name: string }[] = [ + { id: null, name: "Documents" }, + ]; + + if (currentFolderId !== null) { + const folderMap = new Map(folders.map((f) => [f.id, f])); + const segments: { id: number; name: string }[] = []; + let current = folderMap.get(currentFolderId); + while (current) { + segments.unshift({ id: current.id, name: current.name }); + current = + current.parentId !== null + ? folderMap.get(current.parentId) + : undefined; + } + path.push(...segments); + } + + return ( + + ); +} + +// --- Document components --- function DocumentCard({ id, @@ -56,10 +137,17 @@ function useDocumentActions() { const { prompt, alert, confirm } = useDialogs(); async function handleRename(id: number, currentName: string) { - const newName = await prompt({ title: `Rename "${currentName}"`, label: "New name", defaultValue: currentName }); - if (newName === null || newName.trim() === "" || newName.trim() === currentName) return; + const newName = await prompt({ + title: `Rename "${currentName}"`, + label: "New name", + defaultValue: currentName, + }); + if (newName === null || newName.trim() === "" || newName.trim() === currentName) + return; startTransition(async () => { - const result = await runAction(renameDocumentAction({ id, name: newName.trim() })); + const result = await runAction( + renameDocumentAction({ id, name: newName.trim() }) + ); if (result.success) { router.refresh(); } else { @@ -69,7 +157,10 @@ function useDocumentActions() { } async function handleArchive(id: number, displayName: string) { - const confirmed = await confirm({ message: `Archive "${displayName}"?`, actionLabel: "Archive" }); + const confirmed = await confirm({ + message: `Archive "${displayName}"?`, + actionLabel: "Archive", + }); if (!confirmed) return; startTransition(async () => { const result = await runAction(archiveDocumentAction({ id })); @@ -82,7 +173,10 @@ function useDocumentActions() { } async function handleUnarchive(id: number, displayName: string) { - const confirmed = await confirm({ message: `Unarchive "${displayName}"?`, actionLabel: "Unarchive" }); + const confirmed = await confirm({ + message: `Unarchive "${displayName}"?`, + actionLabel: "Unarchive", + }); if (!confirmed) return; startTransition(async () => { const result = await runAction(unarchiveDocumentAction({ id })); @@ -102,7 +196,9 @@ function useDocumentActions() { }); if (name === null || name.trim() === "") return; startTransition(async () => { - const result = await runAction(duplicateDocumentAction({ id, name: name.trim() })); + const result = await runAction( + duplicateDocumentAction({ id, name: name.trim() }) + ); if (result.success) { router.refresh(); } else { @@ -114,7 +210,7 @@ function useDocumentActions() { return { isPending, handleRename, handleArchive, handleUnarchive, handleDuplicate }; } -function NewDocumentCard() { +function NewDocumentCard({ folderId }: { folderId: number | null }) { const router = useRouter(); const [isCreating, startTransition] = useTransition(); const { prompt, alert } = useDialogs(); @@ -122,19 +218,15 @@ function NewDocumentCard() { async function handleCreateDocument() { const name = await prompt({ title: "Create document", label: "Name" }); - if (name === null) { - return; - } + if (name === null) return; const trimmedName = name.trim(); - - if (!trimmedName) { - return; - } + if (!trimmedName) return; startTransition(async () => { const result = await createDocumentAction({ name: trimmedName, + folderId, }); if (result.success === false) { @@ -156,17 +248,247 @@ function NewDocumentCard() { ); } -export function DocumentList({ documents }: { documents: DocumentItem[] }) { - const { isPending, handleRename, handleArchive, handleDuplicate } = useDocumentActions(); +// --- Folder components --- + +function FolderCard({ + folder, + onOpen, + actions, +}: { + folder: FolderSummary; + onOpen: () => void; + actions?: ReactNode; +}) { + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + > +
+ +
+ + + {folder.name} + + {"\u00A0"} + {actions && ( + + {actions} + + )} + +
+ ); +} + +function NewFolderCard({ parentId }: { parentId: number | null }) { + const router = useRouter(); + const [isCreating, startTransition] = useTransition(); + const { prompt, alert } = useDialogs(); + + async function handleCreateFolder() { + const name = await prompt({ title: "Create folder", label: "Name" }); + if (name === null || name.trim() === "") return; + startTransition(async () => { + const result = await runAction( + createFolderAction({ name: name.trim(), parentId }) + ); + if (result.success) { + router.refresh(); + } else { + await alert(result.error); + } + }); + } + + return ( + + ); +} + +function useFolderActions(onNavigate: (id: number | null) => void) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const { prompt, alert, confirm } = useDialogs(); + + async function handleRenameFolder(id: number, currentName: string) { + const newName = await prompt({ + title: `Rename "${currentName}"`, + label: "New name", + defaultValue: currentName, + }); + if (newName === null || newName.trim() === "" || newName.trim() === currentName) + return; + startTransition(async () => { + const result = await runAction( + renameFolderAction({ id, name: newName.trim() }) + ); + if (result.success) { + router.refresh(); + } else { + await alert(result.error); + } + }); + } + + async function handleDeleteFolder(id: number, name: string, parentId: number | null) { + const confirmed = await confirm({ + message: `Delete folder "${name}"? Contents will be moved to the parent folder.`, + actionLabel: "Delete", + destructive: true, + }); + if (!confirmed) return; + startTransition(async () => { + const result = await runAction(deleteFolderAction({ id })); + if (result.success) { + onNavigate(parentId); + router.refresh(); + } else { + await alert(result.error); + } + }); + } + + return { isPending, handleRenameFolder, handleDeleteFolder }; +} + +// --- Main components --- + +export function DocumentList({ + documents, + folders, +}: { + documents: DocumentItem[]; + folders: FolderSummary[]; +}) { + const router = useRouter(); + const [currentFolderId, setCurrentFolderId] = useState(null); + const [moveTarget, setMoveTarget] = useState(null); + const [isMoving, startMoveTransition] = useTransition(); + + const { isPending, handleRename, handleArchive, handleDuplicate } = + useDocumentActions(); + const { + isPending: isFolderPending, + handleRenameFolder, + handleDeleteFolder, + } = useFolderActions(setCurrentFolderId); + + const { alert } = useDialogs(); + + // Reset to root if current folder no longer exists (e.g. after deletion) + useEffect(() => { + if ( + currentFolderId !== null && + !folders.some((f) => f.id === currentFolderId) + ) { + setCurrentFolderId(null); + } + }, [currentFolderId, folders]); + + const currentDocuments = documents.filter( + (d) => d.folderId === currentFolderId + ); + const currentSubfolders = folders.filter( + (f) => f.parentId === currentFolderId + ); + + function handleMoveConfirm(target: MoveTarget, folderId: number | null) { + startMoveTransition(async () => { + const result = + target.type === "document" + ? await runAction(moveDocumentAction({ id: target.id, folderId })) + : await runAction(moveFolderAction({ id: target.id, parentId: folderId })); + if (result.success) { + router.refresh(); + } else { + await alert(result.error); + } + }); + } + + const actionDisabled = isPending || isFolderPending || isMoving; return (
-

Documents

+
- + + + + {currentSubfolders.map((folder) => ( + setCurrentFolderId(folder.id)} + actions={ + <> + + + + + } + /> + ))} - {documents.map((doc) => { + {currentDocuments.map((doc) => { const displayName = getDocumentName(doc); return ( @@ -197,11 +519,29 @@ export function DocumentList({ documents }: { documents: DocumentItem[] }) { e.stopPropagation(); handleDuplicate(doc.id, displayName); }} - disabled={isPending} + disabled={actionDisabled} title="Duplicate" > +
+ + {moveTarget && ( + { + handleMoveConfirm(moveTarget, folderId); + setMoveTarget(null); + }} + onCancel={() => setMoveTarget(null)} + /> + )}
); } -export function ArchivedDocumentList({ documents }: { documents: DocumentItem[] }) { +export function ArchivedDocumentList({ + documents, +}: { + documents: DocumentItem[]; +}) { const [open, setOpen] = useState(false); const { isPending, handleUnarchive } = useDocumentActions(); diff --git a/src/app/editor/MoveToFolderDialog.tsx b/src/app/editor/MoveToFolderDialog.tsx new file mode 100644 index 00000000..ca8dde4b --- /dev/null +++ b/src/app/editor/MoveToFolderDialog.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; +import { Folder as FolderIcon, ChevronRight, ChevronDown } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import type { FolderSummary } from "@/lib/folders/queries"; + +export type MoveTarget = { + type: "document" | "folder"; + id: number; + name: string; + currentFolderId: number | null; +}; + +export function MoveToFolderDialog({ + target, + folders, + excludeFolderIds, + onSelect, + onCancel, +}: { + target: MoveTarget; + folders: FolderSummary[]; + excludeFolderIds: Set; + onSelect: (folderId: number | null) => void; + onCancel: () => void; +}) { + const [selected, setSelected] = useState(target.currentFolderId); + const [expanded, setExpanded] = useState>(() => { + // Auto-expand the path to the current folder + const set = new Set(); + const folderMap = new Map(folders.map((f) => [f.id, f])); + let current = target.currentFolderId !== null ? folderMap.get(target.currentFolderId) : undefined; + while (current) { + if (current.parentId !== null) set.add(current.parentId); + current = current.parentId !== null ? folderMap.get(current.parentId) : undefined; + } + return set; + }); + + const canSubmit = selected !== target.currentFolderId; + + function toggleExpanded(id: number) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function renderTree(parentId: number | null, depth: number) { + const children = folders + .filter((f) => f.parentId === parentId && !excludeFolderIds.has(f.id)); + + if (children.length === 0) return null; + + return children.map((folder) => { + const hasChildren = folders.some( + (f) => f.parentId === folder.id && !excludeFolderIds.has(f.id) + ); + const isExpanded = expanded.has(folder.id); + const isSelected = selected === folder.id; + + return ( +
+ + ) : ( + + )} + + {folder.name} + + {isExpanded && renderTree(folder.id, depth + 1)} +
+ ); + }); + } + + return ( + onCancel()}> + + + Move “{target.name}” + + +
+ + {renderTree(null, 1)} +
+ + + + + +
+
+ ); +} + +export function getDescendantIds( + folders: FolderSummary[], + folderId: number +): Set { + const result = new Set([folderId]); + let added = true; + while (added) { + added = false; + for (const f of folders) { + if (f.parentId !== null && result.has(f.parentId) && !result.has(f.id)) { + result.add(f.id); + added = true; + } + } + } + return result; +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 32845077..719d1ffa 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -1,5 +1,6 @@ import { getDocumentSummaries } from "../../lib/documents/queries"; import { getDocumentName } from "../../lib/documents/utils"; +import { getAllFolders } from "../../lib/folders/queries"; import { getMediaFiles } from "../../lib/media/queries"; import { getRoutesWithDocuments } from "../../lib/routes/queries"; import { DocumentList, ArchivedDocumentList } from "./DocumentList"; @@ -7,16 +8,18 @@ import { MediaLibrary } from "./MediaLibrary"; import { RouteTable } from "./RouteTable"; export default async function EditorIndexPage() { - const [routes, mediaFiles, documents] = await Promise.all([ + const [routes, mediaFiles, documents, folders] = await Promise.all([ getRoutesWithDocuments(), getMediaFiles(), getDocumentSummaries(), + getAllFolders(), ]); const toDocumentItem = (doc: (typeof documents)[number]) => ({ id: doc.id, name: doc.name, lastModified: doc.versions[0]?.createdAt ?? null, + folderId: doc.folderId, }); const activeDocuments = documents.filter((doc) => doc.archivedAt === null).map(toDocumentItem); @@ -38,7 +41,7 @@ export default async function EditorIndexPage() { }))} /> - + diff --git a/src/lib/documents/actions.ts b/src/lib/documents/actions.ts index 940a215a..b0d7b4dc 100644 --- a/src/lib/documents/actions.ts +++ b/src/lib/documents/actions.ts @@ -10,6 +10,7 @@ import type { ArchiveDocumentInput, CreateDocumentInput, DuplicateDocumentInput, + MoveDocumentInput, RenameInput, SaveVersionInput, PublishVersionInput, @@ -64,11 +65,12 @@ async function publishVersion( async function createDocumentWithVersion( name: string, content: Data, - publish: boolean = false + publish: boolean = false, + folderId?: number | null, ) { return await prisma.$transaction(async (tx) => { const document = await tx.document.create({ - data: { name }, + data: { name, folderId: folderId ?? null }, }); const version = await tx.version.create({ @@ -95,7 +97,7 @@ export async function createDocumentAction( return wrapAction(async () => { const name = validateName(input.name); const content = input.content ?? createEmptyPuckData(); - const document = await createDocumentWithVersion(name, content, false); + const document = await createDocumentWithVersion(name, content, false, input.folderId); return { documentId: document.id }; }); } @@ -181,3 +183,15 @@ export async function renameDocumentAction( }); }); } + +export async function moveDocumentAction( + input: MoveDocumentInput +): Promise> { + return wrapAction(async () => { + await fetchAndAssertNotArchived(input.id); + await prisma.document.update({ + where: { id: input.id }, + data: { folderId: input.folderId }, + }); + }); +} diff --git a/src/lib/folders/actions.ts b/src/lib/folders/actions.ts new file mode 100644 index 00000000..29cc65e9 --- /dev/null +++ b/src/lib/folders/actions.ts @@ -0,0 +1,108 @@ +"use server"; + +import { prisma } from "../prisma"; +import { validateName } from "../utils"; +import { wrapAction } from "../utils"; +import type { + ActionResult, + CreateFolderInput, + RenameFolderInput, + MoveFolderInput, + DeleteFolderInput, +} from "../types"; + +export async function createFolderAction( + input: CreateFolderInput +): Promise> { + return wrapAction(async () => { + const name = validateName(input.name); + const folder = await prisma.folder.create({ + data: { + name, + parentId: input.parentId ?? null, + }, + }); + return { folderId: folder.id }; + }); +} + +export async function renameFolderAction( + input: RenameFolderInput +): Promise> { + return wrapAction(async () => { + const name = validateName(input.name); + await prisma.folder.update({ + where: { id: input.id }, + data: { name }, + }); + }); +} + +export async function moveFolderAction( + input: MoveFolderInput +): Promise> { + return wrapAction(async () => { + if (input.parentId === input.id) { + throw new Error("A folder cannot be moved into itself"); + } + + if (input.parentId !== null) { + const isDescendant = await checkIsDescendant(input.parentId, input.id); + if (isDescendant) { + throw new Error("A folder cannot be moved into one of its descendants"); + } + } + + await prisma.folder.update({ + where: { id: input.id }, + data: { parentId: input.parentId }, + }); + }); +} + +export async function deleteFolderAction( + input: DeleteFolderInput +): Promise> { + return wrapAction(async () => { + const folder = await prisma.folder.findUniqueOrThrow({ + where: { id: input.id }, + select: { parentId: true }, + }); + + // Move all contents to the parent folder (or root) + await prisma.$transaction([ + prisma.document.updateMany({ + where: { folderId: input.id }, + data: { folderId: folder.parentId }, + }), + prisma.media.updateMany({ + where: { folderId: input.id }, + data: { folderId: folder.parentId }, + }), + prisma.folder.updateMany({ + where: { parentId: input.id }, + data: { parentId: folder.parentId }, + }), + prisma.folder.delete({ + where: { id: input.id }, + }), + ]); + }); +} + +async function checkIsDescendant( + candidateId: number, + ancestorId: number +): Promise { + const result = await prisma.$queryRaw<{ id: number }[]>` + WITH RECURSIVE ancestors AS ( + SELECT "id", "parentId" FROM "Folder" WHERE "id" = ${candidateId} + UNION ALL + SELECT f."id", f."parentId" + FROM "Folder" f + INNER JOIN ancestors a ON a."parentId" = f."id" + ) + SELECT "id" FROM ancestors WHERE "id" = ${ancestorId} LIMIT 1 + `; + return result.length > 0; +} diff --git a/src/lib/folders/queries.ts b/src/lib/folders/queries.ts new file mode 100644 index 00000000..0ffdf421 --- /dev/null +++ b/src/lib/folders/queries.ts @@ -0,0 +1,18 @@ +import { prisma } from "../prisma"; + +export type FolderSummary = { + id: number; + name: string; + parentId: number | null; +}; + +export async function getAllFolders(): Promise { + return await prisma.folder.findMany({ + select: { + id: true, + name: true, + parentId: true, + }, + orderBy: { name: "asc" }, + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ac28adef..6ca4f937 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -13,6 +13,7 @@ export type ActionResult = export type CreateDocumentInput = { name: string; content?: Data; + folderId?: number | null; }; export type SaveVersionInput = { @@ -57,3 +58,27 @@ export type DuplicateDocumentInput = { id: number; name: string; }; + +export type CreateFolderInput = { + name: string; + parentId?: number | null; +}; + +export type RenameFolderInput = { + id: number; + name: string; +}; + +export type MoveFolderInput = { + id: number; + parentId: number | null; +}; + +export type DeleteFolderInput = { + id: number; +}; + +export type MoveDocumentInput = { + id: number; + folderId: number | null; +};