web: keep a saved photo's look in EXIF and its own row
EXPORT no longer burns the caption strip: the pixels stay the photo's own and the look travels as metadata — ImageDescription (0x010e) for the tag, UserComment (0x9286, ASCII header) for the recipe JSON. SAVE PHOTO now stores the look with the frame (photos.recipe) and the uploader's consent for the community film strip (photos.consent, PATCH /api/photos/:id for the owner). The landing reel skips non-consented frames, and a new MY PHOTOS tab lists the account's saves, reopens one with the settings it was stored with, and carries the two consent switches.
This commit is contained in:
+99
-24
@@ -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<Photo, 'consent'> & { 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+103
-11
@@ -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<Photo[]>([]);
|
||||
// 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<number | null>(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<Recipe[]>([]);
|
||||
@@ -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':
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<void>(`/photos/${id}`, { method: 'DELETE' }),
|
||||
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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: '✦' },
|
||||
|
||||
Reference in New Issue
Block a user