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:
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -227,6 +227,11 @@ export function Admin() {
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
<b>#{p.id}</b>
|
||||
{/* The labels the uploader's studio stored, when it
|
||||
sent any — the same ones the reel draws. */}
|
||||
{p.tag ? <span>{p.tag}</span> : null}
|
||||
{p.title ? <span>{p.title}</span> : null}
|
||||
{p.meta ? <span>{p.meta}</span> : null}
|
||||
<span>{t('adm.owner')}: {p.email}</span>
|
||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</span>
|
||||
|
||||
@@ -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<GPSInfo | null>(() => session.gps ?? null);
|
||||
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
|
||||
const [iso, setIso] = useState<number | null>(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<User | null>(null);
|
||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
||||
// 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<Photo[]>([]);
|
||||
// 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<Recipe[]>([]);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -170,10 +170,13 @@ export function Landing() {
|
||||
const reel = useMemo<Look[]>(
|
||||
() => [
|
||||
...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',
|
||||
})),
|
||||
|
||||
@@ -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<User | null>(null);
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const filePick = useRef<HTMLInputElement>(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<void>) => {
|
||||
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 (
|
||||
<div className="adm">
|
||||
<header className="adm-bar">
|
||||
<a className="adm-brand" href="/">
|
||||
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
|
||||
<span>Recipes<em>Cam</em></span>
|
||||
</a>
|
||||
<b className="adm-title">{t('photos.title')}</b>
|
||||
<span className="adm-spacer" />
|
||||
<span className="hint" data-key="photos-count">{t('photos.count', { n: photos.length, max: MAX_PHOTOS })}</span>
|
||||
<Avatar user={user} />
|
||||
<a className="btn ghost" data-key="photos-home" href="/">{t('nav.back')}</a>
|
||||
</header>
|
||||
|
||||
{note ? <p className="hint adm-note">{note}</p> : null}
|
||||
{loaded && !user ? (
|
||||
<p className="adm-note">
|
||||
<a className="btn primary" href="/?auth=login">{t('nav.login')}</a>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{user ? (
|
||||
<div className="adm-frame" data-key="photos-frame">
|
||||
<div className="adm-pane">
|
||||
<p className="hint adm-sub">{t('photos.hint', { max: MAX_PHOTOS })}</p>
|
||||
{full ? <p className="hint adm-sub" data-key="photos-full">{t('photos.full', { max: MAX_PHOTOS })}</p> : null}
|
||||
<div className="chip-row" style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key="photos-add"
|
||||
disabled={busy || full}
|
||||
onClick={() => filePick.current?.click()}
|
||||
>
|
||||
{busy ? t('photo.uploading') : t('photos.add')}
|
||||
</button>
|
||||
<input
|
||||
ref={filePick}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
data-key="photos-add-input"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) void add(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{photos.length === 0 ? <p className="hint adm-note">{t('photos.empty')}</p> : null}
|
||||
<div className="adm-grid">
|
||||
{photos.map((p) => (
|
||||
<article className="adm-card" key={p.id} data-key={`my-photo-${p.id}`}>
|
||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||
<div className="adm-meta">
|
||||
{p.tag || p.title || p.meta ? (
|
||||
<>
|
||||
{p.tag ? <b>{p.tag}</b> : null}
|
||||
{p.title ? <span>{p.title}</span> : null}
|
||||
{p.meta ? <span>{p.meta}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="hint">{t('photos.noLabels')}</span>
|
||||
)}
|
||||
<span>#{p.id} · {new Date(p.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn adm-danger"
|
||||
data-key={`my-photo-del-${p.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(p.id)}
|
||||
>
|
||||
{t('photos.delete')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>(`/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<void>(`/photos/${id}`, { method: 'DELETE' }),
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
|
||||
// Admin only: every account, with how many photos it owns.
|
||||
|
||||
@@ -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<RenderResult | nu
|
||||
resultImage = sharpened;
|
||||
}
|
||||
}
|
||||
// 9b. The strip the file carries (export only, never the preview). Drawn
|
||||
// onto a taller surface so every frame variant is treated the same: the
|
||||
// tagline sits on the photo's top-left, the caption band hangs below.
|
||||
const caption = options?.caption ?? null;
|
||||
if (caption) {
|
||||
const W = resultImage.width();
|
||||
const band = Math.round(W * 0.155);
|
||||
const capSurface = createSurface(W, resultImage.height() + band);
|
||||
const loaded = loadFaces(input.assets);
|
||||
if (capSurface && loaded) {
|
||||
own(capSurface);
|
||||
const cc = capSurface.getCanvas();
|
||||
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
|
||||
|
||||
const pad = Math.round(W * 0.03);
|
||||
// Amber over a black drop, so the tagline still reads on a bright sky.
|
||||
const tagSize = Math.round(W * 0.026);
|
||||
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
|
||||
const shadow = own(Skia.Paint());
|
||||
shadow.setColor(Skia.Color('#000000'));
|
||||
const amber = own(Skia.Paint());
|
||||
amber.setColor(Skia.Color('#f59e0b'));
|
||||
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
|
||||
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
|
||||
|
||||
const bandTop = resultImage.height();
|
||||
const bandPaint = own(Skia.Paint());
|
||||
bandPaint.setColor(Skia.Color('#0b0b0b'));
|
||||
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
|
||||
const titleSize = Math.round(W * 0.042);
|
||||
const metaSize = Math.round(W * 0.026);
|
||||
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
|
||||
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
|
||||
const titlePaint = own(Skia.Paint());
|
||||
titlePaint.setColor(Skia.Color('#f2f2f2'));
|
||||
const metaPaint = own(Skia.Paint());
|
||||
metaPaint.setColor(Skia.Color('#9a9a9a'));
|
||||
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
|
||||
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7), metaPaint, metaFont);
|
||||
|
||||
flush(capSurface);
|
||||
const composed = own(capSurface.makeImageSnapshot());
|
||||
release(owned, resultImage);
|
||||
resultImage = composed;
|
||||
} else if (capSurface) {
|
||||
capSurface.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||
if (!bytes || bytes.length === 0) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
|
||||
@@ -85,6 +85,22 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<number | null> {
|
||||
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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') ? (
|
||||
<Workspace />
|
||||
) : path.startsWith('/admin') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user