web: a photo can sit in every landing section at once

The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.

Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
This commit is contained in:
2026-09-18 16:47:12 +07:00
parent ee2c402438
commit 260517c547
9 changed files with 148 additions and 83 deletions
+34 -16
View File
@@ -83,21 +83,30 @@ CREATE INDEX IF NOT EXISTS idx_events_at ON events(at);
// Where a curated photo is allowed to appear on the landing page: the community
// strip, the live tester's preview, the creator lab's preview, or the QR card.
// One is picked at random out of its slot on every page load. `off` is the
// curator's "take it off the landing, keep the row" — the reel and the three
// live slots all draw by exact slot, so an `off` photo shows up nowhere, while
// its owner still has it in MY PHOTOS.
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr', 'off'] as const;
// A photo may sit in several at once — each section draws its own random pick
// out of its set on every page load. Sitting in none of them is the curator's
// "take it off the landing, keep the row": the owner still has it in MY PHOTOS.
export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const;
export type PhotoSlot = (typeof PHOTO_SLOTS)[number];
export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v);
// The column holds the set as a comma list. Ids it does not know — the retired
// single-slot `off`, anything hand-edited — are dropped, so a row can always be
// read back as a set the landing page understands.
export const parseSlots = (raw: string | null | undefined): PhotoSlot[] =>
(raw ?? '').split(',').filter(isPhotoSlot);
export const serializeSlots = (slots: readonly PhotoSlot[]): string =>
[...new Set(slots.filter(isPhotoSlot))].join(',');
// The column arrived after the first strips were already on disk, so add it in
// place — `CREATE TABLE IF NOT EXISTS` would silently skip an existing table.
// It first held one slot; the rename keeps every existing row readable, since
// a bare `strip` parses as a one-member set and the old `off` as the empty one.
{
const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[];
if (!cols.some((c) => c.name === 'slot')) {
db.exec(`ALTER TABLE photos ADD COLUMN slot TEXT NOT NULL DEFAULT 'strip'`);
if (!cols.some((c) => c.name === 'slots')) {
if (cols.some((c) => c.name === 'slot')) db.exec(`ALTER TABLE photos RENAME COLUMN slot TO slots`);
else db.exec(`ALTER TABLE photos ADD COLUMN slots TEXT NOT NULL DEFAULT 'strip'`);
}
}
@@ -320,7 +329,9 @@ export function deleteRecipe(userId: number, id: number): boolean {
export type Photo = {
id: number;
createdAt: string;
slot: PhotoSlot;
// Every landing section this photo is allowed to appear in; empty means it
// is curated off the landing page entirely.
slots: PhotoSlot[];
tag: string | null;
title: string | null;
meta: string | null;
@@ -341,14 +352,19 @@ 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.slot AS slot,
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`;
// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text.
type PhotoRow = Omit<Photo, 'consent'> & { consent: number };
// `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 MyPhotoRow = PhotoRow & { recipe: string | null; history: string | null };
const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 });
const toPhoto = (row: PhotoRow): Photo => ({
...row,
consent: row.consent === 1,
slots: parseSlots(row.slots),
});
// A row whose JSON will not parse is still a photo: its settings are simply
// gone, not worth failing the whole folder over. Same for one bad entry in the
// history — the rest of the list still stands.
@@ -513,11 +529,12 @@ export function createPhoto(
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.
// A fresh upload starts in the community strip; the curator may put it in
// any of the live sections too, or take it off the landing.
return {
id: Number(info.lastInsertRowid),
createdAt: ts,
slot: 'strip',
slots: ['strip'],
tag: meta?.tag ?? null,
title: meta?.title ?? null,
meta: meta?.meta ?? null,
@@ -610,9 +627,10 @@ export function deletePhotoOf(userId: number, id: number): string | undefined {
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;
// Curating, not moderating: where this photo is allowed to surface. The whole
// set arrives at once — the admin's checkboxes are the only writer.
export function setPhotoSlots(id: number, slots: readonly PhotoSlot[]): boolean {
return db.prepare('UPDATE photos SET slots = ? WHERE id = ?').run(serializeSlots(slots), id).changes > 0;
}
export function deleteAllPhotos(): string[] {