web: give each member a photo folder and burn the strip into the export

Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+66 -7
View File
@@ -104,6 +104,19 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
}
}
// The strip's own labels, added after the first contributions were on disk: the
// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title
// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are
// optional and length-capped by the route that accepts them.
{
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
for (const name of ['tag', 'title', 'meta']) {
if (!cols.some((c) => c.name === name)) {
db.exec(`ALTER TABLE photos ADD COLUMN ${name} TEXT`);
}
}
}
// `avatar` is the stored file name, or null for "no picture".
export type User = {
id: number;
@@ -257,13 +270,26 @@ export function deleteRecipe(userId: number, id: number): boolean {
// ---- contributed strip photos -------------------------------------------
// The public shape carries no owner: the landing page is anonymous, so the
// uploader's email must never be reachable from an unauthenticated request.
export type Photo = { id: number; createdAt: string; slot: PhotoSlot };
// `tag`/`title`/`meta` are the frame's own labels (see the migration above).
export type Photo = {
id: number;
createdAt: string;
slot: PhotoSlot;
tag: string | null;
title: string | null;
meta: string | null;
};
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
export type PhotoMeta = { tag?: string | null; title?: string | null; meta?: string | null };
// One SELECT list, so the four call sites cannot drift apart.
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
photos.tag AS tag, photos.title AS title, photos.meta AS meta`;
export function listPhotos(): Photo[] {
return db
.prepare(
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot
`SELECT ${PHOTO_COLUMNS}
FROM photos JOIN users ON users.id = photos.user_id
WHERE users.deleted_at IS NULL
ORDER BY photos.id DESC`,
@@ -274,7 +300,7 @@ export function listPhotos(): Photo[] {
export function listPhotosWithOwner(): AdminPhoto[] {
return db
.prepare(
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
`SELECT ${PHOTO_COLUMNS},
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
users.email AS email
FROM photos JOIN users ON users.id = photos.user_id
@@ -283,6 +309,13 @@ export function listPhotosWithOwner(): AdminPhoto[] {
.all() as AdminPhoto[];
}
// A member's own folder, newest first. No JOIN: the owner is the caller.
export function listPhotosByUser(userId: number): Photo[] {
return db
.prepare(`SELECT ${PHOTO_COLUMNS} FROM photos WHERE user_id = ? ORDER BY photos.id DESC`)
.all(userId) as Photo[];
}
// Admin listing: one row per account with how many photos it owns. Blocked and
// removed accounts stay listed — a removed one has to be findable to restore it.
export type AdminUser = {
@@ -363,13 +396,28 @@ export function countPhotos(userId: number): number {
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
}
export function createPhoto(userId: number, file: string, mime: string, bytes: number): Photo {
export function createPhoto(
userId: number,
file: string,
mime: string,
bytes: number,
meta?: PhotoMeta,
): Photo {
const ts = now();
const info = db
.prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)')
.run(userId, file, mime, bytes, ts);
.prepare(
'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null);
// A fresh upload is a strip photo until the curator moves it to a live slot.
return { id: Number(info.lastInsertRowid), createdAt: ts, slot: 'strip' };
return {
id: Number(info.lastInsertRowid),
createdAt: ts,
slot: 'strip',
tag: meta?.tag ?? null,
title: meta?.title ?? null,
meta: meta?.meta ?? null,
};
}
// The stored file name is only ever used through here, and callers must still
@@ -387,6 +435,17 @@ export function deletePhoto(id: number): string | undefined {
return row.file;
}
// The owner's own delete: the user_id in the WHERE is the whole authorisation,
// so a member can never name someone else's row.
export function deletePhotoOf(userId: number, id: number): string | undefined {
const row = db.prepare('SELECT file FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as
| { file: string }
| undefined;
if (!row) return undefined;
db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId);
return row.file;
}
// Curating, not moderating: where this photo is allowed to surface.
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;