Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home
This commit is contained in:
@@ -56,6 +56,23 @@ CREATE INDEX IF NOT EXISTS idx_recipes_user ON recipes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_user ON photos(user_id);
|
||||
`);
|
||||
|
||||
// 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.
|
||||
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 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.
|
||||
{
|
||||
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'`);
|
||||
}
|
||||
}
|
||||
|
||||
export type User = { id: number; email: string };
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
@@ -179,20 +196,21 @@ export function deleteRecipe(userId: number, id: number): boolean {
|
||||
// ---- contributed strip photos -------------------------------------------
|
||||
// The public shape carries no owner: the landing page is anonymous, so the
|
||||
// uploader's email must never be reachable from an unauthenticated request.
|
||||
export type Photo = { id: number; createdAt: string };
|
||||
export type Photo = { id: number; createdAt: string; slot: PhotoSlot };
|
||||
export type AdminPhoto = Photo & { userId: number; email: string; mime: string; bytes: number };
|
||||
|
||||
export function listPhotos(): Photo[] {
|
||||
return db
|
||||
.prepare('SELECT id, created_at AS createdAt FROM photos ORDER BY id DESC')
|
||||
.prepare('SELECT id, created_at AS createdAt, slot FROM photos ORDER BY id DESC')
|
||||
.all() as Photo[];
|
||||
}
|
||||
|
||||
export function listPhotosWithOwner(): AdminPhoto[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.user_id AS userId,
|
||||
photos.mime AS mime, photos.bytes AS bytes, users.email AS email
|
||||
`SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot,
|
||||
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`,
|
||||
)
|
||||
@@ -208,7 +226,8 @@ export function createPhoto(userId: number, file: string, mime: string, bytes: n
|
||||
const info = db
|
||||
.prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(userId, file, mime, bytes, ts);
|
||||
return { id: Number(info.lastInsertRowid), createdAt: ts };
|
||||
// A fresh upload is a strip photo until the curator moves it to a live slot.
|
||||
return { id: Number(info.lastInsertRowid), createdAt: ts, slot: 'strip' };
|
||||
}
|
||||
|
||||
// The stored file name is only ever used through here, and callers must still
|
||||
@@ -226,6 +245,11 @@ export function deletePhoto(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;
|
||||
}
|
||||
|
||||
export function deleteAllPhotos(): string[] {
|
||||
const files = (db.prepare('SELECT file FROM photos').all() as { file: string }[]).map((r) => r.file);
|
||||
db.prepare('DELETE FROM photos').run();
|
||||
|
||||
Reference in New Issue
Block a user