"use server"; import { revalidatePath } from "next/cache"; import { rawDb, uniqueSlug } from "@/lib/db/client"; import { redirect } from "next/navigation"; export async function createCollection(name: string, description?: string): Promise<{ id: number; slug: string } | null> { const trimmed = name.trim(); if (!trimmed) return null; const slug = uniqueSlug(rawDb, "collections", trimmed); // New collections land at the end of the manual order. Wrap the read + // insert in a tx so concurrent creates don't both pick the same // position. const tx = rawDb.transaction(() => { const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collections`).get() as { m: number }; const r = rawDb.prepare(` INSERT INTO collections (name, slug, description, position) VALUES (?, ?, ?, ?) RETURNING id `).get(trimmed, slug, description?.trim() || null, max.m + 1) as { id: number }; return r.id; }); const id = tx(); revalidatePath("/collection"); return { id, slug }; } export async function createCollectionAction(formData: FormData) { const name = String(formData.get("name") ?? ""); const description = String(formData.get("description") ?? ""); const created = await createCollection(name, description); if (created) redirect(`/collection/${created.slug}`); } export async function addImageToCollection(collectionId: number, imageId: number) { // Wrap the read-then-insert in a transaction so concurrent calls // can't both compute the same MAX(position) and produce duplicate // ordering values. const tx = rawDb.transaction(() => { const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collection_images WHERE collection_id = ?`).get(collectionId) as { m: number }; rawDb.prepare(` INSERT OR IGNORE INTO collection_images (collection_id, image_id, position) VALUES (?, ?, ?) `).run(collectionId, imageId, max.m + 1); rawDb.prepare(`UPDATE collections SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(collectionId); }); tx(); revalidatePath(`/collection`); revalidatePath(`/image/${imageId}`); } export async function removeImageFromCollection(collectionId: number, imageId: number) { rawDb.prepare(`DELETE FROM collection_images WHERE collection_id = ? AND image_id = ?`).run(collectionId, imageId); revalidatePath(`/collection`); revalidatePath(`/image/${imageId}`); } export async function deleteCollection(collectionId: number) { rawDb.prepare(`DELETE FROM collections WHERE id = ?`).run(collectionId); revalidatePath("/collection"); redirect("/collection"); } /** * Reorder a single image within a collection. The drag-and-drop UI passes * the image being moved and the image it should now sit before (or null * to drop at the end). We pull the current ordered list, splice the * moved image into its new index, then rewrite every position so the * sequence is dense (0..N-1) regardless of any gaps the previous * ordering may have had. */ export async function reorderCollectionImage( collectionId: number, movedImageId: number, beforeImageId: number | null, ): Promise { const rows = rawDb.prepare(` SELECT image_id FROM collection_images WHERE collection_id = ? ORDER BY position ASC, image_id ASC `).all(collectionId) as Array<{ image_id: number }>; const ids = rows.map((r) => r.image_id); const fromIdx = ids.indexOf(movedImageId); if (fromIdx === -1) return; ids.splice(fromIdx, 1); let toIdx = beforeImageId == null ? ids.length : ids.indexOf(beforeImageId); if (toIdx === -1) toIdx = ids.length; ids.splice(toIdx, 0, movedImageId); const tx = rawDb.transaction(() => { const update = rawDb.prepare(` UPDATE collection_images SET position = ? WHERE collection_id = ? AND image_id = ? `); for (let i = 0; i < ids.length; i++) { update.run(i, collectionId, ids[i]); } }); tx(); const slugRow = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined; revalidatePath("/collection"); if (slugRow) revalidatePath(`/collection/${slugRow.slug}`); } /** * Reorder a collection in the manual list on /collection. Pulls the * current ordered ids, splices the moved one to its new index, then * rewrites every position so the sequence is dense (0..N-1). */ export async function reorderCollection( movedId: number, beforeId: number | null, ): Promise { const rows = rawDb.prepare(` SELECT id FROM collections ORDER BY position ASC, id ASC `).all() as Array<{ id: number }>; const ids = rows.map((r) => r.id); const fromIdx = ids.indexOf(movedId); if (fromIdx === -1) return; ids.splice(fromIdx, 1); let toIdx = beforeId == null ? ids.length : ids.indexOf(beforeId); if (toIdx === -1) toIdx = ids.length; ids.splice(toIdx, 0, movedId); const tx = rawDb.transaction(() => { const update = rawDb.prepare(`UPDATE collections SET position = ? WHERE id = ?`); for (let i = 0; i < ids.length; i++) update.run(i, ids[i]); }); tx(); revalidatePath("/collection"); } /** Rename a collection. Returns the new slug for client-side redirect. * Wraps the slug-uniqueness check + UPDATE in a transaction so two * concurrent renames can't both compute the same slug and crash on * the UNIQUE constraint (or worse, race past it). */ export async function renameCollection(id: number, name: string): Promise<{ slug: string; name: string } | null> { const trimmed = name.trim(); if (!trimmed) return null; const tx = rawDb.transaction(() => { const current = rawDb.prepare(`SELECT name, slug FROM collections WHERE id = ?`).get(id) as | { name: string; slug: string } | undefined; if (!current) return null; if (current.name === trimmed) { return { slug: current.slug, name: trimmed }; } const slug = uniqueSlug(rawDb, "collections", trimmed, id); rawDb.prepare(`UPDATE collections SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id); return { slug, name: trimmed }; }); const result = tx() as { slug: string; name: string } | null; if (!result) return null; revalidatePath("/collection"); revalidatePath(`/collection/${result.slug}`); return result; }