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:
+99
-24
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user