web: name a photo on first save, and file looks from a SAVE RECENT tab

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.
This commit is contained in:
2026-09-18 14:57:49 +07:00
parent 72d5ce1c3e
commit dcea6b926f
6 changed files with 272 additions and 44 deletions
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
// One field, one rule, shared by the two save flows that have to ask for a name:
// the photo being filed and the look being filed as a recipe. `check` is where a
// caller says a name is already taken — the reply is shown under the field and
// nothing is saved until it comes back clean.
export function NameModal({
title,
label,
initial,
check,
onSubmit,
onClose,
}: {
title: string;
label: string;
initial: string;
check?: (name: string) => string | null;
onSubmit: (name: string) => void;
onClose: () => void;
}) {
const { t } = useI18n();
const [name, setName] = useState(initial);
const [problem, setProblem] = useState<string | null>(null);
const field = useRef<HTMLInputElement>(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 (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" data-key="name-modal" onSubmit={submit}>
<h2>{title}</h2>
<label className="hint" htmlFor="name-modal-field">
{label}
</label>
<input
id="name-modal-field"
ref={field}
className="field"
data-key="name-input"
value={name}
maxLength={60}
onChange={(e) => {
setName(e.target.value);
setProblem(null);
}}
/>
{problem ? (
<p className="err" data-key="name-error">
{problem}
</p>
) : null}
<button type="submit" className="btn primary" data-key="name-ok">
{t('name.save')}
</button>
<button type="button" className="btn ghost" data-key="name-cancel" onClick={onClose}>
{t('name.cancel')}
</button>
</form>
</div>
);
}
+5 -1
View File
@@ -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: '✎' },
];