web: keep a saved photo's look in EXIF and its own row

EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
This commit is contained in:
2026-09-18 12:49:05 +07:00
parent cf4b01d4b3
commit 0627f8dd91
9 changed files with 292 additions and 39 deletions
+99 -24
View File
@@ -117,6 +117,21 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
}
}
// The saved-photo flow, added later still:
// recipe — the look that made these pixels (same JSON a recipe row holds), so
// the studio can open the photo again and keep editing it.
// consent — the uploader's own say over the landing strip. The params live in
// this row, never burned into the image.
{
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'consent')) {
db.exec(`ALTER TABLE photos ADD COLUMN consent INTEGER NOT NULL DEFAULT 1`);
}
if (!cols.some((c) => c.name === 'recipe')) {
db.exec(`ALTER TABLE photos ADD COLUMN recipe TEXT`);
}
}
// `avatar` is the stored file name, or null for "no picture".
export type User = {
id: number;
@@ -278,42 +293,81 @@ export type Photo = {
tag: string | null;
title: string | null;
meta: string | null;
// The uploader's permission for this photo to appear on the landing strip.
// The owner's own folder reads it back to draw the toggle.
consent: boolean;
};
// The owner's own row adds the look that made it, so it can be opened again.
export type MyPhoto = Photo & { recipe: unknown | 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 };
export type PhotoMeta = {
tag?: string | null;
title?: string | null;
meta?: string | null;
recipe?: unknown;
consent?: boolean;
};
// One SELECT list, so the four call sites cannot drift apart.
// One SELECT list, so the 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`;
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
photos.consent AS consent`;
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
type PhotoRow = Omit<Photo, 'consent'> & { consent: number };
type MyPhotoRow = PhotoRow & { recipe: string | null };
const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 });
const toMyPhoto = (row: MyPhotoRow): MyPhoto => ({
...toPhoto(row),
// A row whose JSON will not parse is still a photo: its settings are simply
// gone, not worth failing the whole folder over.
recipe: (() => {
try {
return row.recipe ? JSON.parse(row.recipe) : null;
} catch {
return null;
}
})(),
});
export function listPhotos(): Photo[] {
return db
.prepare(
`SELECT ${PHOTO_COLUMNS}
return (
db
.prepare(
`SELECT ${PHOTO_COLUMNS}
FROM photos JOIN users ON users.id = photos.user_id
WHERE users.deleted_at IS NULL
ORDER BY photos.id DESC`,
)
.all() as Photo[];
)
.all() as PhotoRow[]
).map(toPhoto);
}
export function listPhotosWithOwner(): AdminPhoto[] {
return db
.prepare(
`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
ORDER BY photos.id DESC`,
)
.all() as AdminPhoto[];
return (
db
.prepare(
`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
ORDER BY photos.id DESC`,
)
.all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[]
).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes }));
}
// 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[];
// A member's own folder, newest first. No JOIN: the owner is the caller. This
// is the one listing that carries `recipe` — the look to reopen the photo with.
export function listPhotosByUser(userId: number): MyPhoto[] {
return (
db
.prepare(
`SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe
FROM photos WHERE user_id = ? ORDER BY photos.id DESC`,
)
.all(userId) as MyPhotoRow[]
).map(toMyPhoto);
}
// Admin listing: one row per account with how many photos it owns. Blocked and
@@ -406,9 +460,21 @@ export function createPhoto(
const ts = now();
const info = db
.prepare(
'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
`INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta, consent, recipe)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null);
.run(
userId,
file,
mime,
bytes,
ts,
meta?.tag ?? null,
meta?.title ?? null,
meta?.meta ?? null,
meta?.consent === false ? 0 : 1,
meta?.recipe === undefined ? null : JSON.stringify(meta.recipe),
);
// A fresh upload is a strip photo until the curator moves it to a live slot.
return {
id: Number(info.lastInsertRowid),
@@ -417,9 +483,18 @@ export function createPhoto(
tag: meta?.tag ?? null,
title: meta?.title ?? null,
meta: meta?.meta ?? null,
consent: meta?.consent !== false,
};
}
// The uploader's own toggle: may this photo show on the landing strip?
export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean {
return (
db.prepare('UPDATE photos SET consent = ? WHERE id = ? AND user_id = ?').run(consent ? 1 : 0, id, userId)
.changes > 0
);
}
// The stored file name is only ever used through here, and callers must still
// reject anything that is not a single path segment (see server.ts).
export function photoFile(id: number): { file: string; mime: string } | undefined {