Files
pinkudex/app/actions/collections.ts
T
2026-05-26 22:46:00 +02:00

154 lines
6.1 KiB
TypeScript

"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<void> {
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<void> {
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;
}