diff --git a/src/app/api/projects/bulk/route.ts b/src/app/api/projects/bulk/route.ts new file mode 100644 index 0000000..e2bcf6e --- /dev/null +++ b/src/app/api/projects/bulk/route.ts @@ -0,0 +1,150 @@ +import { NextResponse } from "next/server"; +import type { Prisma, ProjectStatus } from "@prisma/client"; +import { describeStatus, recordActivities } from "@/lib/activity"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request"; +import { formatZodError, projectBulkSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Dieselbe Aenderung fuer mehrere Projekte auf einmal. + * + * Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat + * zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der + * Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls. + */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const limited = rateLimit( + `mutate:${clientIp(request)}`, + LIMITS.mutation.limit, + LIMITS.mutation.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = projectBulkSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { ids, action } = parsed.data; + + // Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht + // erst eingesammelt und fallen damit still unter den Tisch. + const owned = await prisma.project.findMany({ + where: { ownerId: auth.user.id, id: { in: ids } }, + select: { id: true, status: true, tags: true, favorite: true, updatedAt: true }, + }); + + if (owned.length === 0) { + return NextResponse.json({ changed: 0, ignored: ids.length }); + } + + const eigene = owned.map((project) => project.id); + const entries: Parameters[0] = []; + + if (action === "delete") { + const result = await prisma.project.deleteMany({ + where: { ownerId: auth.user.id, id: { in: eigene } }, + }); + return NextResponse.json({ changed: result.count, ignored: ids.length - result.count }); + } + + const updates: Prisma.PrismaPromise[] = []; + + // Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe + // immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und + // alle Verschobenen landeten uebereinander auf derselben Position. + let position = + action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0; + + for (const project of owned) { + if (action === "status") { + const ziel = parsed.data.status!; + if (project.status === ziel) continue; + updates.push( + prisma.project.update({ + where: { id: project.id }, + // Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch. + data: { status: ziel, position: position++ }, + }), + ); + entries.push({ + projectId: project.id, + userId: auth.user.id, + kind: "STATUS_CHANGED", + summary: describeStatus(project.status, ziel), + meta: { from: project.status, to: ziel, bulk: true }, + }); + continue; + } + + if (action === "favorite") { + const ziel = parsed.data.favorite!; + if (project.favorite === ziel) continue; + updates.push( + prisma.project.update({ + where: { id: project.id }, + // Ein Stern ist keine inhaltliche Aenderung. + data: { favorite: ziel, updatedAt: project.updatedAt }, + }), + ); + continue; + } + + // action === "tags" + const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase()); + const naechste = + parsed.data.tagMode === "add" + ? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12) + : project.tags.filter((tag) => !gewuenscht.includes(tag)); + + if (naechste.length === project.tags.length && + naechste.every((tag, index) => tag === project.tags[index])) { + continue; + } + + updates.push( + prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }), + ); + entries.push({ + projectId: project.id, + userId: auth.user.id, + kind: "PROJECT_UPDATED", + summary: "Tags geändert", + meta: { tags: naechste, bulk: true }, + }); + } + + if (updates.length > 0) { + await prisma.$transaction(updates); + await recordActivities(entries); + } + + return NextResponse.json({ + changed: updates.length, + // Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert. + ignored: ids.length - updates.length, + }); +} + +/** Naechste freie Position am Ende einer Statusspalte. */ +async function nextPosition(ownerId: string, status: ProjectStatus) { + const last = await prisma.project.findFirst({ + where: { ownerId, status }, + orderBy: { position: "desc" }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; +} diff --git a/src/components/BulkBar.tsx b/src/components/BulkBar.tsx new file mode 100644 index 0000000..c4b3e15 --- /dev/null +++ b/src/components/BulkBar.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Star, Tag, Trash2, X } from "lucide-react"; +import type { ProjectStatus } from "@prisma/client"; +import { Popover } from "@/components/ui/Popover"; +import { STATUS_META, STATUS_ORDER } from "@/lib/status"; +import { cn, parseTags } from "@/lib/utils"; + +/** + * Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist, + * und liegt fest am unteren Rand – bei einer langen Liste waere sie oben + * sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt. + */ +export function BulkBar({ + ids, + onDone, + onClear, +}: { + ids: string[]; + onDone: () => void; + onClear: () => void; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [statusOpen, setStatusOpen] = useState(false); + const [tagsOpen, setTagsOpen] = useState(false); + const [tagInput, setTagInput] = useState(""); + const statusRef = useRef(null); + const tagsRef = useRef(null); + + async function apply(body: Record) { + setBusy(true); + setError(null); + try { + const response = await fetch("/api/projects/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids, ...body }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt."); + onDone(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + setStatusOpen(false); + setTagsOpen(false); + } + } + + async function remove() { + if ( + !window.confirm( + `${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` + + "unwiderruflich loeschen?", + ) + ) { + return; + } + await apply({ action: "delete" }); + } + + return ( +
+
+ + {ids.length} ausgewählt + + + + + + setStatusOpen(false)} + align="start" + > +
+ {STATUS_ORDER.map((status) => ( + + ))} +
+
+ + + setTagsOpen(false)} align="start"> +
+ + setTagInput(event.target.value)} + placeholder="experiment, archiv" + autoFocus + /> +
+ + +
+
+
+ + + + + + {busy ? : null} + + + + {error ? ( +

{error}

+ ) : null} +
+
+ ); +} diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index 4ce84e4..328a5ab 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -16,6 +16,7 @@ import { X, } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; +import { BulkBar } from "@/components/BulkBar"; import { ImportDialog } from "@/components/ImportDialog"; import { KanbanBoard } from "@/components/KanbanBoard"; import { ProjectCard } from "@/components/ProjectCard"; @@ -54,6 +55,11 @@ function highlight(snippet: string): React.ReactNode[] { type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type ViewMode = "grid" | "grouped" | "kanban"; +const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"]; + +/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */ +const ANSICHT_SCHLUESSEL = "nebula.board"; + const SORT_LABEL: Record = { updated: "Zuletzt geaendert", created: "Neueste zuerst", @@ -74,11 +80,52 @@ export function ProjectBoard({ const [sort, setSort] = useState("updated"); const [view, setView] = useState("grid"); const [showArchived, setShowArchived] = useState(false); + + /** + * Ansicht und Sortierung ueberdauern das Neuladen. + * + * Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch + * sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern + * gelesen: der Server kennt den gespeicherten Wert nicht, und ein + * Unterschied zwischen beiden waere ein Hydrationsfehler. + * + * Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen + * wiederherzustellen waere eher verwirrend als hilfreich. + */ + useEffect(() => { + try { + const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL); + if (!gespeichert) return; + const wert = JSON.parse(gespeichert) as Partial<{ + view: ViewMode; + sort: SortKey; + showArchived: boolean; + }>; + if (wert.view && VIEWS.includes(wert.view)) setView(wert.view); + if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort); + if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived); + } catch { + // Kaputter oder gesperrter Speicher: dann eben die Voreinstellung. + } + }, []); + + useEffect(() => { + try { + window.localStorage.setItem( + ANSICHT_SCHLUESSEL, + JSON.stringify({ view, sort, showArchived }), + ); + } catch { + // Im privaten Modus mancher Browser nicht beschreibbar – kein Grund, + // die Seite scheitern zu lassen. + } + }, [view, sort, showArchived]); const [dialogOpen, setDialogOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false); const dataButtonRef = useRef(null); const [noteHits, setNoteHits] = useState([]); + const [selection, setSelection] = useState>(new Set()); /** * Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite @@ -174,6 +221,28 @@ export function ProjectBoard({ })).filter((group) => group.items.length > 0); }, [visible]); + /** + * Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine + * Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. + */ + useEffect(() => { + setSelection((current) => { + if (current.size === 0) return current; + const sichtbar = new Set(visible.map((project) => project.id)); + const next = new Set([...current].filter((id) => sichtbar.has(id))); + return next.size === current.size ? current : next; + }); + }, [visible]); + + function toggleSelect(id: string, selected: boolean) { + setSelection((current) => { + const next = new Set(current); + if (selected) next.add(id); + else next.delete(id); + return next; + }); + } + function toggleStatus(status: ProjectStatus) { setStatusFilter((current) => current.includes(status) @@ -218,6 +287,14 @@ export function ProjectBoard({ className="field w-auto min-w-[11rem]" value={sort} onChange={(event) => setSort(event.target.value as SortKey)} + // Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier + // stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend. + disabled={view === "kanban"} + title={ + view === "kanban" + ? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst." + : undefined + } > {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (