diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 3796a5e..7e7ccb7 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColum import { ImageStage } from './ui/ImageStage'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; +import { NameModal } from './ui/NameModal'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; @@ -307,6 +308,9 @@ export function Workspace() { // 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([]); + // 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); // 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. @@ -496,6 +500,10 @@ export function Workspace() { 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] ); @@ -575,22 +583,51 @@ export function Workspace() { }); }, [restoreLook]); - const saveRecipe = useCallback(async () => { + // 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; } - const name = window.prompt(t('save.name'), recipe.name); - if (!name) return; - try { - const created = await api.createRecipe(name, { ...recipe, name }); - setSaved((list) => [created.recipe, ...list]); - setRecipe((prev) => ({ ...prev, name })); - } catch (err) { - setError(t('err.generic', { msg: String(err) })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recipe, user, t]); + 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 @@ -792,7 +829,7 @@ 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 () => { + const savePhotoToFolder = useCallback(async (photoName?: string) => { if (!user) { setAuthMode('login'); return; @@ -808,6 +845,14 @@ export function Workspace() { 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; + } + const labels = photoName ? { ...strip, title: photoName } : strip; setSavingPhoto(true); setError(null); try { @@ -839,13 +884,13 @@ export function Workspace() { // 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, strip, { + 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, strip, { recipe: photoSettings, consent: shareToStrip }); + const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings, consent: shareToStrip }); // The list entry carries the recipe we just sent, so reopening works // before the next /photos/mine refresh. setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); @@ -1237,9 +1282,9 @@ export function Workspace() { setShowRecipes((v) => !v); }, }, - // ponytail: no CREATE chip — the save button lives under the CREATE - // RECIPES tab (see the create column). IMPORT is a chip inside the - // RECIPES strip. + // 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 @@ -1302,7 +1347,9 @@ export function Workspace() { // 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(); - // CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row. + // 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: @@ -1323,18 +1370,28 @@ export function Workspace() { : []), ]; - // RECIPES = what this session made (newest first) + the bundled presets. + // 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 }, - ...created.map((entry) => ({ - key: entry.id, + ...saved.map((entry) => ({ + key: `saved-${entry.id}`, label: entry.name, - active: recipe.id === entry.id, - drag: entry.id, - onClick: () => applyRecipe(entry), + 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, @@ -1379,29 +1436,61 @@ export function Workspace() {
{/* column 1 — the tab's own chips, RESET ruled off at the foot */} -
+
{tab === 'create' ? ( + void addCreatedRecipe(draft)} + onExport={exportRecipeFile} + /> + ) : tab === 'save' ? ( <> - void addCreatedRecipe(draft)} - onExport={exportRecipeFile} - /> - {/* The studio's own save lives here now, under the CREATE tab: - it stores the recipe standing on the stage, where the panel - above stores the draft being built. */} + {/* SAVE RECENT files the look standing on the stage — sim, WB, + light, FX and frame — as a recipe of this account's own. */} +

{t('save.recentHint')}

+ {saved.length === 0 ? ( +

{t('save.recentEmpty')}

+ ) : ( +
+ {saved.map((entry) => ( +
+ + {/* Only this account can delete its own file — the API + scopes the delete by user, the button mirrors it. */} + +
+ ))} +
+ )} ) : ( @@ -1541,6 +1630,33 @@ export function Workspace() { />
+ {naming === 'recipe' ? ( + { + setNaming(null); + void saveRecipeNamed(name); + }} + onClose={() => setNaming(null)} + /> + ) : null} + + {naming === 'photo' ? ( + { + setNaming(null); + void savePhotoToFolder(name); + }} + onClose={() => setNaming(null)} + /> + ) : null} + {authMode ? ( string | null; + onSubmit: (name: string) => void; + onClose: () => void; +}) { + const { t } = useI18n(); + const [name, setName] = useState(initial); + const [problem, setProblem] = useState(null); + const field = useRef(null); + + useEffect(() => field.current?.select(), []); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) { + setProblem(t('name.required')); + return; + } + const clash = check?.(trimmed) ?? null; + if (clash) { + setProblem(clash); + return; + } + onSubmit(trimmed); + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+

{title}

+ + { + setName(e.target.value); + setProblem(null); + }} + /> + {problem ? ( +

+ {problem} +

+ ) : null} + + +
+
+ ); +} diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index 4afa367..f7cfaba 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -5,7 +5,7 @@ import { useI18n } from '../i18n/I18nProvider'; // eye corrects, and the web rail draws them in the order the panel opens. The // rail is a single column on desktop and becomes the bottom bar under 860px // (see styles/app.css). -export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'create'; +export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'save' | 'create'; export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'presets', label: 'tab.presets', glyph: '◉' }, @@ -15,6 +15,10 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'light', label: 'tab.light', glyph: '☀' }, { id: 'fx', label: 'tab.fx', glyph: '✦' }, { id: 'frame', label: 'tab.frame', glyph: '▣' }, + // Keeping the look on the stage and building one from scratch are two acts, + // so they get two tabs: SAVE RECENT files what is open, CREATE RECIPES starts + // a recipe from its parts. + { id: 'save', label: 'tab.save', glyph: '⤓' }, { id: 'create', label: 'tab.create', glyph: '✎' }, ];