dcea6b926f
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.
79 lines
2.2 KiB
TypeScript
79 lines
2.2 KiB
TypeScript
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>
|
|
);
|
|
}
|