web: the QR card hands out the look that made the photo

A photo's landing section can now be the QR card, and that section is the
only one that hands something out: the server writes the photo's own stored
look back as the app's .recipe file, at
GET /api/photos/:id/preset.recipe, for any row the curator ticked into the
qr slot. Nothing new is stored — the file is built from the recipe the
upload already carried, so it works for a photo uploaded by the phone too.

The admin pane grows a fourth checkbox and a fourth row (QR card); the
row draws the download link as a scannable code, and the box is dead for a
photo with no stored look. The landing's QR card now encodes the curated
photo's own link instead of a mock address. The listing exposes
hasPreset, never the recipe itself.
This commit is contained in:
2026-09-18 16:57:46 +07:00
parent a35ecf4f1c
commit 8a889db069
11 changed files with 233 additions and 18 deletions
+22 -2
View File
@@ -338,6 +338,10 @@ export type Photo = {
// 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;
// Whether the row still carries the look that made it — the only thing the
// QR card can hand out (see the preset route in server.ts). A bool, not the
// recipe itself: the public listing has no business shipping looks.
hasPreset: boolean;
};
// The owner's own row adds the look that made it, so it can be opened again,
// and the looks it carried before: newest first, at most PHOTO_HISTORY_MAX.
@@ -354,15 +358,20 @@ export type PhotoMeta = {
// One SELECT list, so the call sites cannot drift apart.
const PHOTO_COLUMNS = `photos.id AS id, photos.created_at AS createdAt, photos.slots AS slots,
photos.tag AS tag, photos.title AS title, photos.meta AS meta,
photos.consent AS consent`;
photos.consent AS consent, (photos.recipe IS NOT NULL) AS hasPreset`;
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
// `slots` comes back as the stored comma list, turned into a set on the way out.
type PhotoRow = Omit<Photo, 'consent' | 'slots'> & { consent: number; slots: string | null };
type PhotoRow = Omit<Photo, 'consent' | 'slots' | 'hasPreset'> & {
consent: number;
slots: string | null;
hasPreset: number;
};
type MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
const toPhoto = (row: PhotoRow): Photo => ({
...row,
consent: row.consent === 1,
hasPreset: row.hasPreset === 1,
slots: parseSlots(row.slots),
});
// A row whose JSON will not parse is still a photo: its settings are simply
@@ -539,6 +548,7 @@ export function createPhoto(
title: meta?.title ?? null,
meta: meta?.meta ?? null,
consent: meta?.consent !== false,
hasPreset: meta?.recipe !== undefined,
};
}
@@ -609,6 +619,16 @@ export function photoFile(id: number): { file: string; mime: string } | undefine
| undefined;
}
// The QR card's payload: the stored look, as raw JSON, and where the photo is
// allowed to show. The route decides who may read it (a curated `qr` slot), so
// this returns the row as stored, recipe included.
export function photoPreset(id: number): { recipe: string | null; slots: PhotoSlot[] } | undefined {
const row = db.prepare('SELECT recipe, slots FROM photos WHERE id = ?').get(id) as
| { recipe: string | null; slots: string | null }
| undefined;
return row && { recipe: row.recipe, slots: parseSlots(row.slots) };
}
export function deletePhoto(id: number): string | undefined {
const row = db.prepare('SELECT file FROM photos WHERE id = ?').get(id) as { file: string } | undefined;
if (!row) return undefined;