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
+5
View File
@@ -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>
+87 -3
View File
@@ -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 () => {
+6 -3
View File
@@ -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',
})),
+147
View File
@@ -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>
);
}
+25 -4
View File
@@ -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');
+16
View File
@@ -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');
+18
View File
@@ -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',
+18
View File
@@ -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',
+6 -3
View File
@@ -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 />
) : (
+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>
</>