web: give each member a photo folder and burn the strip into the export

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.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+24 -1
View File
@@ -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({
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
<button
type="button"
className="btn ghost"
data-key="save-photo"
disabled={savingPhoto || exporting}
title={photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined}
onClick={onSavePhoto}
>
{savingPhoto ? t('photo.uploading') : t('save.photo', { n: photoCount, max: photoMax })}
</button>
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>
@@ -149,7 +169,10 @@ export function TopBar({
{user.admin ? (
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
) : (
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
<>
<a className="btn" data-key="nav-photos" href="/photos">{t('nav.photos')}</a>
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
</>
)}
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
</>