diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 7e7ccb7..2638d9f 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -5,6 +5,7 @@ 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'; @@ -244,16 +245,32 @@ export function Workspace() { ) ); - // --- undo ----------------------------------------------------------------- + // --- 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([]); + const [redo, setRedo] = useState([]); + // 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(null); const lookRef = useRef(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) setUndo((stack) => [...stack.slice(-19), snap]); + 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) => { @@ -300,9 +317,6 @@ export function Workspace() { // a saved frame with the settings it was stored with; the count labels the // save button. The API is the real cap — this only labels the button. const [myPhotos, setMyPhotos] = useState([]); - // Consent for the NEXT save: does it join the landing film strip? Sticky, so - // the answer survives several saves. A guest's frames never reach the reel. - const [shareToStrip, setShareToStrip] = useState(true); // The saved photo standing on the stage, if any — its own consent chip shows. const [openPhotoId, setOpenPhotoId] = useState(null); // Recipes made in this session, guest ones included. A guest's copy lives @@ -311,6 +325,9 @@ export function Workspace() { // 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(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. @@ -427,6 +444,16 @@ export function Workspace() { // 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); @@ -455,8 +482,10 @@ export function Workspace() { 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. + // 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: '' }); @@ -555,15 +584,20 @@ export function Workspace() { 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(() => { - setUndo((stack) => { - const last = stack[stack.length - 1]; - if (last) restoreLook(last); - return stack.slice(0, -1); - }); - }, [restoreLook]); + const cancelCrop = useCallback(() => stepBack(), [stepBack]); const reset = useCallback(() => { remember(); @@ -575,13 +609,18 @@ export function Workspace() { setShowRecipes(false); }, [remember, restoreLook, defaultLook]); - const undoLast = useCallback(() => { - setUndo((stack) => { - const last = stack[stack.length - 1]; - if (last) restoreLook(last); - return stack.slice(0, -1); - }); - }, [restoreLook]); + // 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 @@ -852,7 +891,9 @@ export function Workspace() { setNaming('photo'); return; } - const labels = photoName ? { ...strip, title: photoName } : strip; + // 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 { @@ -890,18 +931,21 @@ export function Workspace() { }); setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p))); } else { - const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings, consent: shareToStrip }); + 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, shareToStrip, t]); + }, [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. @@ -909,6 +953,10 @@ export function Workspace() { 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}`); @@ -927,7 +975,7 @@ export function Workspace() { setBusy(null); } }, - [loadFile, applyRecipe, t] + [loadFile, applyRecipe, clearPreview, t] ); // The saved frame's own switch. The reel reads `consent`, so the tick answers @@ -945,6 +993,23 @@ export function Workspace() { } }, []); + // 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 @@ -1300,18 +1365,12 @@ export function Workspace() { onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); case 'photos': { - // MY PHOTOS: the account's own saved frames, newest first, plus the - // switch that decides how a NEW save lands. Opening one reopens it for a - // further edit; the open frame's own history and consent live in its own - // column (see the render). - const rows: ChipDef[] = [ - { - key: 'strip-next', - label: t(shareToStrip ? 'myp.newOn' : 'myp.newOff'), - active: shareToStrip, - onClick: () => setShareToStrip((v) => !v), - }, - ]; + // 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 { @@ -1417,7 +1476,9 @@ export function Workspace() { exporting={exporting} savingPhoto={savingPhoto} canUndo={undo.length > 0} + canRedo={redo.length > 0} onUndo={undoLast} + onRedo={redoLast} onReset={reset} onSavePhoto={() => void savePhotoToFolder()} onExport={exportPhoto} @@ -1586,7 +1647,16 @@ export function Workspace() { ...openPhoto.history.map((look, i) => ({ key: `hist-${i}`, label: look.name || `#${i + 1}`, - onClick: () => applyRecipe(look), + // 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); + }, })), ]} /> @@ -1602,6 +1672,16 @@ export function Workspace() { /> {t('myp.consent')} + {/* The frame's own removal. The row goes, and with it the looks + its history kept, so the dialog asks before it happens. */} + ) : null} @@ -1657,6 +1737,16 @@ export function Workspace() { /> ) : null} + {removing ? ( + void removeMyPhoto(removing)} + onClose={() => setRemoving(null)} + /> + ) : null} + {authMode ? ( void } setPos({ x: Math.max(8, b.width - box.getBoundingClientRect().width - 12), y: 12 }); }, [pos]); + // The photo's own box is what the frame is parked against, and it shrinks + // when a column opens beside the stage — not only when the window does. So + // the frame watches that box, not the window, and is pulled back inside it. useEffect(() => { const onResize = () => setPos((p) => (p ? clamp(p.x, p.y) : p)); window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); + const wrap = boxRef.current?.parentElement; + const ro = wrap ? new ResizeObserver(onResize) : null; + ro?.observe(wrap as Element); + return () => { + window.removeEventListener('resize', onResize); + ro?.disconnect(); + }; }, [clamp]); const start = (e: React.PointerEvent) => { diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index 674f0cb..94d444e 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -12,7 +12,9 @@ export function TopBar({ exporting, savingPhoto, canUndo, + canRedo, onUndo, + onRedo, onReset, onSavePhoto, onExport, @@ -27,7 +29,9 @@ export function TopBar({ exporting: boolean; savingPhoto: boolean; canUndo: boolean; + canRedo: boolean; onUndo: () => void; + onRedo: () => void; onReset: () => void; onSavePhoto: () => void; onExport: () => void; @@ -63,7 +67,30 @@ export function TopBar({ {recipeName} - + {/* Both arrows are one icon wide, so the pair reads as one control: the + counter-clockwise arrow steps back, the clockwise one forward. */} + +