From b4d5d2926bc9cd1fe1f0f70c1c641ab062f8fe03 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 10:56:26 +0700 Subject: [PATCH] web: give each member a photo folder and burn the strip into the export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every member gets /photos — their own uploads, counted against a 12-photo cap, each card showing the tagline and the technical line the studio would print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full resolution look, stores the strip (tag/title/meta) with the upload so the landing reel frames it the same way, and refuses past the cap. EXPORT now burns that strip into the file: the amber #TAG over the photo's top-left plus a dark caption band below carrying the recipe name and the ISO / grain / warmth line. The live preview stays clean, and the saved upload stays clean too — the reel draws its own frame from the stored labels, so a burned band would tag the tag twice. Admins manage any photo through DELETE /api/photos/:id; members only their own. The users table's photo counts stay in step with the folder. --- docker/backend/src/db.ts | 73 +++++++++- docker/backend/src/server.ts | 49 ++++++- docker/backend/test/security.mjs | 57 ++++++++ docker/frontend/src/Admin.tsx | 5 + docker/frontend/src/App.tsx | 90 ++++++++++++- docker/frontend/src/Landing.tsx | 9 +- docker/frontend/src/PhotosPage.tsx | 147 +++++++++++++++++++++ docker/frontend/src/api.ts | 29 +++- docker/frontend/src/engine/exportEngine.ts | 53 ++++++++ docker/frontend/src/engine/imageOps.ts | 16 +++ docker/frontend/src/i18n/en.ts | 18 +++ docker/frontend/src/i18n/vi.ts | 18 +++ docker/frontend/src/main.tsx | 9 +- docker/frontend/src/ui/TopBar.tsx | 25 +++- 14 files changed, 576 insertions(+), 22 deletions(-) create mode 100644 docker/frontend/src/PhotosPage.tsx diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 2ae40d8..7f0c524 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -104,6 +104,19 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot => } } +// The strip's own labels, added after the first contributions were on disk: the +// tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title +// and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are +// optional and length-capped by the route that accepts them. +{ + const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; + for (const name of ['tag', 'title', 'meta']) { + if (!cols.some((c) => c.name === name)) { + db.exec(`ALTER TABLE photos ADD COLUMN ${name} TEXT`); + } + } +} + // `avatar` is the stored file name, or null for "no picture". export type User = { id: number; @@ -257,13 +270,26 @@ 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; slot: PhotoSlot }; +// `tag`/`title`/`meta` are the frame's own labels (see the migration above). +export type Photo = { + id: number; + createdAt: string; + slot: PhotoSlot; + tag: string | null; + title: string | null; + meta: string | 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 }; + +// One SELECT list, so the four 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`; export function listPhotos(): Photo[] { return db .prepare( - `SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot + `SELECT ${PHOTO_COLUMNS} FROM photos JOIN users ON users.id = photos.user_id WHERE users.deleted_at IS NULL ORDER BY photos.id DESC`, @@ -274,7 +300,7 @@ export function listPhotos(): Photo[] { export function listPhotosWithOwner(): AdminPhoto[] { return db .prepare( - `SELECT photos.id AS id, photos.created_at AS createdAt, photos.slot AS slot, + `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 @@ -283,6 +309,13 @@ export function listPhotosWithOwner(): AdminPhoto[] { .all() as AdminPhoto[]; } +// 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[]; +} + // Admin listing: one row per account with how many photos it owns. Blocked and // removed accounts stay listed — a removed one has to be findable to restore it. export type AdminUser = { @@ -363,13 +396,28 @@ export function countPhotos(userId: number): number { return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n; } -export function createPhoto(userId: number, file: string, mime: string, bytes: number): Photo { +export function createPhoto( + userId: number, + file: string, + mime: string, + bytes: number, + meta?: PhotoMeta, +): Photo { const ts = now(); const info = db - .prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)') - .run(userId, file, mime, bytes, ts); + .prepare( + 'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ) + .run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null); // 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' }; + return { + id: Number(info.lastInsertRowid), + createdAt: ts, + slot: 'strip', + tag: meta?.tag ?? null, + title: meta?.title ?? null, + meta: meta?.meta ?? null, + }; } // The stored file name is only ever used through here, and callers must still @@ -387,6 +435,17 @@ export function deletePhoto(id: number): string | undefined { return row.file; } +// The owner's own delete: the user_id in the WHERE is the whole authorisation, +// so a member can never name someone else's row. +export function deletePhotoOf(userId: number, id: number): string | undefined { + const row = db.prepare('SELECT file FROM photos WHERE id = ? AND user_id = ?').get(id, userId) as + | { file: string } + | undefined; + if (!row) return undefined; + db.prepare('DELETE FROM photos WHERE id = ? AND user_id = ?').run(id, userId); + 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; diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 036e08a..1d52989 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -16,6 +16,7 @@ import { createUser, deleteAllPhotos, deletePhoto, + deletePhotoOf, deleteRecipe, deleteSession, deleteUser, @@ -23,6 +24,7 @@ import { findUserById, isPhotoSlot, listPhotos, + listPhotosByUser, listPhotosWithOwner, listRecipes, listUsersWithCounts, @@ -39,6 +41,7 @@ import { updateUserEmail, userAvatar, verifyPassword, + type PhotoMeta, type Recipe, type User, } from './db'; @@ -332,11 +335,40 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => }); // ---- contributed strip photos ------------------------------------------- +// The frame's own labels ride the query string: the body is the raw image, so +// there is no JSON envelope to put them in. Capped and control-stripped here, +// because they are drawn and stored rather than trusted. +const META_MAX = { tag: 64, title: 120, meta: 160 } as const; + +function cleanMeta(value: unknown, max: number): string | null { + if (typeof value !== 'string') return null; + // eslint-disable-next-line no-control-regex + const text = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max); + return text || null; +} + +function photoMeta(req: FastifyRequest): PhotoMeta { + const q = (req.query ?? {}) as Record; + return { + tag: cleanMeta(q.tag, META_MAX.tag), + title: cleanMeta(q.title, META_MAX.title), + meta: cleanMeta(q.meta, META_MAX.meta), + }; +} + // Anyone may read the strip; only a signed-in account may add to it. The bytes // are written under a server-generated name, so a caller's own filename never // reaches the filesystem, and the row is the only place the real mime lives. app.get('/api/photos', async () => ({ photos: listPhotos() })); +// The caller's own folder — the count the studio's SAVE PHOTO shows comes from +// here, and the admin drill-down reads the same rows through /admin/photos. +app.get('/api/photos/mine', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + return reply.status(200).send({ photos: listPhotosByUser(user.id) }); +}); + app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => { const user = auth(req); if (!user) return reply.status(401).send({ error: 'unauthorized' }); @@ -356,7 +388,7 @@ app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`; writeFileSync(photoPath(file), body, { flag: 'wx' }); - const photo = createPhoto(user.id, file, mime, body.length); + const photo = createPhoto(user.id, file, mime, body.length, photoMeta(req)); return reply.status(201).send({ photo }); }); @@ -428,6 +460,21 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) = .send(data); }); +// Removing one of your own photos. An admin may remove anyone's from here too, +// so the folder and the moderation screen share one route. The row is only +// dropped when the caller owns it (or curates the whole strip), and the file +// goes with it — `deletePhotoOf` / `deletePhoto` return the name to unlink. +app.delete<{ Params: { id: string } }>('/api/photos/:id', async (req, reply) => { + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + const id = Number(req.params.id); + if (!Number.isInteger(id) || id <= 0) return reply.status(404).send({ error: 'photo not found' }); + const file = isAdmin(user) ? deletePhoto(id) : deletePhotoOf(user.id, id); + if (!file) return reply.status(404).send({ error: 'photo not found' }); + unlink(file); + return reply.status(204).send(); +}); + // ---- admin --------------------------------------------------------------- // Moderation only: the allowlist can list everything and clean up. There is // deliberately no endpoint here that grants the privilege itself. diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 9c74d9e..0634720 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -218,6 +218,63 @@ try { (await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404, ); + // ---- the member's own folder -------------------------------------------- + check('a guest has no folder', (await actor().req('/photos/mine')).status === 401); + const folder = await user.req('/photos/mine'); + check( + 'a member lists their own photos', + folder.status === 200 && (folder.body?.photos ?? []).some((p) => p.id === id), + JSON.stringify(folder.body).slice(0, 120), + ); + + const stranger = actor(); + await stranger.signup(`stranger${stamp}@test.local`); + check("a fresh account's folder is empty", ((await stranger.req('/photos/mine')).body?.photos ?? []).length === 0); + + // The strip's own labels ride the query string: the body is the image. + const labels = { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18' }; + const labelled = await user.req( + `/photos?tag=${encodeURIComponent(labels.tag)}&title=${encodeURIComponent(labels.title)}&meta=${encodeURIComponent(labels.meta)}`, + { method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG }, + ); + check( + 'an upload carries its strip labels', + labelled.status === 201 && + labelled.body?.photo?.tag === labels.tag && + labelled.body?.photo?.title === labels.title && + labelled.body?.photo?.meta === labels.meta, + JSON.stringify(labelled.body), + ); + const labelledId = labelled.body?.photo?.id; + const publicRow = ((await actor().req('/photos')).body?.photos ?? []).find((p) => p.id === labelledId); + check('the labels reach the public strip', publicRow?.tag === labels.tag && publicRow?.meta === labels.meta); + + const capped = await user.req( + `/photos?tag=${'x'.repeat(200)}&meta=${encodeURIComponent('bad\u0007line')}`, + { method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG }, + ); + check('a label is length-capped', capped.body?.photo?.tag?.length === 64, `len ${capped.body?.photo?.tag?.length}`); + check('a label is control-stripped', capped.body?.photo?.meta === 'bad line', JSON.stringify(capped.body?.photo?.meta)); + + check( + 'a member cannot delete a photo they do not own', + (await stranger.req(`/photos/${labelledId}`, { method: 'DELETE' })).status === 404, + ); + check('the stranger’s delete leaves the file alone', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 200); + const ownDelete = await user.req(`/photos/${labelledId}`, { method: 'DELETE' }); + check('a member deletes their own photo', ownDelete.status === 204, `got ${ownDelete.status}`); + check('the deleted file is gone from disk', (await fetch(`${BASE}/photos/${labelledId}/file`)).status === 404); + check( + 'the row leaves the folder', + !((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === labelledId), + ); + + // The curator removes anyone's through the same route — the moderation screen + // keeps its own two admin endpoints, this one just shares the job. + const spare = (await user.upload(PNG, 'image/png')).body?.photo; + check('an admin deletes through the member route too', (await admin.req(`/photos/${spare?.id}`, { method: 'DELETE' })).status === 204); + check('a deleted photo leaves the strip', !((await actor().req('/photos')).body?.photos ?? []).some((p) => p.id === spare?.id)); + // ---- moderation --------------------------------------------------------- check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401); const forbidden = await user.req('/admin/photos'); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 127dd77..027c3ca 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -227,6 +227,11 @@ export function Admin() {
#{p.id} + {/* The labels the uploader's studio stored, when it + sent any — the same ones the reel draws. */} + {p.tag ? {p.tag} : null} + {p.title ? {p.title} : null} + {p.meta ? {p.meta} : null} {t('adm.owner')}: {p.email} {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index d69655a..e2730fe 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -8,9 +8,9 @@ import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; -import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps'; +import { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps'; import { loadPhoto, loadState, savePhoto, saveState } from './engine/session'; -import { api, type SavedRecipe, type User } from './api'; +import { api, type Photo, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; @@ -27,6 +27,8 @@ import { } from '../shared/types'; import type { MsgKey } from './i18n/vi'; +// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. +const MAX_PHOTOS = 12; // Preview renders on a downscaled copy; export always re-renders the original // bytes, so nothing shown here is baked into the file. const PREVIEW_MAX = 1600; @@ -185,6 +187,8 @@ export function Workspace() { const [peek, setPeek] = useState(false); const [gps, setGps] = useState(() => session.gps ?? null); + // ISO off the loaded photo's EXIF; null means the strip prints AUTO. + const [iso, setIso] = useState(null); const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' }); const [place, setPlace] = useState(() => session.place ?? ''); const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false); @@ -285,8 +289,12 @@ export function Workspace() { }; }, []); const [exporting, setExporting] = useState(false); + const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); + // This account's own folder. Only the count matters here; /photos owns the + // management screen. The API is the real cap — this only labels the button. + const [myPhotos, setMyPhotos] = useState([]); // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); @@ -339,9 +347,11 @@ export function Workspace() { useEffect(() => { if (!user) { setSaved([]); + setMyPhotos([]); return; } api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([])); + api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([])); }, [user]); // --- preview render ------------------------------------------------------- @@ -408,6 +418,7 @@ export function Workspace() { async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); const found = await readGps(bytes); + const foundIso = await readIso(bytes); // The photo's own shape drives the default crop frame. Read off the // downscaled copy — same aspect, a fraction of the decode cost. const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' })) @@ -429,6 +440,7 @@ export function Workspace() { previewJobRef.current++; setPreviewBytes(preview); setGps(found); + setIso(foundIso); // A new photo starts a new history whose floor is the default look, so // UNDO walks all the way back and still leaves the photo loaded. setUndo([defaultLook()]); @@ -643,6 +655,19 @@ export function Workspace() { input.click(); }, [importRecipeFile]); + // The strip this look shows — the tagline off the recipe name, and the + // technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own + // grain, warmth and monochrome state. Burned into the download; stored with + // a saved photo so the landing reel can frame it the same way. + const strip = useMemo(() => { + const name = (recipe.name || 'RECIPE').trim(); + const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); + const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0)); + const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); + const tail = recipe.baseFilter === 'monochrome' ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; + return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; + }, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]); + const exportPhoto = useCallback(async () => { if (!engine || !source) return; setExporting(true); @@ -662,6 +687,8 @@ export function Workspace() { wallframeLandscape: frameId === 'wallframe-landscape', dpi: 300, sharpen: true, + // The file carries the strip; the live stage never does. + caption: strip, photoRotation: rotation, cropRect: cropLive ? cropRect : null, watermark: markOn && markText @@ -695,7 +722,60 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip]); + + // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. + // The upload is left clean (no burned strip) so the landing reel can draw its + // own `.lp-shot` frame around it; the labels are stored alongside instead. + // The 12-photo cap is the API's, mirrored here so the button can say no. + const savePhotoToFolder = useCallback(async () => { + if (!user) { + setAuthMode('login'); + return; + } + if (!engine || !source) { + setError(t('save.photoNoSource')); + return; + } + if (myPhotos.length >= MAX_PHOTOS) { + setError(t('save.photoFull', { max: MAX_PHOTOS })); + return; + } + setSavingPhoto(true); + setError(null); + try { + const src = TIERS.user.maxExportDim + ? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95) + : source.bytes; + const out = await renderPhoto({ + source: src, + recipe, + frameId: cropEditing ? 'none' : frameId, + useGeotag, + gpsInfo: gps, + assets: engine.assets, + options: { + wallframeLandscape: frameId === 'wallframe-landscape', + dpi: 300, + sharpen: true, + photoRotation: rotation, + cropRect: cropLive ? cropRect : null, + watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, + gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, + }, + }); + if (!out) throw new Error('render returned null'); + const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, { + type: 'image/jpeg', + }); + const { photo } = await api.uploadPhoto(file, strip); + setMyPhotos((prev) => [photo, ...prev]); + } catch (err) { + setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) })); + } finally { + setSavingPhoto(false); + } + }, [user, engine, source, myPhotos.length, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, t]); // --- strips --------------------------------------------------------------- // The cascade: a chip opens the next column, and the column it came from stays @@ -1111,10 +1191,14 @@ export function Workspace() { recipeName={recipe.name} user={user} exporting={exporting} + savingPhoto={savingPhoto} canUndo={undo.length > 0} onUndo={undoLast} onReset={reset} + onSavePhoto={() => void savePhotoToFolder()} onExport={exportPhoto} + photoCount={myPhotos.length} + photoMax={MAX_PHOTOS} onAuth={() => setAuthMode('login')} onSignup={() => setAuthMode('signup')} onLogout={async () => { diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 5ad049c..170759c 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -170,10 +170,13 @@ export function Landing() { const reel = useMemo( () => [ ...REEL, + // The labels the uploader's studio stored — the tagline, the title and + // the ISO/grain line. A contribution from before those existed keeps the + // plain community defaults. ...photos.filter((p) => p.slot === 'strip').map((p) => ({ - tag: '#COMMUNITY', - title: { en: 'Community Frame', vi: 'Khung cộng đồng' }, - meta: `#${p.id}`, + tag: p.tag ?? '#COMMUNITY', + title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' }, + meta: p.meta ?? `#${p.id}`, photo: api.photoUrl(p.id), filter: 'none', })), diff --git a/docker/frontend/src/PhotosPage.tsx b/docker/frontend/src/PhotosPage.tsx new file mode 100644 index 0000000..feecfda --- /dev/null +++ b/docker/frontend/src/PhotosPage.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, type Photo, type User } from './api'; +import { useI18n } from './i18n/I18nProvider'; +import { Avatar } from './ui/Avatar'; + +// The member's own photo folder: everything this account has uploaded, with the +// count that the studio's SAVE PHOTO button mirrors and the same 12-photo cap +// the API enforces. No router — main.tsx picks it by pathname. +const MAX_PHOTOS = 12; + +export function PhotosPage() { + const { t } = useI18n(); + const [user, setUser] = useState(null); + const [photos, setPhotos] = useState([]); + const [loaded, setLoaded] = useState(false); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(null); + const filePick = useRef(null); + + useEffect(() => { + api + .me() + .then((r) => setUser(r.user)) + .catch(() => undefined) + .finally(() => setLoaded(true)); + }, []); + + const reload = useCallback( + () => api.listMyPhotos().then((r) => setPhotos(r.photos)).catch(() => setPhotos([])), + [], + ); + + useEffect(() => { + if (user) void reload(); + }, [user, reload]); + + const run = async (fn: () => Promise) => { + setBusy(true); + setNote(null); + try { + await fn(); + } catch (err) { + setNote(t('photos.failed', { msg: err instanceof Error ? err.message : String(err) })); + } finally { + setBusy(false); + } + }; + + const add = (file: File) => + run(async () => { + await api.uploadPhoto(file); + await reload(); + }); + + const remove = (id: number) => + run(async () => { + await api.deletePhoto(id); + setPhotos((prev) => prev.filter((p) => p.id !== id)); + setNote(t('photos.deleted')); + }); + + const full = photos.length >= MAX_PHOTOS; + + return ( +
+
+ + + RecipesCam + + {t('photos.title')} + + {t('photos.count', { n: photos.length, max: MAX_PHOTOS })} + + {t('nav.back')} +
+ + {note ?

{note}

: null} + {loaded && !user ? ( +

+ {t('nav.login')} +

+ ) : null} + + {user ? ( +
+
+

{t('photos.hint', { max: MAX_PHOTOS })}

+ {full ?

{t('photos.full', { max: MAX_PHOTOS })}

: null} +
+ + { + const file = e.target.files?.[0]; + e.target.value = ''; + if (file) void add(file); + }} + /> +
+ {photos.length === 0 ?

{t('photos.empty')}

: null} +
+ {photos.map((p) => ( +
+ +
+ {p.tag || p.title || p.meta ? ( + <> + {p.tag ? {p.tag} : null} + {p.title ? {p.title} : null} + {p.meta ? {p.meta} : null} + + ) : ( + {t('photos.noLabels')} + )} + #{p.id} · {new Date(p.createdAt).toLocaleString()} +
+ +
+ ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 82a7708..02424a7 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -36,11 +36,23 @@ export interface SavedRecipe { export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr'; // A strip contribution as the public sees it — the API never puts an email on -// this shape. +// this shape. `tag`/`title`/`meta` are the frame's own labels (the amber +// tagline, the artwork title and the `ISO … · GRAIN …` line); null when the +// uploader sent none, and the reel then falls back to its built-in text. export interface Photo { id: number; createdAt: string; slot: PhotoSlot; + tag: string | null; + title: string | null; + meta: string | null; +} + +// What a caller may attach to an upload. Same three labels. +export interface PhotoLabels { + tag?: string; + title?: string; + meta?: string; } // Admin listing only: adds the owner, which /api/admin/photos is gated on. @@ -121,14 +133,23 @@ export const api = { deleteRecipe: (id: number) => call(`/recipes/${id}`, { method: 'DELETE' }), // The strip. Upload is the raw file as the request body — one image per - // request, so no multipart framing and no extra dependency. + // request, so no multipart framing and no extra dependency. The frame's + // labels ride the query string, since the body is the image itself. listPhotos: () => call<{ photos: Photo[] }>('/photos'), - uploadPhoto: async (file: File) => { - const res = await uploadBytes('/api/photos', file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD); + listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'), + uploadPhoto: async (file: File, labels?: PhotoLabels) => { + const q = new URLSearchParams(); + if (labels?.tag) q.set('tag', labels.tag); + if (labels?.title) q.set('title', labels.title); + if (labels?.meta) q.set('meta', labels.meta); + const suffix = q.size > 0 ? `?${q}` : ''; + const res = await uploadBytes(`/api/photos${suffix}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD); const body = await readJson(res); if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`); return body as unknown as { photo: Photo }; }, + // The owner's own delete; an admin may pass any id here too. + deletePhoto: (id: number) => call(`/photos/${id}`, { method: 'DELETE' }), photoUrl: (id: number) => `/api/photos/${id}/file`, // Admin only: every account, with how many photos it owns. diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 98e76b4..9e811c0 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -68,6 +68,10 @@ export interface RenderOptions { frameWindowZoom?: { s: number; u: number; v: number } | null; photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; + // The strip the FILE carries: the amber `#TAG` over the photo and a caption + // band appended below it, mirroring the landing page's `.lp-shot` + + // `.lp-frame-meta`. Left off the live preview, which must not show it. + caption?: { tag: string; title: string; meta: string } | null; } export interface RenderInput { @@ -699,6 +703,55 @@ export async function renderPhoto(input: RenderInput): Promise { } } +// EXIF ISO of the loaded photo, for the strip's technical line. `pick` keeps +// the parse to the tags that matter; a screenshot or a film scan has none, and +// the caller then prints "AUTO" instead of inventing a number. +export async function readIso(bytes: Uint8Array): Promise { + try { + const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as + | { ISO?: unknown; ISOSpeedRatings?: unknown } + | undefined; + const raw = tags?.ISO ?? tags?.ISOSpeedRatings; + const n = Number(Array.isArray(raw) ? raw[0] : raw); + return Number.isFinite(n) && n > 0 ? Math.round(n) : null; + } catch { + return null; + } +} + export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void { const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type })); const a = document.createElement('a'); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 215e858..04ab6d2 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -106,13 +106,31 @@ export const en: Dict = { 'save.recipes': 'SAVE RECIPES', 'save.saved': 'Saved "{name}"', 'save.needLogin': 'Log in to save a recipe.', + 'save.photo': 'SAVE PHOTO {n}/{max}', + 'save.photoNeedLogin': 'Log in to save a photo.', + 'save.photoSaved': 'Photo saved {n}/{max}.', + 'save.photoFull': 'That is all {max} — remove one in My photos.', + 'save.photoNoSource': 'Load a photo first.', + 'save.photoFailed': 'Could not save the photo: {msg}', 'photo.contribute': 'CONTRIBUTE A PHOTO', 'photo.uploading': 'UPLOADING…', 'photo.thanks': 'Your frame is in the strip.', 'photo.failed': 'Upload failed: {msg}', + 'photos.title': 'My photos', + 'photos.hint': 'Each account keeps up to {max} photos. What the studio saves lives here and shows up on the community reel.', + 'photos.count': '{n}/{max} photos', + 'photos.add': 'ADD A PHOTO', + 'photos.empty': 'No photos yet. Open the studio and hit SAVE PHOTO, or add one right here.', + 'photos.full': 'That is all {max}. Remove one before saving again.', + 'photos.delete': 'DELETE', + 'photos.deleted': 'Photo removed.', + 'photos.failed': 'That did not work: {msg}', + 'photos.noLabels': 'No strip labels yet', + 'nav.admin': 'Admin', + 'nav.photos': 'My photos', 'nav.profile': 'Profile', 'nav.studio': 'Studio', 'adm.title': 'Strip moderation', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index f292445..d8ee96f 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -15,6 +15,7 @@ export const vi = { 'nav.language': 'Ngôn ngữ', 'nav.back': 'Về trang chủ', 'nav.admin': 'Quản trị', + 'nav.photos': 'Ảnh của tôi', 'nav.profile': 'Hồ sơ', 'nav.studio': 'Studio', 'nav.guest': 'Khách', @@ -112,12 +113,29 @@ export const vi = { 'save.recipes': 'LƯU CÔNG THỨC', 'save.saved': 'Đã lưu "{name}"', 'save.needLogin': 'Đăng nhập để lưu công thức.', + 'save.photo': 'LƯU ẢNH {n}/{max}', + 'save.photoNeedLogin': 'Đăng nhập để lưu ảnh.', + 'save.photoSaved': 'Đã lưu ảnh {n}/{max}.', + 'save.photoFull': 'Đã đủ {max} ảnh — xoá bớt trong Ảnh của tôi.', + 'save.photoNoSource': 'Chưa có ảnh nào để lưu.', + 'save.photoFailed': 'Không lưu được ảnh: {msg}', 'photo.contribute': 'ĐÓNG GÓP ẢNH', 'photo.uploading': 'ĐANG TẢI…', 'photo.thanks': 'Ảnh của bạn đã vào dải phim.', 'photo.failed': 'Không tải được ảnh: {msg}', + 'photos.title': 'Ảnh của tôi', + 'photos.hint': 'Mỗi tài khoản giữ tối đa {max} ảnh. Ảnh lưu từ studio nằm ở đây và xuất hiện trên dải phim cộng đồng.', + 'photos.count': '{n}/{max} ảnh', + 'photos.add': 'THÊM ẢNH', + 'photos.empty': 'Chưa có ảnh nào. Mở studio rồi bấm LƯU ẢNH, hoặc thêm ảnh ngay ở đây.', + 'photos.full': 'Đã đủ {max} ảnh. Xoá bớt trước khi lưu tiếp.', + 'photos.delete': 'XOÁ', + 'photos.deleted': 'Đã xoá ảnh.', + 'photos.failed': 'Không thực hiện được: {msg}', + 'photos.noLabels': 'Chưa có nhãn dải phim', + 'adm.title': 'Quản trị dải phim', 'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.', 'adm.upload': 'THÊM ẢNH', diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 307cc8b..71a9daf 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -9,15 +9,18 @@ import { Landing } from './Landing'; import { Workspace } from './App'; import { Admin } from './Admin'; import { ProfilePage } from './ProfilePage'; +import { PhotosPage } from './PhotosPage'; -// Four routes, no router: the landing page, the workspace, the strip moderation -// screen and the member's own profile. nginx serves index.html for all of them -// (SPA fallback), so this is just a pathname check. +// Five routes, no router: the landing page, the workspace, the strip moderation +// screen, the member's own profile and the member's photo folder. nginx serves +// index.html for all of them (SPA fallback), so this is a pathname check. const path = window.location.pathname; const page = path.startsWith('/app') ? ( ) : path.startsWith('/admin') ? ( +) : path.startsWith('/photos') ? ( + ) : path.startsWith('/profile') ? ( ) : ( diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 93ef51a..3a4a586 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -10,10 +10,14 @@ export function TopBar({ recipeName, user, exporting, + savingPhoto, canUndo, onUndo, onReset, + onSavePhoto, onExport, + photoCount, + photoMax, onAuth, onSignup, onLogout, @@ -21,10 +25,16 @@ export function TopBar({ recipeName: string; user: User | null; exporting: boolean; + savingPhoto: boolean; canUndo: boolean; onUndo: () => void; onReset: () => void; + onSavePhoto: () => void; onExport: () => void; + // This account's own folder: the SAVE PHOTO button states the count, and the + // API refuses past the cap. A guest sees 0/12 and is sent to sign in. + photoCount: number; + photoMax: number; onAuth: () => void; onSignup: () => void; onLogout: () => void; @@ -55,6 +65,16 @@ export function TopBar({ + @@ -149,7 +169,10 @@ export function TopBar({ {user.admin ? ( {t('nav.admin')} ) : ( - {t('nav.profile')} + <> + {t('nav.photos')} + {t('nav.profile')} + )}