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".
|
// `avatar` is the stored file name, or null for "no picture".
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -257,13 +270,26 @@ export function deleteRecipe(userId: number, id: number): boolean {
|
|||||||
// ---- contributed strip photos -------------------------------------------
|
// ---- contributed strip photos -------------------------------------------
|
||||||
// The public shape carries no owner: the landing page is anonymous, so the
|
// The public shape carries no owner: the landing page is anonymous, so the
|
||||||
// uploader's email must never be reachable from an unauthenticated request.
|
// 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 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[] {
|
export function listPhotos(): Photo[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.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
|
FROM photos JOIN users ON users.id = photos.user_id
|
||||||
WHERE users.deleted_at IS NULL
|
WHERE users.deleted_at IS NULL
|
||||||
ORDER BY photos.id DESC`,
|
ORDER BY photos.id DESC`,
|
||||||
@@ -274,7 +300,7 @@ export function listPhotos(): Photo[] {
|
|||||||
export function listPhotosWithOwner(): AdminPhoto[] {
|
export function listPhotosWithOwner(): AdminPhoto[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.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,
|
photos.user_id AS userId, photos.mime AS mime, photos.bytes AS bytes,
|
||||||
users.email AS email
|
users.email AS email
|
||||||
FROM photos JOIN users ON users.id = photos.user_id
|
FROM photos JOIN users ON users.id = photos.user_id
|
||||||
@@ -283,6 +309,13 @@ export function listPhotosWithOwner(): AdminPhoto[] {
|
|||||||
.all() as 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
|
// 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.
|
// removed accounts stay listed — a removed one has to be findable to restore it.
|
||||||
export type AdminUser = {
|
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;
|
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 ts = now();
|
||||||
const info = db
|
const info = db
|
||||||
.prepare('INSERT INTO photos (user_id, file, mime, bytes, created_at) VALUES (?, ?, ?, ?, ?)')
|
.prepare(
|
||||||
.run(userId, file, mime, bytes, ts);
|
'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.
|
// 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
|
// 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;
|
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.
|
// Curating, not moderating: where this photo is allowed to surface.
|
||||||
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
|
export function setPhotoSlot(id: number, slot: PhotoSlot): boolean {
|
||||||
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;
|
return db.prepare('UPDATE photos SET slot = ? WHERE id = ?').run(slot, id).changes > 0;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
deleteAllPhotos,
|
deleteAllPhotos,
|
||||||
deletePhoto,
|
deletePhoto,
|
||||||
|
deletePhotoOf,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
findUserById,
|
findUserById,
|
||||||
isPhotoSlot,
|
isPhotoSlot,
|
||||||
listPhotos,
|
listPhotos,
|
||||||
|
listPhotosByUser,
|
||||||
listPhotosWithOwner,
|
listPhotosWithOwner,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
listUsersWithCounts,
|
listUsersWithCounts,
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
updateUserEmail,
|
updateUserEmail,
|
||||||
userAvatar,
|
userAvatar,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
|
type PhotoMeta,
|
||||||
type Recipe,
|
type Recipe,
|
||||||
type User,
|
type User,
|
||||||
} from './db';
|
} from './db';
|
||||||
@@ -332,11 +335,40 @@ app.delete<{ Params: { id: string } }>('/api/recipes/:id', async (req, reply) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---- contributed strip photos -------------------------------------------
|
// ---- 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
|
// 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
|
// 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.
|
// reaches the filesystem, and the row is the only place the real mime lives.
|
||||||
app.get('/api/photos', async () => ({ photos: listPhotos() }));
|
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) => {
|
app.post('/api/photos', { bodyLimit: MAX_PHOTO_BYTES + 8192 }, async (req, reply) => {
|
||||||
const user = auth(req);
|
const user = auth(req);
|
||||||
if (!user) return reply.status(401).send({ error: 'unauthorized' });
|
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]}`;
|
const file = `${randomBytes(16).toString('hex')}.${EXT[mime]}`;
|
||||||
writeFileSync(photoPath(file), body, { flag: 'wx' });
|
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 });
|
return reply.status(201).send({ photo });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,6 +460,21 @@ app.get<{ Params: { id: string } }>('/api/photos/:id/file', async (req, reply) =
|
|||||||
.send(data);
|
.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 ---------------------------------------------------------------
|
// ---- admin ---------------------------------------------------------------
|
||||||
// Moderation only: the allowlist can list everything and clean up. There is
|
// Moderation only: the allowlist can list everything and clean up. There is
|
||||||
// deliberately no endpoint here that grants the privilege itself.
|
// deliberately no endpoint here that grants the privilege itself.
|
||||||
|
|||||||
@@ -218,6 +218,63 @@ try {
|
|||||||
(await fetch(`${BASE}/photos/..%2f..%2fetc%2fpasswd/file`)).status === 404,
|
(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 ---------------------------------------------------------
|
// ---- moderation ---------------------------------------------------------
|
||||||
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
|
check('a guest cannot moderate', (await actor().req('/admin/photos')).status === 401);
|
||||||
const forbidden = await user.req('/admin/photos');
|
const forbidden = await user.req('/admin/photos');
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ export function Admin() {
|
|||||||
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
|
||||||
<div className="adm-meta">
|
<div className="adm-meta">
|
||||||
<b>#{p.id}</b>
|
<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.owner')}: {p.email}</span>
|
||||||
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
<span>{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}</span>
|
||||||
<span>{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}</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 { useI18n } from './i18n/I18nProvider';
|
||||||
import { loadEngine } from './engine/assets';
|
import { loadEngine } from './engine/assets';
|
||||||
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
|
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 { 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 { TIERS } from './config/tiers';
|
||||||
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
|
||||||
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
||||||
@@ -27,6 +27,8 @@ import {
|
|||||||
} from '../shared/types';
|
} from '../shared/types';
|
||||||
import type { MsgKey } from './i18n/vi';
|
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
|
// Preview renders on a downscaled copy; export always re-renders the original
|
||||||
// bytes, so nothing shown here is baked into the file.
|
// bytes, so nothing shown here is baked into the file.
|
||||||
const PREVIEW_MAX = 1600;
|
const PREVIEW_MAX = 1600;
|
||||||
@@ -185,6 +187,8 @@ export function Workspace() {
|
|||||||
const [peek, setPeek] = useState(false);
|
const [peek, setPeek] = useState(false);
|
||||||
|
|
||||||
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
|
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 [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' });
|
||||||
const [place, setPlace] = useState(() => session.place ?? '');
|
const [place, setPlace] = useState(() => session.place ?? '');
|
||||||
const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false);
|
const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false);
|
||||||
@@ -285,8 +289,12 @@ export function Workspace() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [savingPhoto, setSavingPhoto] = useState(false);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
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
|
// 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.
|
// here and nowhere else, so reloading the page is what deletes it.
|
||||||
const [created, setCreated] = useState<Recipe[]>([]);
|
const [created, setCreated] = useState<Recipe[]>([]);
|
||||||
@@ -339,9 +347,11 @@ export function Workspace() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setSaved([]);
|
setSaved([]);
|
||||||
|
setMyPhotos([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
|
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
|
||||||
|
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// --- preview render -------------------------------------------------------
|
// --- preview render -------------------------------------------------------
|
||||||
@@ -408,6 +418,7 @@ export function Workspace() {
|
|||||||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
||||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||||
const found = await readGps(bytes);
|
const found = await readGps(bytes);
|
||||||
|
const foundIso = await readIso(bytes);
|
||||||
// The photo's own shape drives the default crop frame. Read off the
|
// The photo's own shape drives the default crop frame. Read off the
|
||||||
// downscaled copy — same aspect, a fraction of the decode cost.
|
// downscaled copy — same aspect, a fraction of the decode cost.
|
||||||
const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' }))
|
const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' }))
|
||||||
@@ -429,6 +440,7 @@ export function Workspace() {
|
|||||||
previewJobRef.current++;
|
previewJobRef.current++;
|
||||||
setPreviewBytes(preview);
|
setPreviewBytes(preview);
|
||||||
setGps(found);
|
setGps(found);
|
||||||
|
setIso(foundIso);
|
||||||
// A new photo starts a new history whose floor is the default look, so
|
// 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.
|
// UNDO walks all the way back and still leaves the photo loaded.
|
||||||
setUndo([defaultLook()]);
|
setUndo([defaultLook()]);
|
||||||
@@ -643,6 +655,19 @@ export function Workspace() {
|
|||||||
input.click();
|
input.click();
|
||||||
}, [importRecipeFile]);
|
}, [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 () => {
|
const exportPhoto = useCallback(async () => {
|
||||||
if (!engine || !source) return;
|
if (!engine || !source) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
@@ -662,6 +687,8 @@ export function Workspace() {
|
|||||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||||
dpi: 300,
|
dpi: 300,
|
||||||
sharpen: true,
|
sharpen: true,
|
||||||
|
// The file carries the strip; the live stage never does.
|
||||||
|
caption: strip,
|
||||||
photoRotation: rotation,
|
photoRotation: rotation,
|
||||||
cropRect: cropLive ? cropRect : null,
|
cropRect: cropLive ? cropRect : null,
|
||||||
watermark: markOn && markText
|
watermark: markOn && markText
|
||||||
@@ -695,7 +722,60 @@ export function Workspace() {
|
|||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
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 ---------------------------------------------------------------
|
// --- strips ---------------------------------------------------------------
|
||||||
// The cascade: a chip opens the next column, and the column it came from stays
|
// 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}
|
recipeName={recipe.name}
|
||||||
user={user}
|
user={user}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
|
savingPhoto={savingPhoto}
|
||||||
canUndo={undo.length > 0}
|
canUndo={undo.length > 0}
|
||||||
onUndo={undoLast}
|
onUndo={undoLast}
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
|
onSavePhoto={() => void savePhotoToFolder()}
|
||||||
onExport={exportPhoto}
|
onExport={exportPhoto}
|
||||||
|
photoCount={myPhotos.length}
|
||||||
|
photoMax={MAX_PHOTOS}
|
||||||
onAuth={() => setAuthMode('login')}
|
onAuth={() => setAuthMode('login')}
|
||||||
onSignup={() => setAuthMode('signup')}
|
onSignup={() => setAuthMode('signup')}
|
||||||
onLogout={async () => {
|
onLogout={async () => {
|
||||||
|
|||||||
@@ -170,10 +170,13 @@ export function Landing() {
|
|||||||
const reel = useMemo<Look[]>(
|
const reel = useMemo<Look[]>(
|
||||||
() => [
|
() => [
|
||||||
...REEL,
|
...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) => ({
|
...photos.filter((p) => p.slot === 'strip').map((p) => ({
|
||||||
tag: '#COMMUNITY',
|
tag: p.tag ?? '#COMMUNITY',
|
||||||
title: { en: 'Community Frame', vi: 'Khung cộng đồng' },
|
title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' },
|
||||||
meta: `#${p.id}`,
|
meta: p.meta ?? `#${p.id}`,
|
||||||
photo: api.photoUrl(p.id),
|
photo: api.photoUrl(p.id),
|
||||||
filter: 'none',
|
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';
|
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
|
||||||
|
|
||||||
// A strip contribution as the public sees it — the API never puts an email on
|
// 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 {
|
export interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
slot: PhotoSlot;
|
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.
|
// 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' }),
|
deleteRecipe: (id: number) => call<void>(`/recipes/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// The strip. Upload is the raw file as the request body — one image per
|
// 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'),
|
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
|
||||||
uploadPhoto: async (file: File) => {
|
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
|
||||||
const res = await uploadBytes('/api/photos', file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
|
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);
|
const body = await readJson(res);
|
||||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||||
return body as unknown as { photo: Photo };
|
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`,
|
photoUrl: (id: number) => `/api/photos/${id}/file`,
|
||||||
|
|
||||||
// Admin only: every account, with how many photos it owns.
|
// 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;
|
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
||||||
photoRotation?: 0 | 90 | 180 | 270;
|
photoRotation?: 0 | 90 | 180 | 270;
|
||||||
photoStraighten?: number;
|
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 {
|
export interface RenderInput {
|
||||||
@@ -699,6 +703,55 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
|||||||
resultImage = sharpened;
|
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);
|
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||||
if (!bytes || bytes.length === 0) {
|
if (!bytes || bytes.length === 0) {
|
||||||
console.error('Failed to encode image to JPEG');
|
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 {
|
export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void {
|
||||||
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
|
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@@ -106,13 +106,31 @@ export const en: Dict = {
|
|||||||
'save.recipes': 'SAVE RECIPES',
|
'save.recipes': 'SAVE RECIPES',
|
||||||
'save.saved': 'Saved "{name}"',
|
'save.saved': 'Saved "{name}"',
|
||||||
'save.needLogin': 'Log in to save a recipe.',
|
'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.contribute': 'CONTRIBUTE A PHOTO',
|
||||||
'photo.uploading': 'UPLOADING…',
|
'photo.uploading': 'UPLOADING…',
|
||||||
'photo.thanks': 'Your frame is in the strip.',
|
'photo.thanks': 'Your frame is in the strip.',
|
||||||
'photo.failed': 'Upload failed: {msg}',
|
'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.admin': 'Admin',
|
||||||
|
'nav.photos': 'My photos',
|
||||||
'nav.profile': 'Profile',
|
'nav.profile': 'Profile',
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'adm.title': 'Strip moderation',
|
'adm.title': 'Strip moderation',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const vi = {
|
|||||||
'nav.language': 'Ngôn ngữ',
|
'nav.language': 'Ngôn ngữ',
|
||||||
'nav.back': 'Về trang chủ',
|
'nav.back': 'Về trang chủ',
|
||||||
'nav.admin': 'Quản trị',
|
'nav.admin': 'Quản trị',
|
||||||
|
'nav.photos': 'Ảnh của tôi',
|
||||||
'nav.profile': 'Hồ sơ',
|
'nav.profile': 'Hồ sơ',
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'nav.guest': 'Khách',
|
'nav.guest': 'Khách',
|
||||||
@@ -112,12 +113,29 @@ export const vi = {
|
|||||||
'save.recipes': 'LƯU CÔNG THỨC',
|
'save.recipes': 'LƯU CÔNG THỨC',
|
||||||
'save.saved': 'Đã lưu "{name}"',
|
'save.saved': 'Đã lưu "{name}"',
|
||||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
'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.contribute': 'ĐÓNG GÓP ẢNH',
|
||||||
'photo.uploading': 'ĐANG TẢI…',
|
'photo.uploading': 'ĐANG TẢI…',
|
||||||
'photo.thanks': 'Ảnh của bạn đã vào dải phim.',
|
'photo.thanks': 'Ảnh của bạn đã vào dải phim.',
|
||||||
'photo.failed': 'Không tải được ảnh: {msg}',
|
'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.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.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',
|
'adm.upload': 'THÊM ẢNH',
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import { Landing } from './Landing';
|
|||||||
import { Workspace } from './App';
|
import { Workspace } from './App';
|
||||||
import { Admin } from './Admin';
|
import { Admin } from './Admin';
|
||||||
import { ProfilePage } from './ProfilePage';
|
import { ProfilePage } from './ProfilePage';
|
||||||
|
import { PhotosPage } from './PhotosPage';
|
||||||
|
|
||||||
// Four routes, no router: the landing page, the workspace, the strip moderation
|
// Five 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
|
// screen, the member's own profile and the member's photo folder. nginx serves
|
||||||
// (SPA fallback), so this is just a pathname check.
|
// index.html for all of them (SPA fallback), so this is a pathname check.
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const page = path.startsWith('/app') ? (
|
const page = path.startsWith('/app') ? (
|
||||||
<Workspace />
|
<Workspace />
|
||||||
) : path.startsWith('/admin') ? (
|
) : path.startsWith('/admin') ? (
|
||||||
<Admin />
|
<Admin />
|
||||||
|
) : path.startsWith('/photos') ? (
|
||||||
|
<PhotosPage />
|
||||||
) : path.startsWith('/profile') ? (
|
) : path.startsWith('/profile') ? (
|
||||||
<ProfilePage />
|
<ProfilePage />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ export function TopBar({
|
|||||||
recipeName,
|
recipeName,
|
||||||
user,
|
user,
|
||||||
exporting,
|
exporting,
|
||||||
|
savingPhoto,
|
||||||
canUndo,
|
canUndo,
|
||||||
onUndo,
|
onUndo,
|
||||||
onReset,
|
onReset,
|
||||||
|
onSavePhoto,
|
||||||
onExport,
|
onExport,
|
||||||
|
photoCount,
|
||||||
|
photoMax,
|
||||||
onAuth,
|
onAuth,
|
||||||
onSignup,
|
onSignup,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -21,10 +25,16 @@ export function TopBar({
|
|||||||
recipeName: string;
|
recipeName: string;
|
||||||
user: User | null;
|
user: User | null;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
|
savingPhoto: boolean;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
onSavePhoto: () => void;
|
||||||
onExport: () => 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;
|
onAuth: () => void;
|
||||||
onSignup: () => void;
|
onSignup: () => void;
|
||||||
onLogout: () => 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" 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" 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}>
|
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
|
||||||
{exporting ? t('act.exporting') : t('act.export')}
|
{exporting ? t('act.exporting') : t('act.export')}
|
||||||
</button>
|
</button>
|
||||||
@@ -149,7 +169,10 @@ export function TopBar({
|
|||||||
{user.admin ? (
|
{user.admin ? (
|
||||||
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
|
<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>
|
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user