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:
+157
-41
@@ -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<Recipe[]>([]);
|
||||
// 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() {
|
||||
|
||||
<div className="chips">
|
||||
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
||||
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
|
||||
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
resetToken={createReset}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||||
onExport={exportRecipeFile}
|
||||
/>
|
||||
) : tab === 'save' ? (
|
||||
<>
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
current={recipe.adjustments}
|
||||
signedIn={!!user}
|
||||
resetToken={createReset}
|
||||
onPreview={previewCreatedRecipe}
|
||||
onSave={(draft) => 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. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-key="save-recipes"
|
||||
className="btn primary"
|
||||
data-key="save-recent"
|
||||
onClick={() => void saveRecipe()}
|
||||
>
|
||||
{t('save.recipes')}
|
||||
{t('save.recent')}
|
||||
</button>
|
||||
<p className="hint">{t('save.recentHint')}</p>
|
||||
{saved.length === 0 ? (
|
||||
<p className="hint">{t('save.recentEmpty')}</p>
|
||||
) : (
|
||||
<div className="saved-list">
|
||||
{saved.map((entry) => (
|
||||
<div className="saved-row" key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
data-key={`saved-${entry.id}`}
|
||||
aria-pressed={recipe.name === entry.name}
|
||||
onClick={() => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name })}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
{/* Only this account can delete its own file — the API
|
||||
scopes the delete by user, the button mirrors it. */}
|
||||
<button
|
||||
type="button"
|
||||
className="chip"
|
||||
data-key={`saved-del-${entry.id}`}
|
||||
title={t('save.delete')}
|
||||
aria-label={t('save.delete')}
|
||||
onClick={() => void deleteRecipeEntry(entry)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ChipRow chips={chips} />
|
||||
@@ -1541,6 +1630,33 @@ export function Workspace() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{naming === 'recipe' ? (
|
||||
<NameModal
|
||||
title={t('name.recipeTitle')}
|
||||
label={t('name.recipeLabel')}
|
||||
initial={recipe.name}
|
||||
check={recipeNameTaken}
|
||||
onSubmit={(name) => {
|
||||
setNaming(null);
|
||||
void saveRecipeNamed(name);
|
||||
}}
|
||||
onClose={() => setNaming(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{naming === 'photo' ? (
|
||||
<NameModal
|
||||
title={t('name.photoTitle')}
|
||||
label={t('name.photoLabel')}
|
||||
initial={strip.title}
|
||||
onSubmit={(name) => {
|
||||
setNaming(null);
|
||||
void savePhotoToFolder(name);
|
||||
}}
|
||||
onClose={() => setNaming(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{authMode ? (
|
||||
<AuthModal
|
||||
initialMode={authMode}
|
||||
|
||||
@@ -38,6 +38,7 @@ export const en: Dict = {
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
'tab.save': 'SAVE RECENT',
|
||||
'tab.create': 'CREATE RECIPES',
|
||||
|
||||
'create.title': 'CREATE RECIPES',
|
||||
@@ -108,7 +109,18 @@ export const en: Dict = {
|
||||
|
||||
'save.title': 'Save recipe',
|
||||
'save.name': 'Recipe name',
|
||||
'save.recipes': 'SAVE RECIPES',
|
||||
'save.recent': 'SAVE RECENT TO RECIPES',
|
||||
'save.recentHint': 'Files the look on the stage — sim, WB, light, FX and frame — under a name, in this account alone.',
|
||||
'save.recentEmpty': 'No recipes of your own yet.',
|
||||
'save.delete': 'Delete this recipe',
|
||||
'name.photoTitle': 'NAME THIS PHOTO',
|
||||
'name.photoLabel': 'What the folder keeps it as, and the title on its landing frame.',
|
||||
'name.recipeTitle': 'SAVE TO RECIPES',
|
||||
'name.recipeLabel': 'A name for this look, in this account alone.',
|
||||
'name.required': 'Give it a name first.',
|
||||
'name.duplicate': 'You already have a recipe with that name.',
|
||||
'name.save': 'SAVE',
|
||||
'name.cancel': 'CANCEL',
|
||||
'save.saved': 'Saved "{name}"',
|
||||
'save.needLogin': 'Log in to save a recipe.',
|
||||
'save.photo': 'SAVE PHOTO {n}/{max}',
|
||||
|
||||
@@ -46,6 +46,7 @@ export const vi = {
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
'tab.save': 'LƯU GẦN ĐÂY',
|
||||
'tab.create': 'CREATE RECIPES',
|
||||
|
||||
'create.title': 'TẠO CÔNG THỨC',
|
||||
@@ -116,7 +117,18 @@ export const vi = {
|
||||
|
||||
'save.title': 'Lưu công thức',
|
||||
'save.name': 'Tên công thức',
|
||||
'save.recipes': 'LƯU CÔNG THỨC',
|
||||
'save.recent': 'LƯU THAY ĐỔI VÀO RECIPES',
|
||||
'save.recentHint': 'Lưu look đang đứng trên màn hình — sim, WB, light, FX và frame — thành một recipe riêng của tài khoản này.',
|
||||
'save.recentEmpty': 'Chưa có recipe nào của riêng bạn.',
|
||||
'save.delete': 'Xoá recipe này',
|
||||
'name.photoTitle': 'ĐẶT TÊN ẢNH',
|
||||
'name.photoLabel': 'Tên lưu trong thư mục ảnh, và là tiêu đề trên khung ảnh ở landing page.',
|
||||
'name.recipeTitle': 'LƯU VÀO RECIPES',
|
||||
'name.recipeLabel': 'Tên cho look này, chỉ trong tài khoản của bạn.',
|
||||
'name.required': 'Đặt tên trước đã.',
|
||||
'name.duplicate': 'Bạn đã có một recipe mang tên này.',
|
||||
'name.save': 'LƯU',
|
||||
'name.cancel': 'HUỶ',
|
||||
'save.saved': 'Đã lưu "{name}"',
|
||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
||||
'save.photo': 'LƯU ẢNH {n}/{max}',
|
||||
|
||||
@@ -126,6 +126,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.consent input { flex: 0 0 auto; margin: 1px 0 0; accent-color: var(--accent); }
|
||||
/* SAVE RECENT's own files: the name fills the row, the ✕ that deletes it sits
|
||||
beside it — the account's recipe list is the one place a file can be thrown
|
||||
away from the studio. */
|
||||
.saved-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.saved-row { display: flex; gap: 6px; align-items: stretch; }
|
||||
.saved-row .chip:first-child { flex: 1; justify-content: space-between; text-align: left; }
|
||||
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||
/* Edited value, strip closed: the text glows amber, the border does not. */
|
||||
.chip.amber, .chip.amber .val { color: var(--accent); }
|
||||
|
||||
@@ -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,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: '✎' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user