4d0d44170f
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.
1787 lines
70 KiB
TypeScript
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>
|
|
);
|
|
}
|