diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index a6283a6..6ba9603 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -7,12 +7,13 @@ import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; import { NameModal } from './ui/NameModal'; import { DeletePhotoModal } from './ui/DeletePhotoModal'; +import { ClearPhotoModal } from './ui/ClearPhotoModal'; 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 { clearSession, loadPhoto, loadState, savePhoto, saveState } from './engine/session'; +import { clearSession, forgetPhoto, 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'; @@ -424,6 +425,9 @@ export function Workspace() { // 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); + // CLEAR's confirmation is up: the photo is still on the stage until one of its + // answers is taken. + const [clearing, setClearing] = useState(false); // 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. @@ -572,6 +576,30 @@ export function Workspace() { }); }, []); + // CLEAR: the photo leaves the stage, the look stays. The knobs, the sim, the + // frame and the stamps are the user's work and were made on this picture, not + // by it — so they survive it, exactly as they survive opening another photo. + // What goes is what the picture itself brought: its pixels, its shape, its + // EXIF position and ISO. The session's own copy goes too (forgetPhoto), or + // the next reload would put the frame straight back. + const clearPhoto = useCallback(() => { + setSource((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return null; + }); + clearPreview(); + setPreviewBytes(null); + setPhotoDims(null); + setOpenPhotoId(null); + setGps(null); + setGpsManual({ lat: '', lng: '' }); + setUseGeotag(false); + setIso(null); + setError(null); + setBusy(null); + void forgetPhoto(); + }, [clearPreview]); + // What the phone app gets from the OS geocoder, the browser has no twin of, // so the name behind a coordinate comes from the API. Fire and forget: the // stamp is already readable without it, and a name that arrives late (or not @@ -1085,11 +1113,13 @@ export function Workspace() { [frameId, useGeotag, recipe.adjustments.hdf] ); - const exportPhoto = useCallback(async () => { - if (!engine || !source) return; + // `true` when a file was handed over, so CLEAR knows the export it offered + // actually happened before the photo comes off the stage. + const exportPhoto = useCallback(async (): Promise => { + if (!engine || !source) return false; if (!pro && proLookInUse()) { promptPro(); - return; + return false; } setExporting(true); setError(null); @@ -1140,8 +1170,10 @@ export function Workspace() { ); const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`); + return true; } catch (err) { setError(t('err.generic', { msg: String(err) })); + return false; } finally { setExporting(false); } @@ -1151,28 +1183,30 @@ export function Workspace() { // 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) => { + // `true` once the row and its base are stored, so CLEAR only takes the photo + // off the stage when the save it offered really landed. + const savePhotoToFolder = useCallback(async (photoName?: string): Promise => { if (!pro) { promptPro(); - return; + return false; } if (!engine || !source) { setError(t('save.photoNoSource')); - return; + return false; } // 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 && photoCap !== null && myPhotos.length >= photoCap) { setError(t('save.photoFull', { max: photoCap })); - return; + return false; } // 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; + return false; } // A re-save keeps the name the frame already has — the folder's title, not // the recipe it happens to be standing in. @@ -1250,13 +1284,37 @@ export function Workspace() { // The stage now stands on the newest look of this frame, so no earlier // one in its history is the picked one. setHistPick(null); + return true; } catch (err) { setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) })); + return false; } finally { setSavingPhoto(false); } }, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); + // CLEAR's "keep it" answers: the save lands and the photo goes, or the export + // hands a file over and then it goes. Neither forces the hand — a save that + // bails (a guest without an account, the photo cap) leaves the frame standing, + // and so does an export that never produced a file. + const clearAfterRef = useRef(false); + const saveThenMaybeClear = useCallback( + async (photoName?: string) => { + const saved = await savePhotoToFolder(photoName); + // The name prompt can stand between the button and the save, so the + // intent is read where the save finishes rather than where it started. + if (saved && clearAfterRef.current) { + clearAfterRef.current = false; + clearPhoto(); + } + return saved; + }, + [savePhotoToFolder, clearPhoto] + ); + const exportThenClear = useCallback(async () => { + if (await exportPhoto()) clearPhoto(); + }, [exportPhoto, clearPhoto]); + // The original file, handed back byte for byte — the untouched frame the // camera took, never the render. A page cannot write to the camera roll, so // on a phone the file goes to the share sheet and "Save Image" on it is the @@ -2064,7 +2122,7 @@ export function Workspace() { onUndo={undoLast} onRedo={redoLast} onReset={reset} - onSavePhoto={() => void savePhotoToFolder()} + onSavePhoto={() => void saveThenMaybeClear()} onExport={exportPhoto} photoCount={myPhotos.length} photoMax={photoCap} @@ -2370,6 +2428,7 @@ export function Workspace() { onStageZoom={onStageZoom} canCompare={crop === 'none'} onSaveOriginal={() => void saveOriginal()} + onClear={() => setClearing(true)} picking={picking} onPickColor={pickColor} pickPanel={pickPanel} @@ -2442,12 +2501,33 @@ export function Workspace() { initial={strip.title} onSubmit={(name) => { setNaming(null); - void savePhotoToFolder(name); + void saveThenMaybeClear(name); }} onClose={() => setNaming(null)} /> ) : null} + {clearing ? ( + { + setClearing(false); + // The save may still have to ask for a name; the flag makes that + // save the one that takes the photo off the stage. + clearAfterRef.current = true; + void saveThenMaybeClear(); + }} + onExport={() => { + setClearing(false); + void exportThenClear(); + }} + onDiscard={() => { + setClearing(false); + clearPhoto(); + }} + onClose={() => setClearing(false)} + /> + ) : null} + {removing ? ( { + try { + await idb('readwrite', (s) => s.delete(PHOTO_KEY)); + } catch { + // Private mode / no IndexedDB: the photo just will not survive a reload. + } +} + // Signing out ends the session: drop the knobs and the working photo, so the // next visit — guest or another account — opens the studio empty. The account's // own photos stay in MY PHOTOS, ready to reopen. diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index b3e1464..005b4ad 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -22,7 +22,8 @@ export const en: Dict = { 'drop.title': 'Drag & drop a photo here', 'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V', - 'drop.replace': 'Choose another photo', + 'drop.replace': 'Open photo', + 'drop.clear': 'Clear', 'drop.peeking': 'SHOWING THE ORIGINAL', 'drop.peekHint': 'Hold right-click or the \\ key to compare with the original', 'drop.compare': 'Compare with original picture', @@ -164,6 +165,15 @@ export const en: Dict = { 'del.confirm': 'DELETE PHOTO', 'del.cancel': 'CANCEL', + // CLEAR's confirmation: keep the work one of two ways, or let it go. + 'clear.title': 'Clear this photo?', + 'clear.hint': + 'The photo leaves the workspace. Save it to MY PHOTOS to carry on editing later, export it as an image file, or clear it without keeping anything.', + 'clear.save': 'SAVE PHOTO', + 'clear.export': 'EXPORT IMAGE', + 'clear.discard': 'CLEAR WITHOUT SAVING', + 'clear.cancel': 'CANCEL', + 'photo.uploading': 'UPLOADING…', 'photos.title': 'My photos', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 01c9b40..34abf8f 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -29,7 +29,8 @@ export const vi = { 'drop.title': 'Kéo & thả ảnh vào đây', 'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V', - 'drop.replace': 'Đổi ảnh khác', + 'drop.replace': 'Mở ảnh', + 'drop.clear': 'Xoá ảnh', 'drop.peeking': 'ĐANG XEM ẢNH GỐC', 'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc', 'drop.compare': 'So với ảnh gốc', @@ -172,6 +173,15 @@ export const vi = { 'del.confirm': 'XOÁ ẢNH', 'del.cancel': 'HUỶ', + // Xác nhận của CLEAR: giữ lại thành quả theo một trong hai cách, hoặc bỏ hẳn. + 'clear.title': 'Xoá ảnh đang mở?', + 'clear.hint': + 'Ảnh sẽ rời khỏi màn hình làm việc. Lưu vào ẢNH CỦA TÔI để lần sau sửa tiếp, xuất ra tệp ảnh, hoặc xoá mà không giữ lại gì.', + 'clear.save': 'LƯU ẢNH', + 'clear.export': 'XUẤT ẢNH', + 'clear.discard': 'XOÁ KHÔNG LƯU', + 'clear.cancel': 'HUỶ', + 'photo.uploading': 'ĐANG TẢI…', 'photos.title': 'Ảnh của tôi', diff --git a/docker/frontend/src/ui/ClearPhotoModal.tsx b/docker/frontend/src/ui/ClearPhotoModal.tsx new file mode 100644 index 0000000..987b9ef --- /dev/null +++ b/docker/frontend/src/ui/ClearPhotoModal.tsx @@ -0,0 +1,44 @@ +import { useI18n } from '../i18n/I18nProvider'; + +// CLEAR takes the photo off the stage, and the work standing on it goes with +// it: the look lives in the session, not in the picture. So the button asks +// first, and the two ways of keeping something are one tap each — file the +// frame in MY PHOTOS so it can be picked up again, or put a JPEG in the user's +// hands. Neither is forced: CLEAR WITHOUT SAVING is a plain discard, and +// closing the dialog does nothing at all. +export function ClearPhotoModal({ + onSave, + onExport, + onDiscard, + onClose, +}: { + onSave: () => void; + onExport: () => void; + onDiscard: () => void; + onClose: () => void; +}) { + const { t } = useI18n(); + + return ( +
e.target === e.currentTarget && onClose()}> +
+

