From dcea6b926f3b34f5700c924a7ce539e3aa9c4d57 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 14:57:49 +0700 Subject: [PATCH] web: name a photo on first save, and file looks from a SAVE RECENT tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two saves that never had a name of their own now ask for one, through a single modal (ui/NameModal, shared by both flows). The first filing of an upload asks what the folder keeps it as, and that name rides along as the frame's title. A re-save keeps the name it already has, so it never asks twice. SAVE RECENT leaves CREATE RECIPES and becomes its own rail tab: it files the look standing on the stage — sim, WB, light, FX and the frame — as a recipe of this account's own, refusing a name the account has already spent. The frame travels in the recipe's JSON, so applying the entry puts the whole look back. The tab lists those files and is the one place they can be deleted from; the API already scopes both by user. They also show up in PRESETS/RECIPES, deduped against anything CREATE filed under the same name in this session. --- docker/frontend/src/App.tsx | 198 +++++++++++++++++++++------ docker/frontend/src/i18n/en.ts | 14 +- docker/frontend/src/i18n/vi.ts | 14 +- docker/frontend/src/styles/app.css | 6 + docker/frontend/src/ui/NameModal.tsx | 78 +++++++++++ docker/frontend/src/ui/ToolRail.tsx | 6 +- 6 files changed, 272 insertions(+), 44 deletions(-) create mode 100644 docker/frontend/src/ui/NameModal.tsx 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: '✎' }, ];