Files
RecipesCam/docker/frontend/src/App.tsx
T
3dtours 4d0d44170f web: an FX chip that flips the photo to monochrome
It is a switch, not a look: it swaps the base filter for the mono stock and
swaps right back to the one the photo was wearing — name and sim included. The
adjustments are never touched, so a knob moved while it is on survives the trip
back, and the swap rides the undo stack like any other edit.
2026-09-18 16:16:28 +07:00

1787 lines
70 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ToolRail, type TabId } from './ui/ToolRail';
import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { TopBar } from './ui/TopBar';
import { AuthModal } from './ui/AuthModal';
import { NameModal } from './ui/NameModal';
import { DeletePhotoModal } from './ui/DeletePhotoModal';
import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
import { useI18n } from './i18n/I18nProvider';
import { loadEngine } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps';
import { loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { api, type Photo, type SavedRecipe, type User } from './api';
import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare';
import { writeJpegExif } from '../shared/utils/exifWrite';
import {
CROP_W_H,
DEFAULT_CROP_RECT,
type CropRatio,
type CropRect,
type FrameId,
type GPSInfo,
type Recipe,
} from '../shared/types';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
const MAX_PHOTOS = 12;
// Preview renders on a downscaled copy; export always re-renders the original
// bytes, so nothing shown here is baked into the file.
const PREVIEW_MAX = 1600;
// A committed crop keeps only a fraction of the photo, so the 1600px copy
// leaves too few pixels behind and the result reads as blocky once the stage
// scales it up. While a crop is live the source is re-derived larger, inverted
// by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is
// what the decode can afford: past it a wheel gesture stops keeping up.
const PREVIEW_HARD_MAX = 3200;
const CROP_PREVIEW_MIN_FRAC = 0.05;
// Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it
// on every notch.
const PREVIEW_QUANTUM = 512;
const PREVIEW_DEBOUNCE_MS = 120;
const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'none', label: 'frame.none' },
{ id: 'polaroid', label: 'frame.polaroid' },
{ id: 'classic-white', label: 'frame.classic' },
{ id: 'cinematic', label: 'frame.cinematic' },
{ id: 'wallframe', label: 'frame.wall' },
{ id: 'wallframe-landscape', label: 'frame.wallLand' },
];
// FREE draws the frame on the canvas and lets the user drag its corners, so
// every ratio is offered here — no ratio is applied until APPLY.
const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9'];
const TEMP_PRESETS: { label: string; kelvin: number }[] = [
{ label: 'AUTO', kelvin: 5500 },
{ label: 'DAYLIGHT', kelvin: 5600 },
{ label: 'DAYLIGHT -3R', kelvin: 5800 },
{ label: 'CLOUDY', kelvin: 6500 },
{ label: 'SHADE', kelvin: 7500 },
{ label: 'TUNGSTEN', kelvin: 3200 },
{ label: 'FLUOR', kelvin: 4000 },
];
// Kelvin -> sRGB (Tanner Helland's approximation, what every WB widget uses):
// 2500K reads warm orange, 5500K a near-neutral white, 10000K blue.
function kelvinToHex(k: number): string {
const t = Math.min(40000, Math.max(1000, k)) / 100;
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');
const r = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
const g = t <= 66 ? 99.4708025861 * Math.log(t) - 161.1195681661 : 288.1221695283 * (t - 60) ** -0.0755148492;
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
return `#${chan(r)}${chan(g)}${chan(b)}`;
}
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
// own hex, so the panel hands out one format).
function hslToHex(h: number, s: number, l: number): string {
const k = (n: number) => (n + h / 30) % 12;
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
const chan = (n: number) =>
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
.toString(16)
.padStart(2, '0');
return `#${chan(0)}${chan(8)}${chan(4)}`;
}
// The knobs that drive a colour get a swatch under their ruler, so the hue is
// visible next to the number. TINT runs green (-10) through a neutral grey (0)
// to magenta (+10) — the axis every WB tool labels G..M.
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
temperature: kelvinToHex,
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
};
// The option strips, one open at a time (phone's GroupKey set minus the groups
// the web engine has no knob for).
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm'
| 'wmColor' | 'wmSize' | 'gpsColor' | 'gpsName' | 'gpsTime';
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP).
const PARAM_GROUP: Partial<Record<string, GroupKey>> = { temperature: 'temp' };
const CHROME_OPTIONS = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
];
const DR_OPTIONS = [
{ v: 'auto', d: 'AUTO' },
{ v: '100', d: 'DR100' },
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
];
const WM_COLORS = [
{ v: '#ffffff', d: 'WHITE' },
{ v: '#f59e0b', d: 'AMBER' },
{ v: '#000000', d: 'BLACK' },
];
const WM_SIZES = [
{ v: '1', d: '1x' },
{ v: '1.5', d: '1.5x' },
{ v: '2', d: '2x' },
];
interface LoadedSource {
bytes: Uint8Array;
name: string;
url: string;
}
// Everything a single UNDO step puts back. The recipe alone was not enough:
// frame, crop, rotation, marks and geotag all change the photo too.
interface Look {
recipe: Recipe;
simId: string | null;
frameId: FrameId;
crop: CropRatio;
cropRect: CropRect;
cropApplied: boolean;
rotation: 0 | 90 | 180 | 270;
markOn: boolean;
markText: string;
markStyle: { color: string; size: number };
useGeotag: boolean;
gpsStyle: { color: string; showName: boolean; showTime: boolean };
}
export function Workspace() {
const { t } = useI18n();
// Session restore: read once, synchronously, so the first paint already holds
// the user's own settings instead of a flash of the defaults. The photo comes
// back from IndexedDB in the boot effect below.
const session = useMemo(loadState, []);
const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<LoadedSource | null>(null);
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [recipe, setRecipe] = useState<Recipe>(() => ({
...DEFAULT_RECIPES[0],
id: 'web-current',
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
...session.recipe,
}));
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null);
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
const [openParam, setOpenParam] = useState<string | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
const [peek, setPeek] = useState(false);
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
const [iso, setIso] = useState<number | null>(null);
const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' });
const [place, setPlace] = useState(() => session.place ?? '');
const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false);
const [gpsStyle, setGpsStyle] = useState(
() => session.gpsStyle ?? { color: '#f59e0b', showName: true, showTime: true }
);
const [markText, setMarkText] = useState(() => session.markText ?? '');
const [markOn, setMarkOn] = useState(() => session.markOn ?? false);
const [markStyle, setMarkStyle] = useState(() => session.markStyle ?? { color: '#ffffff', size: 1 });
// Every knob the workspace holds, written back on change — the reload half of
// the session. Small JSON, so no debounce is worth the risk of losing the last
// edit; the photo has its own store (engine/session.ts).
useEffect(() => {
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
// and the whole photo is shown), and applied (the crop alone is rendered).
// What the stage says it is painting at; PREVIEW_MAX until it reports.
const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX);
const onStageZoom = useCallback((longDevicePx: number) => {
setZoomNeedPx(
Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM))
);
}, []);
const cropEditing = crop !== 'none' && !cropApplied;
const cropLive = crop !== 'none' && cropApplied;
const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null;
// A committed crop keeps only part of the photo, so the 1600px copy leaves
// too few pixels behind and the stage shows a blocky upscale. While the crop
// is live the source is re-derived larger — 1/f, where f is the crop's
// longest side as a fraction of the photo's, capped so the decode stays
// bounded. cropRect is in the rotated frame, so the shape is swapped first.
const cropLongFrac = (() => {
const d = photoDims;
const turn = rotation % 180 !== 0;
const f = Math.max(
(turn ? d?.h ?? 0 : d?.w ?? 0) * cropRect.w,
(turn ? d?.w ?? 0 : d?.h ?? 0) * cropRect.h
);
const long = Math.max(d?.w ?? 0, d?.h ?? 0);
return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h);
})();
const previewMax = Math.min(
PREVIEW_HARD_MAX,
Math.max(
zoomNeedPx,
cropLive
? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC)))
: PREVIEW_MAX
)
);
// --- undo / redo ----------------------------------------------------------
// The stack holds whole looks. remember() reads a ref written during render,
// so it is stable and always snapshots the state as of the last painted
// frame — the same value the user is looking at when they press the button.
// The floor is the look the session loaded with; REDO holds what UNDO walked
// back over, up to the newest edit, and a fresh edit drops that branch.
const [undo, setUndo] = useState<Look[]>([]);
const [redo, setRedo] = useState<Look[]>([]);
// Which look of the open frame's own history is standing on the stage, if one
// was picked from its column; a fresh edit or another frame clears it.
const [histPick, setHistPick] = useState<number | null>(null);
// FX's MONOCHROME switch remembers what the photo was wearing, so flipping it
// back returns that filter — and its sim name — instead of guessing PROVIA.
const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null);
const lookRef = useRef<Look | null>(null);
lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, markOn, markText, markStyle, useGeotag, gpsStyle };
// Twenty looks is enough to walk, but the oldest entry is the floor — the look
// the session loaded with — and is never the one trimmed, so UNDO always
// reaches the start of the session.
const pushUndo = (snap: Look) =>
setUndo((stack) => (stack.length > 19 ? [stack[0], ...stack.slice(-18), snap] : [...stack, snap]));
const remember = useCallback(() => {
const snap = lookRef.current;
if (!snap) return;
pushUndo(snap);
setRedo([]);
// The look on the stage is no longer the one that was picked, so the
// history column drops its mark.
setHistPick(null);
}, []);
const restoreLook = useCallback((l: Look) => {
setRecipe(l.recipe);
setSimId(l.simId);
setFrameId(l.frameId);
setCrop(l.crop);
setCropRect(l.cropRect);
setCropApplied(l.cropApplied);
setRotation(l.rotation);
setMarkOn(l.markOn);
setMarkText(l.markText);
setMarkStyle(l.markStyle);
setUseGeotag(l.useGeotag);
setGpsStyle(l.gpsStyle);
}, []);
const defaultLook = useCallback((): Look => {
const provia = FILM_SIMS.find((f) => f.id === 'sim-provia');
const cur = lookRef.current?.recipe ?? DEFAULT_RECIPES[0];
return {
recipe: { ...(provia ? filmSimToRecipe(provia) : cur), id: cur.id, name: cur.name, isCustom: true },
simId: provia ? provia.id : null,
frameId: 'none',
crop: 'none',
cropRect: DEFAULT_CROP_RECT,
cropApplied: false,
rotation: 0,
markOn: false,
markText: '',
markStyle: { color: '#ffffff', size: 1 },
useGeotag: false,
gpsStyle: { color: '#f59e0b', showName: true, showTime: true },
};
}, []);
const [exporting, setExporting] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// 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. 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[]>([]);
// 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[]>([]);
// Which save flow is asking for a name, if any: `photo` on the first filing
// of an uploaded frame, `recipe` on SAVE RECENT. One modal, two callers.
const [naming, setNaming] = useState<'photo' | 'recipe' | null>(null);
// The saved photo whose removal is being confirmed, if any — its own history
// goes with it, so the dialog says so and offers a download first.
const [removing, setRemoving] = useState<Photo | null>(null);
// null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in
// tab (sign-in is the default; ?auth=signup asks for the other one); the
// landing page runs the same dialog in place instead of linking here.
const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => {
const asked = new URLSearchParams(window.location.search).get('auth');
return asked ? (asked === 'signup' ? 'signup' : 'login') : null;
});
const renderToken = useRef(0);
// Which previewMax the bytes in previewBytes were made at, so the upgrade
// effect does not redo the load-time 1600px copy.
const previewMaxRef = useRef(PREVIEW_MAX);
const previewJobRef = useRef(0);
// --- boot -----------------------------------------------------------------
useEffect(() => {
let alive = true;
loadEngine()
.then((loaded) => {
if (!alive) return;
setEngine(loaded);
if (!loaded.gpu) setError(t('err.noWebgl'));
})
.catch((err) => alive && setError(String(err)));
api
.me()
.then((r) => alive && setUser(r.user))
.catch(() => undefined); // signed out is a valid state — the demo needs no account
// ...and the photo half of the session, back out of IndexedDB.
loadPhoto()
.then((photo) => {
if (!alive || !photo) return;
return adoptPhoto(
photo.bytes,
photo.name,
new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }),
true
);
})
.catch(() => undefined);
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!user) {
setSaved([]);
setMyPhotos([]);
return;
}
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
}, [user]);
// --- preview render -------------------------------------------------------
useEffect(() => {
if (!engine || !previewBytes) return;
const token = ++renderToken.current;
const timer = setTimeout(async () => {
setBusy(t('act.exporting'));
try {
const out = await renderPhoto({
source: previewBytes,
recipe,
// An uncommitted crop is framed on the bare photo, so the overlay
// measures exactly what the engine will cut.
frameId: cropEditing ? 'none' : frameId,
useGeotag,
gpsInfo: gps,
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
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 (token !== renderToken.current) return;
if (!out) throw new Error('render returned null');
const url = URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return url;
});
} catch (err) {
if (token === renderToken.current) setError(t('err.generic', { msg: String(err) }));
} finally {
if (token === renderToken.current) setBusy(null);
}
}, PREVIEW_DEBOUNCE_MS);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
// The source copy behind previewBytes, re-made only when previewMax moves:
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
useEffect(() => {
if (!source || previewMaxRef.current === previewMax) return;
const job = ++previewJobRef.current;
resizedJpeg(source.bytes, previewMax)
.then((bytes) => {
if (job !== previewJobRef.current) return;
previewMaxRef.current = previewMax;
setPreviewBytes(bytes);
})
.catch(() => undefined);
}, [source, previewMax]);
// --- input ---------------------------------------------------------------
// One photo, two ways in: a drop/pick (which also stores it for the next
// visit) and the session restore, whose params are already in state and so
// must not be overwritten by the photo's own EXIF.
// A frame arriving on the stage replaces whatever the preview was holding.
// The old render is thrown away FIRST, so the new one can never be painted
// over it: the buffer is empty before the load starts.
const clearPreview = useCallback(() => {
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
}, []);
const adoptPhoto = useCallback(
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
const found = await readGps(bytes);
const foundIso = await readIso(bytes);
// The photo's own shape drives the default crop frame. Read off the
// downscaled copy — same aspect, a fraction of the decode cost.
const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' }))
.then((bmp) => {
const d = { w: bmp.width, h: bmp.height };
bmp.close();
return d;
})
.catch(() => null);
setPhotoDims(dims);
setSource((prev) => {
if (prev) URL.revokeObjectURL(prev.url);
return { bytes, name, url: URL.createObjectURL(blob) };
});
// The new photo's copy is the 1600px one; a live crop or a zoomed stage
// then upgrades it.
previewMaxRef.current = PREVIEW_MAX;
setZoomNeedPx(PREVIEW_MAX);
previewJobRef.current++;
setPreviewBytes(preview);
setGps(found);
setIso(foundIso);
// A new photo starts a new history whose floor is the default look, so
// UNDO walks all the way back and still leaves the photo loaded. REDO has
// nothing to walk forward onto in a history that just began.
setUndo([defaultLook()]);
setRedo([]);
if (keepGeo) return;
setUseGeotag(!!found);
if (!found) setGpsManual({ lat: '', lng: '' });
},
[defaultLook]
);
const loadFile = useCallback(
async (file: File) => {
setError(null);
setBusy(t('auth.busy'));
// A file from the disk is nobody's saved frame: SAVE PHOTO must add one,
// not overwrite whichever of the folder's own was open before. Opening one
// of those sets the id back, after this call.
setOpenPhotoId(null);
try {
const bytes = await fileToBytes(file);
await adoptPhoto(bytes, file.name, file);
void savePhoto(file.name, bytes);
} catch {
setError(t('err.decode'));
} finally {
setBusy(null);
}
},
[t, adoptPhoto]
);
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const file = e.clipboardData?.files?.[0];
if (file) void loadFile(file);
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [loadFile]);
// --- recipe edits ---------------------------------------------------------
const applyRecipe = useCallback(
(next: Recipe) => {
remember();
setRecipe(next);
setSimId(next.isCustom ? null : next.id);
// A recipe filed by SAVE RECENT carries the frame it was saved with;
// a bundled preset carries none, and then the frame on the stage stays.
const stored = (next as Recipe & { frameId?: FrameId }).frameId;
if (stored) setFrameId(stored);
},
[remember]
);
const setAdjustment = useCallback(
(patch: Partial<Recipe['adjustments']>) => {
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } }));
},
[]
);
// A slider drag is one edit, not one per pixel: the first move after the
// ruler opened takes the snapshot, the rest ride on it.
const sliderEditRef = useRef(false);
useEffect(() => {
sliderEditRef.current = false;
}, [openParam]);
const setAdjustmentOnce = useCallback(
(patch: Partial<Recipe['adjustments']>) => {
if (!sliderEditRef.current) {
sliderEditRef.current = true;
remember();
}
setAdjustment(patch);
},
[remember, setAdjustment]
);
// --- crop -----------------------------------------------------------------
// Picking a ratio only opens the frame; the crop is committed by APPLY.
const pickCrop = useCallback(
(ratio: CropRatio) => {
remember();
setCrop(ratio);
setCropApplied(false);
if (ratio === 'none' || ratio === 'free' || !photoDims) return;
// Largest centred rect of that shape the photo can hold. CROP_W_H is a
// PIXEL aspect; in fractions (of the photo's width/height) it becomes
// R = ratio / (w/h), so a 3:4 frame lands on a 3:4 photo and a square one
// on anything else.
const k = rotation % 180 === 0 ? photoDims.w / photoDims.h : photoDims.h / photoDims.w;
const R = CROP_W_H[ratio] / k;
const w = R >= 1 ? 1 : R;
const h = R >= 1 ? 1 / R : 1;
setCropRect({ x: (1 - w) / 2, y: (1 - h) / 2, w, h });
},
[remember, photoDims, rotation]
);
const applyCrop = useCallback(() => setCropApplied(true), []);
// One step back: the look standing now goes onto REDO and the stack's top
// comes back. CANCEL and the UNDO button are the same move.
const stepBack = useCallback(() => {
const last = undo[undo.length - 1];
const cur = lookRef.current;
if (!last || !cur) return;
restoreLook(last);
setUndo((stack) => stack.slice(0, -1));
setRedo((stack) => [...stack.slice(-19), cur]);
}, [undo, restoreLook]);
// CANCEL throws the framing away — the pick pushed the look it replaced, so
// one undo is exactly "never mind".
const cancelCrop = useCallback(() => stepBack(), [stepBack]);
const reset = useCallback(() => {
remember();
// Back to the startup look, exactly like the phone: the PROVIA sim, every
// knob neutral, no frame/crop, no rotation, both marks off and restyled.
restoreLook(defaultLook());
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
}, [remember, restoreLook, defaultLook]);
// UNDO walks back to the look the session loaded with; REDO walks forward
// again over exactly what it stepped past, up to the newest edit.
const undoLast = useCallback(() => stepBack(), [stepBack]);
const redoLast = useCallback(() => {
const next = redo[redo.length - 1];
const cur = lookRef.current;
if (!next || !cur) return;
restoreLook(next);
setRedo((stack) => stack.slice(0, -1));
setUndo((stack) => [...stack, cur]);
}, [redo, restoreLook]);
// SAVE RECENT — its own tab, not a corner of CREATE RECIPES. The look standing
// on the stage (sim, WB, light, FX and the frame) is filed under a name this
// account has not used; the frame rides in the recipe's own JSON, so applying
// the entry puts the whole look back, frame included.
const saveRecipeNamed = useCallback(
async (name: string) => {
try {
const look: Recipe & { frameId?: FrameId } = { ...recipe, name, frameId: cropEditing ? 'none' : frameId };
const { recipe: stored } = await api.createRecipe(name, look);
setSaved((list) => [stored, ...list]);
setRecipe((prev) => ({ ...prev, name }));
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[recipe, frameId, cropEditing, t]
);
const saveRecipe = useCallback(() => {
if (!user) {
setAuthMode('login');
return;
}
setNaming('recipe');
}, [user]);
// A recipe name is only free while this account has not spent it. Checked on
// the field rather than at the store: the list is already here, whole.
const recipeNameTaken = useCallback(
(name: string): string | null =>
saved.some((entry) => entry.name.trim().toLowerCase() === name.toLowerCase()) ? t('name.duplicate') : null,
[saved, t]
);
const deleteRecipeEntry = useCallback(
async (entry: SavedRecipe) => {
try {
await api.deleteRecipe(entry.id);
setSaved((list) => list.filter((r) => r.id !== entry.id));
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[t]
);
// FAVORITED holds the account's own recipes. `*`, or dragging a recipe chip
// onto the star in the rail, files the look on the stage there; a guest is
// asked to sign in first, like every other write in the studio.
const favoriteRecipe = useCallback(
async (entry: Recipe) => {
if (!user) {
setAuthMode('login');
return;
}
try {
const stored = await api.createRecipe(entry.name, { ...entry });
setSaved((list) => [stored.recipe, ...list]);
setTab('favorited');
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[user, t]
);
// The star reads the id the dragged chip carried back to its recipe.
const favoriteById = useCallback(
(id: string) => {
const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id);
if (entry) void favoriteRecipe(entry);
},
[created, favoriteRecipe]
);
// `*` files the look on the stage without leaving the tab it was built in.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return;
e.preventDefault();
void favoriteRecipe(recipe);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [favoriteRecipe, recipe]);
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
// signed-in account also stores it; a guest's copy stays in `created`, which
// is exactly what "forgotten when you leave" means.
const addCreatedRecipe = useCallback(
async (draft: RecipeDraft) => {
const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true };
setCreated((list) => [entry, ...list]);
applyRecipe(entry);
if (!user) return;
try {
const stored = await api.createRecipe(entry.name, entry);
setSaved((list) => [stored.recipe, ...list]);
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[user, applyRecipe, t]
);
// CREATE edits the photo live: each knob lands on the render as it is typed so
// the recipe can be judged before saving. No undo entry — this is a draft, and
// SAVE/EXPORT is what commits it.
const previewCreatedRecipe = useCallback((draft: Pick<Recipe, 'baseFilter' | 'adjustments'>) => {
setRecipe((prev) => ({ ...prev, ...draft, isCustom: true }));
}, []);
// The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the
// app's own file, byte-identical). Signed in only — a guest's recipe is never
// written to disk.
const exportRecipeFile = useCallback(
(draft: RecipeDraft) => {
if (!user) return;
const xml = exportRecipeXml({ ...draft, id: 'share' });
download(
new TextEncoder().encode(xml),
`${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`,
'application/xml'
);
},
[user]
);
// IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved
// from another account. `shared/utils/recipeShare` is the app's own parser, so
// the look arrives intact — a guest gets it for the session, like CREATE.
const importRecipeFile = useCallback(
async (file: File) => {
setError(null);
try {
const parsed = importRecipeXml(await file.text());
const entry: Recipe = { ...parsed, id: `import-${Date.now()}`, isCustom: true };
setCreated((list) => [entry, ...list]);
applyRecipe(entry);
setFrameId(parsed.frameId ?? 'none');
setUseGeotag(!!parsed.useGeotag);
setShowRecipes(true);
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[applyRecipe, t]
);
// The picker is built on the spot: a second <input type=file> in the page
// would compete with the stage's own for `input[type=file]`.
const pickRecipeFile = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.recipe,application/xml,text/xml';
input.onchange = () => {
const file = input.files?.[0];
if (file) void importRecipeFile(file);
};
input.click();
}, [importRecipeFile]);
// The strip this look shows — the tagline off the recipe name, and the
// technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own
// grain, warmth and monochrome state. 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();
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]);
// 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);
setError(null);
try {
const tier = TIERS[user ? 'user' : 'guest'];
const src = tier.maxExportDim ? await resizedJpeg(source.bytes, tier.maxExportDim, 0.95) : source.bytes;
const out = await renderPhoto({
source: src,
recipe,
// The file is the preview: an uncommitted frame is not exported.
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 }
: tier.watermark
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
: null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
},
});
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; the
// recipe is stamped as metadata, never burned into the pixels.
const bytes = writeJpegExif(
out.bytes,
{
dateTime: new Date(),
gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null,
width: out.width,
height: out.height,
dpi: 300,
locationName: place || gps?.locality || null,
imageDescription: strip.tag,
userComment: JSON.stringify(photoSettings),
},
source.bytes
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
} finally {
setExporting(false);
}
}, [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
// 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 (photoName?: string) => {
if (!user) {
setAuthMode('login');
return;
}
if (!engine || !source) {
setError(t('save.photoNoSource'));
return;
}
// Re-saving the frame already on the stage replaces its own row, so the cap
// only stands in the way of a new one.
const open = myPhotos.find((p) => p.id === openPhotoId);
if (!open && myPhotos.length >= MAX_PHOTOS) {
setError(t('save.photoFull', { max: MAX_PHOTOS }));
return;
}
// A frame being filed for the first time is asked for a name — the folder
// keeps it, and the landing reel draws it as the frame's title. A re-save
// keeps the name it already has, so it never asks twice.
if (!open && photoName === undefined) {
setNaming('photo');
return;
}
// A re-save keeps the name the frame already has — the folder's title, not
// the recipe it happens to be standing in.
const labels = { ...strip, title: open ? open.title ?? strip.title : (photoName ?? strip.title) };
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',
});
if (open) {
// The frame on the stage is one of the folder's own: it keeps its id and
// its place, and the look it carried before this save lands in history.
// Its consent is the one it already had — a re-save is not a re-answer.
const { photo } = await api.replacePhoto(open.id, file, labels, {
recipe: photoSettings,
consent: open.consent !== false,
});
setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p)));
} else {
const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings });
// 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);
}
// The stage now stands on the newest look of this frame, so no earlier
// one in its history is the picked one.
setHistPick(null);
} catch (err) {
setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setSavingPhoto(false);
}
}, [user, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, 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);
// The stage is emptied before the fetch, not after it: the frame being
// reopened must not land on top of the one already standing there.
clearPreview();
setHistPick(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, clearPreview, t]
);
// The saved frame's own switch. The reel reads `consent`, so the tick answers
// the click at once; only the store's refusal (a failed call) takes it back.
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
if (!photo) return;
const next = photo.consent === false;
const mark = (value: boolean) =>
setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: value } : p)));
mark(next);
try {
await api.setPhotoConsent(photo.id, next);
} catch {
mark(!next);
}
}, []);
// Removing a frame takes its row and the looks its history kept. Confirmed
// first (ui/DeletePhotoModal), never on a single click.
const removeMyPhoto = useCallback(
async (photo: Photo) => {
try {
await api.deletePhoto(photo.id);
setMyPhotos((prev) => prev.filter((p) => p.id !== photo.id));
if (openPhotoId === photo.id) setOpenPhotoId(null);
} catch (err) {
setError(t('err.generic', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setRemoving(null);
}
},
[openPhotoId, t]
);
// --- strips ---------------------------------------------------------------
// The cascade: a chip opens the next column, and the column it came from stays
// put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The
// recipes list is the one exception — it belongs to PRESETS, not to a chip, so
// anything else opening closes it.
const toggleParam = useCallback(
(key: string) => {
const close = openParam === key;
setOpenParam(close ? null : key);
setShowRecipes(false);
// Opening a ruler clears a strip from another branch; closing one leaves
// its own strip standing, so the column the ruler came from is still there.
if (!close) setOpenGroup(PARAM_GROUP[key] ?? null);
},
[openParam]
);
const toggleGroup = useCallback(
(g: GroupKey) => {
const next = openGroup === g ? null : g;
if (next) {
// A ruler only survives if it belongs to the strip being opened.
if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null);
setShowRecipes(false);
}
setOpenGroup(next);
},
[openGroup, openParam]
);
useEffect(() => {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
}, [tab]);
// Continuous parameter chips: label + live value; amber text when edited,
// amber border while its ruler is open.
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
defs.map((def) => {
const value = def.get(recipe.adjustments);
return {
key: def.key,
label: def.label,
value: def.display(value),
active: openParam === def.key,
amberValue: openParam !== def.key && value !== def.defaultValue,
// The web build is the demo: HDF EFFECT is part of what it shows off,
// so no knob is held back here. The PRO gate stays a phone-build rule.
onClick: () => toggleParam(def.key),
};
});
const slider: SliderDef | null = useMemo(() => {
if (!openParam) return null;
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
const def = all.find((d) => d.key === openParam);
if (!def) return null;
const value = def.get(recipe.adjustments);
return {
key: def.key,
label: def.label,
value,
min: def.min,
max: def.max,
step: def.step,
display: def.display(value),
edited: value !== def.defaultValue,
swatch: SLIDER_SWATCH[def.key]?.(value),
onChange: (value) => setAdjustmentOnce(def.set(value)),
onReset: () => setAdjustmentOnce(def.set(def.defaultValue)),
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
onBack: () => {
const g = PARAM_GROUP[def.key];
setOpenParam(null);
if (g) setOpenGroup(g);
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openParam, recipe.adjustments, setAdjustmentOnce]);
// Option strips: a chip opens one, each option picks a value. `off` is the
// neutral value the chip glows amber against.
const groupDefs: Record<Exclude<GroupKey, 'wm'>, {
label: string;
off: string;
value: string;
options: { v: string; d: string }[];
onPick: (v: string) => void;
}> = {
cx: {
label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none',
options: CHROME_OPTIONS,
onPick: (v) => {
remember();
setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] });
},
},
cxb: {
label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none',
options: CHROME_OPTIONS,
onPick: (v) => {
remember();
setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] });
},
},
dr: {
label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'),
options: DR_OPTIONS,
onPick: (v) => {
remember();
setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] });
},
},
// Picking a ratio frames it on the photo (drag the corners, then APPLY) —
// nothing is cut until APPLY.
crop: {
label: 'CROP', off: 'none', value: crop,
options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })),
onPick: (v) => pickCrop(v as CropRatio),
},
// ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only.
rotate: {
label: 'ROTATE', off: '0', value: String(rotation),
options: [
{ v: 'reset', d: 'RESET' },
{ v: '0', d: '0°' },
{ v: '90', d: '90°' },
{ v: '180', d: '180°' },
{ v: '270', d: '270°' },
],
onPick: (v) => {
remember();
setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270));
},
},
style: {
label: 'PHOTO STYLE', off: 'sim-provia',
value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '',
options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })),
onPick: (v) => {
const sim = FILM_SIMS.find((s) => s.id === v);
if (!sim) return;
setShowRecipes(false);
applyRecipe(filmSimToRecipe(sim));
},
},
// TEMP: the presets plus COLOR TEMP, which opens the ruler above instead
// of picking a value.
temp: {
label: 'TEMP', off: 'auto', value: String(recipe.adjustments.temperature),
options: [
{ v: 'color-temp', d: 'COLOR TEMP' },
...TEMP_PRESETS.map((p) => ({ v: String(p.kelvin), d: p.label })),
],
onPick: (v) => {
if (v !== 'color-temp') remember();
return v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) });
},
},
wmColor: {
label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color,
options: WM_COLORS,
onPick: (v) => {
remember();
setMarkStyle((prev) => ({ ...prev, color: v }));
},
},
wmSize: {
label: 'TEXT SIZE', off: '1', value: String(markStyle.size),
options: WM_SIZES,
onPick: (v) => {
remember();
setMarkStyle((prev) => ({ ...prev, size: Number(v) }));
},
},
gpsColor: {
label: 'COLOR', off: '#f59e0b', value: gpsStyle.color,
options: WM_COLORS,
onPick: (v) => {
remember();
setGpsStyle((prev) => ({ ...prev, color: v }));
},
},
gpsName: {
label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF',
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
onPick: (v) => {
remember();
setGpsStyle((prev) => ({ ...prev, showName: v === 'on' }));
},
},
gpsTime: {
label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF',
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
onPick: (v) => {
remember();
setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' }));
},
},
};
// A strip chip names its pick on the right edge, spelled the way the strip's
// own option spells it. Still on its neutral value -> nothing to name.
const groupChip = (g: Exclude<GroupKey, 'wm'>): ChipDef => {
const { label, value, off, options } = groupDefs[g];
const picked = options.find((o) => o.v === value);
return {
key: `grp-${g}`,
label,
value: value !== off ? picked?.d ?? value : undefined,
active: openGroup === g,
amberValue: openGroup !== g && value !== off,
onClick: () => toggleGroup(g),
};
};
const choiceChips = (
prefix: string,
options: { v: string; d: string }[],
current: string,
onPick: (v: string) => void
): ChipDef[] =>
options.map((o) => ({
key: `${prefix}:${o.v}`,
label: o.d,
active: current === o.v,
onClick: () => onPick(o.v),
}));
const wmOn = useGeotag || markOn;
const wmChips = (): ChipDef[] => [
{
key: 'gps',
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
active: useGeotag,
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
onClick: () => {
remember();
setUseGeotag(!useGeotag);
},
},
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []),
{
key: 'custom-wm',
label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
active: markOn,
onClick: () => {
remember();
setMarkOn(!markOn);
},
},
...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []),
];
// Every tab carries RESET, pinned outside the scrolling chips.
// RESET lights up only while the look differs from what reset() puts back —
// every clause has a matching setter there, or the star never goes out.
const resetDirty = useMemo(() => {
const a = recipe.adjustments;
const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[])
.some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]);
return tweaked
|| (simId ?? 'sim-provia') !== 'sim-provia'
|| frameId !== 'none' || crop !== 'none' || rotation !== 0
|| markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1
|| useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime;
}, [recipe, simId, frameId, crop, rotation, markOn, markText, markStyle, useGeotag, gpsStyle]);
const resetChip = (): ChipDef => ({
key: 'reset-all',
label: resetDirty ? 'RESET *' : 'RESET',
amberValue: resetDirty,
onClick: () => {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
// Under CREATE the button belongs to the form above it: it clears the
// draft, which live-previews its defaults straight onto the photo.
if (tab === 'create') {
setCreateReset((n) => n + 1);
return;
}
reset();
},
});
const framesRow = (): ChipDef[] => [
...FRAMES.map((frame) => ({
key: `fr:${frame.id}`,
label: t(frame.label),
active: frameId === frame.id,
onClick: () => {
setOpenParam(null);
setOpenGroup(null);
remember();
setFrameId(frame.id);
},
})),
groupChip('crop'),
{
key: 'grp-rotate',
label: 'ROTATE',
value: `${rotation}°`,
active: openGroup === 'rotate',
amberValue: openGroup !== 'rotate' && rotation !== 0,
onClick: () => toggleGroup('rotate'),
},
{
key: 'grp-wm',
label: wmOn ? 'WATERMARK ON' : 'WATERMARK',
active: openGroup === 'wm',
amberValue: openGroup !== 'wm' && wmOn,
onClick: () => toggleGroup('wm'),
},
];
// The folder's frame standing on the stage, if any: its own column shows the
// looks it carried before its last saves and its landing-strip consent.
const openPhoto = myPhotos.find((p) => p.id === openPhotoId);
// FX's MONOCHROME switch is not a look of its own: it swaps the base filter
// for the mono stock and swaps right back. Adjustments are never touched, so
// a knob moved while the switch is on survives the trip back.
const monoOn = recipe.baseFilter === 'monochrome';
const toggleMono = () => {
remember();
if (monoOn) {
const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIPES' };
setRecipe((prev) => ({ ...prev, baseFilter: back.baseFilter, name: back.name, isCustom: true }));
setSimId(back.simId);
} else {
monoPrev.current = { baseFilter: recipe.baseFilter, simId, name: recipe.name };
setRecipe((prev) => ({ ...prev, baseFilter: 'monochrome', name: 'MONOCHROME', isCustom: true }));
setSimId(null);
}
};
// The main chip row of the active tab, exactly the phone's per-tab sets.
const chips: ChipDef[] = (() => {
switch (tab) {
case 'presets':
return [
groupChip('style'),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onClick: () => {
setOpenGroup(null);
setOpenParam(null);
setShowRecipes((v) => !v);
},
},
// ponytail: no CREATE chip — making a recipe and filing the look on
// the stage are their own rail tabs (SAVE RECENT, CREATE RECIPES).
// IMPORT is a chip inside the RECIPES strip.
];
case 'favorited':
return saved.length === 0
? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }]
: saved.map((entry) => ({
key: String(entry.id),
label: entry.name,
// The look on the stage came from this entry: its name turns
// accent so the open recipe is visible in the list. A same-id
// preset that was merely filed under a new name must not light up,
// hence the name check too.
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. Opening one
// reopens it for a further edit; that frame's own history, consent and
// removal live in its own column (see the render). A save always joins
// the landing strip — the owner takes it off again with that switch, or
// the curator with the admin slot.
const rows: ChipDef[] = [];
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':
return [
{
// TEMP is the one strip chip that names its value — nothing else
// does.
...groupChip('temp'),
label: 'TEMP',
value: Math.round(recipe.adjustments.temperature) === 5500 ? 'AUTO' : `${recipe.adjustments.temperature}K`,
},
...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')),
groupChip('cx'),
groupChip('cxb'),
];
case 'fx':
return [
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
...paramChips(PARAM_DEFS.filters),
];
case 'frame':
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
// column (see the wm column in the render), so the frame chips stay put.
return framesRow();
// SAVE RECENT draws its own button and list, CREATE its own form; they
// are not chip rows like the rest.
case 'save':
case 'create':
return [];
default:
return [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
})();
const stripChips = (g: Exclude<GroupKey, 'wm'>): ChipDef[] => [
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick),
// The frame is on the photo but the crop is not in force until APPLY.
...(g === 'crop' && cropEditing
? [
{ key: 'crop-apply', label: t('crop.apply'), amberValue: true, onClick: applyCrop },
{ key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop },
]
: []),
];
// RECIPES = this account's own files (SAVE RECENT) + what this session made
// but never filed + the bundled presets. A CREATE that a signed-in account
// stored lands in `saved` too, so it is dropped from `created` by name here.
// IMPORT takes the .recipe file the phone (or another account) exports — same
// envelope, same parser, so a look crosses devices intact.
const recipeStrip: ChipDef[] = [
{ key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile },
...saved.map((entry) => ({
key: `saved-${entry.id}`,
label: entry.name,
active: recipe.name === entry.name,
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
})),
...created
.filter((entry) => !saved.some((s) => s.name.trim().toLowerCase() === entry.name.trim().toLowerCase()))
.map((entry) => ({
key: entry.id,
label: entry.name,
active: recipe.id === entry.id,
drag: entry.id,
onClick: () => applyRecipe(entry),
})),
...DEFAULT_RECIPES.map((preset) => ({
key: preset.id,
label: preset.name,
active: recipe.name === preset.name && !recipe.isCustom,
drag: preset.id,
onClick: () => applyRecipe({ ...preset }),
})),
];
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
// open chip's own panel, the open group's options, the open ruler. Each level
// is a column of its own, so a child never hides the column it came from.
const wmOpen = isWmPanel(openGroup);
const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null;
const status = busy;
return (
<div className="shell">
<TopBar
recipeName={recipe.name}
user={user}
exporting={exporting}
savingPhoto={savingPhoto}
canUndo={undo.length > 0}
canRedo={redo.length > 0}
onUndo={undoLast}
onRedo={redoLast}
onReset={reset}
onSavePhoto={() => void savePhotoToFolder()}
onExport={exportPhoto}
photoCount={myPhotos.length}
photoMax={MAX_PHOTOS}
onAuth={() => setAuthMode('login')}
onSignup={() => setAuthMode('signup')}
onLogout={async () => {
await api.logout().catch(() => undefined);
setUser(null);
}}
/>
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
) : tab === 'save' ? (
<>
{/* SAVE RECENT files the look standing on the stage — sim, WB,
light, FX and frame — as a recipe of this account's own. */}
<button
type="button"
className="btn primary"
data-key="save-recent"
onClick={() => void saveRecipe()}
>
{t('save.recent')}
</button>
<p className="hint">{t('save.recentHint')}</p>
{saved.length === 0 ? (
<p className="hint">{t('save.recentEmpty')}</p>
) : (
<div className="saved-list">
{saved.map((entry) => (
<div className="saved-row" key={entry.id}>
<button
type="button"
className="chip"
data-key={`saved-${entry.id}`}
aria-pressed={recipe.name === entry.name}
onClick={() => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name })}
>
{entry.name}
</button>
{/* Only this account can delete its own file — the API
scopes the delete by user, the button mirrors it. */}
<button
type="button"
className="chip"
data-key={`saved-del-${entry.id}`}
title={t('save.delete')}
aria-label={t('save.delete')}
onClick={() => void deleteRecipeEntry(entry)}
>
✕
</button>
</div>
))}
</div>
)}
</>
) : (
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* RESET is the way out of an edit, not one more chip: it sits under
a rule at the foot of the column so it reads as its own thing. */}
<div className="reset-row">
<ChipRow chips={[resetChip()]} />
</div>
</div>
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
<ChipRow
chips={[
{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) },
...wmChips(),
]}
/>
{markOn ? (
<input
className="field"
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
{useGeotag ? (
<>
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
{!gps ? (
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
onBlur={() => {
const lat = Number(gpsManual.lat);
const lng = Number(gpsManual.lng);
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' });
setUseGeotag(true);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
) : null}
</>
) : null}
</div>
) : null}
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
{optGroup ? (
<div className="col col-sub strip" data-col="options">
<ChipRow chips={stripChips(optGroup)} />
</div>
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : null}
{/* MY PHOTOS' own column, for the frame standing on the stage: the
looks it carried before its last saves (newest first, three kept)
and the switch that puts it on the landing film strip. */}
{tab === 'photos' && openPhoto ? (
<div className="col col-sub strip" data-col="history">
{openPhoto.history?.length ? (
<ChipRow
chips={[
{ key: 'hist-title', label: t('myp.history'), disabled: true, onClick: () => {} },
...openPhoto.history.map((look, i) => ({
key: `hist-${i}`,
label: look.name || `#${i + 1}`,
// The picked look is marked like the open recipe in the
// RECIPES list — accent, so it follows the theme.
active: histPick === i,
onClick: () => {
// An older look is loaded the same way a frame is: the
// preview goes first, then the look it names is painted.
clearPreview();
applyRecipe({ ...look });
setHistPick(i);
},
})),
]}
/>
) : (
<p className="hint">{t('myp.historyEmpty')}</p>
)}
<label className="consent">
<input
type="checkbox"
data-key="strip-open"
checked={openPhoto.consent !== false}
onChange={() => void togglePhotoConsent(openPhoto)}
/>
<span>{t('myp.consent')}</span>
</label>
{/* The frame's own removal. The row goes, and with it the looks
its history kept, so the dialog asks before it happens. */}
<button
type="button"
className="btn adm-danger"
data-key="photo-remove"
onClick={() => setRemoving(openPhoto)}
>
{t('myp.remove')}
</button>
</div>
) : null}
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
<SliderRow slider={slider} />
</div>
) : null}
</div>
<ImageStage
previewUrl={previewUrl}
originalUrl={source?.url ?? null}
busy={status}
error={error}
peek={peek}
onPeek={setPeek}
onFile={(file) => void loadFile(file)}
cropEditing={cropEditing}
cropRect={cropRect}
cropWH={cropWH}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={crop === 'none'}
/>
</div>
{naming === 'recipe' ? (
<NameModal
title={t('name.recipeTitle')}
label={t('name.recipeLabel')}
initial={recipe.name}
check={recipeNameTaken}
onSubmit={(name) => {
setNaming(null);
void saveRecipeNamed(name);
}}
onClose={() => setNaming(null)}
/>
) : null}
{naming === 'photo' ? (
<NameModal
title={t('name.photoTitle')}
label={t('name.photoLabel')}
initial={strip.title}
onSubmit={(name) => {
setNaming(null);
void savePhotoToFolder(name);
}}
onClose={() => setNaming(null)}
/>
) : null}
{removing ? (
<DeletePhotoModal
id={removing.id}
name={removing.title ?? `#${removing.id}`}
historyCount={removing.history?.length ?? 0}
onConfirm={() => void removeMyPhoto(removing)}
onClose={() => setRemoving(null)}
/>
) : null}
{authMode ? (
<AuthModal
initialMode={authMode}
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
const me = await api.me().catch(() => null);
if (me) setUser(me.user);
}}
/>
) : null}
</div>
);
}