diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index 7f0c524..3cfbb5b 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -117,6 +117,21 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot => } } +// The saved-photo flow, added later still: +// recipe — the look that made these pixels (same JSON a recipe row holds), so +// the studio can open the photo again and keep editing it. +// consent — the uploader's own say over the landing strip. The params live in +// this row, never burned into the image. +{ + const cols = db.prepare('PRAGMA table_info(photos)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'consent')) { + db.exec(`ALTER TABLE photos ADD COLUMN consent INTEGER NOT NULL DEFAULT 1`); + } + if (!cols.some((c) => c.name === 'recipe')) { + db.exec(`ALTER TABLE photos ADD COLUMN recipe TEXT`); + } +} + // `avatar` is the stored file name, or null for "no picture". export type User = { id: number; @@ -278,42 +293,81 @@ export type Photo = { tag: string | null; title: string | null; meta: string | null; + // The uploader's permission for this photo to appear on the landing strip. + // The owner's own folder reads it back to draw the toggle. + consent: boolean; }; +// The owner's own row adds the look that made it, so it can be opened again. +export type MyPhoto = Photo & { recipe: unknown | 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 }; +export type PhotoMeta = { + tag?: string | null; + title?: string | null; + meta?: string | null; + recipe?: unknown; + consent?: boolean; +}; -// One SELECT list, so the four call sites cannot drift apart. +// One SELECT list, so the 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`; + photos.tag AS tag, photos.title AS title, photos.meta AS meta, + photos.consent AS consent`; + +// SQLite has no boolean: a row comes back 0/1 and a recipe as its JSON text. +type PhotoRow = Omit & { consent: number }; +type MyPhotoRow = PhotoRow & { recipe: string | null }; +const toPhoto = (row: PhotoRow): Photo => ({ ...row, consent: row.consent === 1 }); +const toMyPhoto = (row: MyPhotoRow): MyPhoto => ({ + ...toPhoto(row), + // A row whose JSON will not parse is still a photo: its settings are simply + // gone, not worth failing the whole folder over. + recipe: (() => { + try { + return row.recipe ? JSON.parse(row.recipe) : null; + } catch { + return null; + } + })(), +}); export function listPhotos(): Photo[] { - return db - .prepare( - `SELECT ${PHOTO_COLUMNS} + return ( + db + .prepare( + `SELECT ${PHOTO_COLUMNS} FROM photos JOIN users ON users.id = photos.user_id WHERE users.deleted_at IS NULL ORDER BY photos.id DESC`, - ) - .all() as Photo[]; + ) + .all() as PhotoRow[] + ).map(toPhoto); } export function listPhotosWithOwner(): AdminPhoto[] { - return db - .prepare( - `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 - ORDER BY photos.id DESC`, - ) - .all() as AdminPhoto[]; + return ( + db + .prepare( + `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 + ORDER BY photos.id DESC`, + ) + .all() as (PhotoRow & { userId: number; email: string; mime: string; bytes: number })[] + ).map((row) => ({ ...toPhoto(row), userId: row.userId, email: row.email, mime: row.mime, bytes: row.bytes })); } -// 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[]; +// A member's own folder, newest first. No JOIN: the owner is the caller. This +// is the one listing that carries `recipe` — the look to reopen the photo with. +export function listPhotosByUser(userId: number): MyPhoto[] { + return ( + db + .prepare( + `SELECT ${PHOTO_COLUMNS}, photos.recipe AS recipe + FROM photos WHERE user_id = ? ORDER BY photos.id DESC`, + ) + .all(userId) as MyPhotoRow[] + ).map(toMyPhoto); } // Admin listing: one row per account with how many photos it owns. Blocked and @@ -406,9 +460,21 @@ export function createPhoto( const ts = now(); const info = db .prepare( - 'INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + `INSERT INTO photos (user_id, file, mime, bytes, created_at, tag, title, meta, consent, recipe) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) - .run(userId, file, mime, bytes, ts, meta?.tag ?? null, meta?.title ?? null, meta?.meta ?? null); + .run( + userId, + file, + mime, + bytes, + ts, + meta?.tag ?? null, + meta?.title ?? null, + meta?.meta ?? null, + meta?.consent === false ? 0 : 1, + meta?.recipe === undefined ? null : JSON.stringify(meta.recipe), + ); // A fresh upload is a strip photo until the curator moves it to a live slot. return { id: Number(info.lastInsertRowid), @@ -417,9 +483,18 @@ export function createPhoto( tag: meta?.tag ?? null, title: meta?.title ?? null, meta: meta?.meta ?? null, + consent: meta?.consent !== false, }; } +// The uploader's own toggle: may this photo show on the landing strip? +export function setPhotoConsent(userId: number, id: number, consent: boolean): boolean { + return ( + db.prepare('UPDATE photos SET consent = ? WHERE id = ? AND user_id = ?').run(consent ? 1 : 0, id, userId) + .changes > 0 + ); +} + // The stored file name is only ever used through here, and callers must still // reject anything that is not a single path segment (see server.ts). export function photoFile(id: number): { file: string; mime: string } | undefined { diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index 1d52989..28b528b 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -33,6 +33,7 @@ import { photoPath, sessionUser, setPhotoSlot, + setPhotoConsent, setUserAvatar, setUserBlocked, setUserPassword, @@ -339,6 +340,9 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) => // 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; +// The recipe travels as one URL-encoded query parameter beside the labels; this +// is the ceiling of what the studio can hand back (a real one is well under 1KB). +const RECIPE_PARAM_MAX = 3000; function cleanMeta(value: unknown, max: number): string | null { if (typeof value !== 'string') return null; @@ -353,9 +357,28 @@ function photoMeta(req: FastifyRequest): PhotoMeta { tag: cleanMeta(q.tag, META_MAX.tag), title: cleanMeta(q.title, META_MAX.title), meta: cleanMeta(q.meta, META_MAX.meta), + // The look that made the pixels, so the studio can open the photo again. + // It rides the query string like the labels do (the body is the raw image), + // so it is capped here: a recipe this app writes is well under a kilobyte. + recipe: parseRecipeParam(q.recipe), + // Consent is the uploader's, and only an explicit false opts out. + consent: q.consent !== '0', }; } +// Anything the studio could not read back as a recipe object is dropped, never +// stored half-parsed: the row must not carry a blob that breaks the folder. +function parseRecipeParam(raw: unknown): unknown { + if (typeof raw !== 'string' || raw.length === 0) return undefined; + if (raw.length > RECIPE_PARAM_MAX) return undefined; + try { + const value: unknown = JSON.parse(raw); + return value && typeof value === 'object' ? value : undefined; + } catch { + return undefined; + } +} + // 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. @@ -460,6 +483,21 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) = .send(data); }); +// The uploader's own permission switch: may this photo show on the landing +// strip? Only the owner may flip it (an admin curates the slot, not the +// consent), and only their own row is reachable — the user_id in the WHERE is +// the authorisation. +app.patch<{ 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 body = (req.body ?? {}) as { consent?: unknown }; + if (typeof body.consent !== 'boolean') return reply.status(400).send({ error: 'invalid consent' }); + if (!setPhotoConsent(user.id, id, body.consent)) return reply.status(404).send({ error: 'photo not found' }); + return reply.status(200).send({ id, consent: body.consent }); +}); + // 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 diff --git a/docker/frontend/shared/utils/exifWrite.ts b/docker/frontend/shared/utils/exifWrite.ts index 14818d1..6d3a903 100644 --- a/docker/frontend/shared/utils/exifWrite.ts +++ b/docker/frontend/shared/utils/exifWrite.ts @@ -32,6 +32,11 @@ export interface ExifStamp { // Device identity for the Gallery watermark blob (ref. watermarkBlob). Only // used when the source photo carries no blob of its own. device?: DeviceIdentity | null; + // What the studio would once have burned into the pixels: the frame's tagline + // as ImageDescription (0x010e) and the whole look as JSON in UserComment + // (0x9286). Kept in metadata only — an exported file must stay clean. + imageDescription?: string | null; + userComment?: string | null; } /** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */ @@ -114,6 +119,16 @@ function undefinedEntry(tag: number, text: string): Entry { return { tag, type: UNDEFINED, count: data.length, data }; } +// UserComment (0x9286) must open with an 8-byte character-code header; the rest +// is UTF-8, which every reader that accepts "ASCII" here decodes correctly. +function userCommentEntry(text: string): Entry { + const body = new TextEncoder().encode(text); + const data = new Uint8Array(8 + body.length); + data.set([0x41, 0x53, 0x43, 0x49, 0x49, 0, 0, 0]); // "ASCII\0\0\0" + data.set(body, 8); + return { tag: 0x9286, type: UNDEFINED, count: data.length, data }; +} + function longEntry(tag: number, v: number): Entry { const data = new Uint8Array(4); u32w(data, 0, v); @@ -384,6 +399,13 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE)); ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight + // The frame's own labels, metadata-only: ImageDescription names the look and + // UserComment carries the settings so the file reopens as it was saved. + const desc = stamp.imageDescription?.trim(); + if (desc) ifd0.set(0x010e, asciiEntry(0x010e, desc)); + const comment = stamp.userComment?.trim(); + if (comment) exif.set(0x9286, userCommentEntry(comment)); + // Baseline tags a conforming EXIF block is expected to carry. Framing apps // reject a file that omits them, and HALs (the emulator's especially) do not // always supply them, so fill the gaps and keep whatever the source had. diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 383cff7..87aabd8 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -295,9 +295,15 @@ export function Workspace() { // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. const [createReset, setCreateReset] = useState(0); - // 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. + // This account's own folder. The list drives the MY PHOTOS tab, which reopens + // a saved frame with the settings it was stored with; the count labels the + // save button. The API is the real cap — this only labels the button. const [myPhotos, setMyPhotos] = useState([]); + // Consent for the NEXT save: does it join the landing film strip? Sticky, so + // the answer survives several saves. A guest's frames never reach the reel. + const [shareToStrip, setShareToStrip] = useState(true); + // The saved photo standing on the stage, if any — its own consent chip shows. + const [openPhotoId, setOpenPhotoId] = useState(null); // 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([]); @@ -702,8 +708,9 @@ export function Workspace() { // 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. + // grain, warmth and monochrome state. Metadata only: an exported file keeps + // clean pixels (the strip rides EXIF), and a saved photo stores it 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(); @@ -713,6 +720,13 @@ export function Workspace() { return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; }, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]); + // Everything needed to put this photo back on the stage: the recipe plus the + // frame and geotag the stage held. EXIF and a saved photo both carry it. + const photoSettings = useMemo( + () => ({ ...recipe, frameId: cropEditing ? 'none' : frameId, useGeotag }), + [recipe, frameId, cropEditing, useGeotag] + ); + const exportPhoto = useCallback(async () => { if (!engine || !source) return; setExporting(true); @@ -732,8 +746,6 @@ 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 @@ -747,7 +759,8 @@ export function Workspace() { if (!out) throw new Error('render returned null'); // Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same - // order the app uses. The source photo donates its camera/lens tags. + // order the app uses. The source photo donates its camera/lens tags; the + // recipe is stamped as metadata, never burned into the pixels. const bytes = writeJpegExif( out.bytes, { @@ -757,6 +770,8 @@ export function Workspace() { height: out.height, dpi: 300, locationName: place || gps?.locality || null, + imageDescription: strip.tag, + userComment: JSON.stringify(photoSettings), }, source.bytes ); @@ -767,7 +782,7 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip]); + }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip, photoSettings]); // 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 @@ -813,14 +828,57 @@ export function Workspace() { 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]); + const { photo } = await api.uploadPhoto(file, strip, { recipe: photoSettings, consent: shareToStrip }); + // The list entry carries the recipe we just sent, so reopening works + // before the next /photos/mine refresh. + setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); + setOpenPhotoId(photo.id); } 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]); + }, [user, engine, source, myPhotos.length, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, shareToStrip, t]); + + // Reopen one of this account's saved frames: the bytes come back as a File + // through the same door an upload uses, then the stored look goes on top. + const openMyPhoto = useCallback( + async (photo: Photo) => { + setBusy(t('auth.busy')); + setError(null); + try { + const res = await fetch(api.photoUrl(photo.id)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' })); + const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null; + if (saved) { + applyRecipe(saved); + if (saved.frameId) setFrameId(saved.frameId); + setUseGeotag(!!saved.useGeotag); + } + setOpenPhotoId(photo.id); + } catch { + setError(t('err.decode')); + } finally { + setBusy(null); + } + }, + [loadFile, applyRecipe, t] + ); + + // The saved frame's own switch. The reel reads `consent`, so the change is + // live as soon as the store answers it. + const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => { + if (!photo) return; + const next = photo.consent === false; + try { + await api.setPhotoConsent(photo.id, next); + setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: next } : p))); + } catch { + // Leave the old value; the next load states the truth. + } + }, []); // --- strips --------------------------------------------------------------- // The cascade: a chip opens the next column, and the column it came from stays @@ -1172,6 +1230,40 @@ export function Workspace() { active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name, onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); + case 'photos': { + // MY PHOTOS: the account's own saved frames, newest first, plus the two + // consent switches. Opening one reopens it for a further edit. + const open = myPhotos.find((p) => p.id === openPhotoId); + const rows: ChipDef[] = [ + { + key: 'strip-next', + label: t(shareToStrip ? 'myp.newOn' : 'myp.newOff'), + active: shareToStrip, + onClick: () => setShareToStrip((v) => !v), + }, + ]; + if (open) { + rows.push({ + key: 'strip-open', + label: t(open.consent === false ? 'myp.thisOff' : 'myp.thisOn'), + active: open.consent !== false, + onClick: () => void togglePhotoConsent(open), + }); + } + if (myPhotos.length === 0) { + rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} }); + } else { + rows.push( + ...myPhotos.map((p) => ({ + key: `photo-${p.id}`, + label: p.title ?? `#${p.id}`, + active: p.id === openPhotoId, + onClick: () => void openMyPhoto(p), + })) + ); + } + return rows; + } case 'light': return [...paramChips(PARAM_DEFS.iq), groupChip('dr')]; case 'wb': diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index c368911..ff75f82 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -173,7 +173,7 @@ export function Landing() { // 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) => ({ + ...photos.filter((p) => p.slot === 'strip' && p.consent !== false).map((p) => ({ tag: p.tag ?? '#COMMUNITY', title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' }, meta: p.meta ?? `#${p.id}`, diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 02424a7..5c45179 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -46,6 +46,12 @@ export interface Photo { tag: string | null; title: string | null; meta: string | null; + // The uploader's consent to show this frame on the landing film strip. Own + // rows carry it back; the public reel only returns consented ones anyway. + consent?: boolean; + // The look the photo was saved with, on the owner's own listing only — the + // studio reads it back so a saved photo can be reopened for editing. + recipe?: Recipe | null; } // What a caller may attach to an upload. Same three labels. @@ -137,17 +143,24 @@ export const api = { // labels ride the query string, since the body is the image itself. listPhotos: () => call<{ photos: Photo[] }>('/photos'), listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'), - uploadPhoto: async (file: File, labels?: PhotoLabels) => { + uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: { recipe?: Recipe; consent?: boolean }) => { 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); + // The look rides the query string too, so the photo reopens with its own + // settings. The server caps the length and drops anything not an object. + if (opts?.recipe) q.set('recipe', JSON.stringify(opts.recipe)); + if (opts?.consent === false) q.set('consent', '0'); 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 }; }, + // Toggle this photo's place on the landing film strip. Owner-only. + setPhotoConsent: (id: number, consent: boolean) => + call<{ id: number; consent: boolean }>(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ consent }) }), // 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`, diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index ddc40a0..10b15da 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -29,6 +29,7 @@ export const en: Dict = { 'tab.presets': 'PRESETS', 'tab.favorited': 'FAVORITED', + 'tab.photos': 'MY PHOTOS', 'tab.light': 'LIGHT', 'tab.wb': 'WB', 'tab.fx': 'FX', @@ -112,6 +113,11 @@ export const en: Dict = { '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}', + 'myp.newOn': 'NEW SAVES: FILM STRIP', + 'myp.newOff': 'NEW SAVES: PRIVATE', + 'myp.thisOn': 'THIS PHOTO: FILM STRIP', + 'myp.thisOff': 'THIS PHOTO: PRIVATE', + 'myp.empty': 'NO SAVED PHOTOS YET.', 'photo.contribute': 'CONTRIBUTE A PHOTO', 'photo.uploading': 'UPLOADING…', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 021e3c1..9ef74a3 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -36,6 +36,7 @@ export const vi = { 'tab.presets': 'PRESETS', 'tab.favorited': 'FAVORITED', + 'tab.photos': 'ẢNH CỦA TÔI', 'tab.light': 'LIGHT', 'tab.wb': 'WB', 'tab.fx': 'FX', @@ -119,6 +120,11 @@ export const vi = { '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}', + 'myp.newOn': 'BẢN LƯU MỚI: LÊN DẢI PHIM', + 'myp.newOff': 'BẢN LƯU MỚI: RIÊNG TƯ', + 'myp.thisOn': 'ẢNH NÀY: LÊN DẢI PHIM', + 'myp.thisOff': 'ẢNH NÀY: RIÊNG TƯ', + 'myp.empty': 'CHƯA LƯU ẢNH NÀO.', 'photo.contribute': 'ĐÓNG GÓP ẢNH', 'photo.uploading': 'ĐANG TẢI…', diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index da62a5b..4afa367 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -1,15 +1,16 @@ import type { MsgKey } from '../i18n/vi'; import { useI18n } from '../i18n/I18nProvider'; -// The app's seven tabs. WB leads LIGHT — white balance is the first thing the +// The app's eight tabs. WB leads LIGHT — white balance is the first thing the // eye corrects, and the web rail draws them in the order the panel opens. The // rail is a single column on desktop and becomes the bottom bar under 860px // (see styles/app.css). -export type TabId = 'presets' | 'favorited' | 'wb' | 'light' | 'fx' | 'frame' | 'create'; +export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'create'; export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'presets', label: 'tab.presets', glyph: '◉' }, { id: 'favorited', label: 'tab.favorited', glyph: '★' }, + { id: 'photos', label: 'tab.photos', glyph: '▤' }, { id: 'wb', label: 'tab.wb', glyph: '◐' }, { id: 'light', label: 'tab.light', glyph: '☀' }, { id: 'fx', label: 'tab.fx', glyph: '✦' },