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

129 lines
5.5 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { rawDb, uniqueSlug } from "@/lib/db/client";
type EntityTable = "actresses" | "studios" | "series";
function createEntity(table: EntityTable, name: string): { id: number; slug: string } | null {
const trimmed = name.trim();
if (!trimmed) return null;
const existing = rawDb.prepare(`SELECT id, slug FROM ${table} WHERE name = ?`).get(trimmed) as
| { id: number; slug: string }
| undefined;
if (existing) return existing;
const slug = uniqueSlug(rawDb, table, trimmed);
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as {
id: number;
};
return { id: row.id, slug };
}
export async function createActressAction(formData: FormData) {
createEntity("actresses", String(formData.get("name") ?? ""));
revalidatePath("/actress");
}
export async function createStudioAction(formData: FormData) {
const created = createEntity("studios", String(formData.get("name") ?? ""));
revalidatePath("/studios");
if (created) redirect(`/studios/${created.slug}`);
}
export async function createSeriesAction(formData: FormData) {
const created = createEntity("series", String(formData.get("name") ?? ""));
revalidatePath("/series");
if (created) redirect(`/series/${created.slug}`);
}
/** Deletes a studio. Any covers referencing it have studio_id set to NULL. */
export async function deleteStudio(id: number) {
rawDb.prepare(`UPDATE images SET studio_id = NULL WHERE studio_id = ?`).run(id);
rawDb.prepare(`DELETE FROM studios WHERE id = ?`).run(id);
revalidatePath("/studios");
revalidatePath("/");
redirect("/studios");
}
/** Deletes a series. Any covers referencing it have series_id set to NULL. */
export async function deleteSeries(id: number) {
rawDb.prepare(`UPDATE images SET series_id = NULL WHERE series_id = ?`).run(id);
rawDb.prepare(`DELETE FROM series WHERE id = ?`).run(id);
revalidatePath("/series");
revalidatePath("/");
redirect("/series");
}
/** Rename a studio. Returns the new slug (which may be re-uniquified) or null. */
export async function renameStudio(id: number, name: string): Promise<{ slug: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const row = rawDb.prepare(`SELECT name, slug FROM studios WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
if (!row) return null;
if (trimmed === row.name) return { slug: row.slug };
const slug = uniqueSlug(rawDb, "studios", trimmed, id);
rawDb.prepare(`UPDATE studios SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
revalidatePath("/studios");
revalidatePath(`/studios/${row.slug}`);
if (slug !== row.slug) revalidatePath(`/studios/${slug}`);
return { slug };
}
/** Rename a series. Returns the new slug or null. */
export async function renameSeries(id: number, name: string): Promise<{ slug: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const row = rawDb.prepare(`SELECT name, slug FROM series WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
if (!row) return null;
if (trimmed === row.name) return { slug: row.slug };
const slug = uniqueSlug(rawDb, "series", trimmed, id);
rawDb.prepare(`UPDATE series SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
revalidatePath("/series");
revalidatePath(`/series/${row.slug}`);
if (slug !== row.slug) revalidatePath(`/series/${slug}`);
return { slug };
}
/** Rename a tag (keyed by name). Returns the new name or null. */
export async function renameTag(oldName: string, newName: string): Promise<{ name: string } | null> {
// Tags are stored lowercased (see tags.ts createTag/addTagToImage).
// Lookup by exact match must use the same casing or it silently misses.
const oldTrim = oldName.trim().toLowerCase();
const newTrim = newName.trim().toLowerCase();
if (!oldTrim || !newTrim) return null;
if (oldTrim === newTrim) return { name: oldTrim };
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(oldTrim) as { id: number } | undefined;
if (!row) return null;
// Wrap merge in a transaction so a concurrent INSERT into image_tags
// for the old tag_id can't slip between the UPDATE re-point and the
// DELETE — both run atomically against a consistent snapshot.
const merge = rawDb.transaction(() => {
const conflict = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(newTrim) as { id: number } | undefined;
if (conflict && conflict.id !== row.id) {
rawDb.prepare(`UPDATE OR IGNORE image_tags SET tag_id = ? WHERE tag_id = ?`).run(conflict.id, row.id);
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
} else {
rawDb.prepare(`UPDATE tags SET name = ? WHERE id = ?`).run(newTrim, row.id);
}
});
merge();
revalidatePath("/tag");
revalidatePath(`/tag/${encodeURIComponent(oldTrim)}`);
revalidatePath(`/tag/${encodeURIComponent(newTrim)}`);
return { name: newTrim };
}
/** Deletes a tag. Any image_tags rows referencing it are removed. */
export async function deleteTag(name: string) {
const trimmed = name.trim();
if (!trimmed) return;
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(trimmed) as { id: number } | undefined;
if (!row) return;
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
revalidatePath("/tag");
revalidatePath("/");
redirect("/tag");
}