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:
+34
-16
@@ -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[] {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
photoFile,
|
||||
photoPath,
|
||||
sessionUser,
|
||||
setPhotoSlot,
|
||||
setPhotoSlots,
|
||||
setPhotoConsent,
|
||||
setUserAvatar,
|
||||
setUserBlocked,
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type Recipe,
|
||||
type User,
|
||||
type EventKind,
|
||||
type PhotoSlot,
|
||||
} from './db';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
@@ -804,18 +805,22 @@ app.delete('/api/admin/photos', async (req, reply) => {
|
||||
return reply.status(200).send({ removed: files.length });
|
||||
});
|
||||
|
||||
// Curating: which slot on the landing page this photo is allowed to appear in.
|
||||
// The landing page picks one at random per slot, so several photos in one slot
|
||||
// rotate between visits.
|
||||
// Curating: the landing sections this photo is allowed to appear in. A photo
|
||||
// may sit in several at once — each section picks one of its own at random per
|
||||
// visit, so several photos in one section rotate. An empty set takes it off
|
||||
// the landing without deleting the row.
|
||||
app.patch<{ Params: { id: string } }>('/api/admin/photos/:id', async (req, reply) => {
|
||||
const user = admin(req);
|
||||
if ('status' in user) return reply.status(user.status).send({ error: user.status === 401 ? 'unauthorized' : 'forbidden' });
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' });
|
||||
const b = bodyOf(req);
|
||||
if (!b || !isPhotoSlot(b.slot)) return reply.status(400).send({ error: 'invalid slot' });
|
||||
if (!setPhotoSlot(id, b.slot)) return reply.status(404).send({ error: 'photo not found' });
|
||||
return reply.status(200).send({ id, slot: b.slot });
|
||||
const slots = b?.slots;
|
||||
if (!Array.isArray(slots) || !slots.every((s) => isPhotoSlot(s)))
|
||||
return reply.status(400).send({ error: 'invalid slots' });
|
||||
const set: PhotoSlot[] = [...new Set(slots as PhotoSlot[])];
|
||||
if (!setPhotoSlots(id, set)) return reply.status(404).send({ error: 'photo not found' });
|
||||
return reply.status(200).send({ id, slots: set });
|
||||
});
|
||||
|
||||
app
|
||||
|
||||
Reference in New Issue
Block a user