{t('clear.title')}

+

+ {t('clear.hint')} +

+ + + + +
+
+ ); +} diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx index 2be619d..6948930 100644 --- a/docker/frontend/src/ui/Histogram.tsx +++ b/docker/frontend/src/ui/Histogram.tsx @@ -68,7 +68,7 @@ function curve(bins: number[], max: number, close: boolean): string { export function Histogram({ url, onClose }: { url: string; onClose: () => void }) { const { t } = useI18n(); const [data, setData] = useState(null); - const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + const [pos, setPos] = useState<{ x: number; y: number }>({ x: 12, y: 12 }); const [dragging, setDragging] = useState(false); const boxRef = useRef(null); const dragRef = useRef<{ px: number; py: number; x: number; y: number } | null>(null); @@ -98,16 +98,6 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void } return { x: Math.min(Math.max(8, x), hi(b.width, m.width)), y: Math.min(Math.max(8, y), hi(b.height, m.height)) }; }, []); - // First paint: park it in the top-right corner of the photo. - useEffect(() => { - if (pos) return; - const wrap = boxRef.current?.parentElement; - const box = boxRef.current; - if (!wrap || !box) return; - const b = wrap.getBoundingClientRect(); - 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. @@ -151,12 +141,15 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void } // that starts to hide the shadows. const max = Math.max(1, ...(data ? [...data.lum, ...data.r, ...data.g, ...data.b] : [1])); + // The shape is parked in the photo's top-left corner as soon as it appears — + // that is where the eye already is when a frame lands — and the user drags it + // wherever they want it from there. return (
void; + onClear?: () => void; // Eyedropper mode: clicks on the photo report the colour underneath instead of // panning it. The HSL mixer's PICK chip owns this flag. picking?: boolean; @@ -831,7 +835,12 @@ export function ImageStage({ {previewUrl ? (
- + ) : null} + {onSaveOriginal ? (