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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user