web: accent the brand and add the studio's quick gestures

- the brand "Cam", the avatar and the signed-in name follow the theme accent
- double-click a slider track resets that parameter; double-click the photo
  toggles 1:1 and the whole photo on the stage
- `*`, or a recipe chip dragged onto the star, files the look under FAVORITED
- RESET under CREATE clears the draft form, and the button now sits under a
  rule at the foot of the column
This commit is contained in:
2026-09-18 12:09:06 +07:00
parent 0f52572f20
commit 567f650bbb
6 changed files with 144 additions and 9 deletions
+62 -3
View File
@@ -292,6 +292,9 @@ export function Workspace() {
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// Bumped by RESET while CREATE is open: the form above the button listens for
// it and drops back to its defaults.
const [createReset, setCreateReset] = useState(0);
// This account's own folder. Only the count matters here; /photos owns the
// management screen. The API is the real cap — this only labels the button.
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
@@ -579,6 +582,48 @@ export function Workspace() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recipe, user, 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
// asked to sign in first, like every other write in the studio.
const favoriteRecipe = useCallback(
async (entry: Recipe) => {
if (!user) {
setAuthMode('login');
return;
}
try {
const stored = await api.createRecipe(entry.name, { ...entry });
setSaved((list) => [stored.recipe, ...list]);
setTab('favorited');
} catch (err) {
setError(t('err.generic', { msg: String(err) }));
}
},
[user, t]
);
// The star reads the id the dragged chip carried back to its recipe.
const favoriteById = useCallback(
(id: string) => {
const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id);
if (entry) void favoriteRecipe(entry);
},
[created, favoriteRecipe]
);
// `*` files the look on the stage without leaving the tab it was built in.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return;
e.preventDefault();
void favoriteRecipe(recipe);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [favoriteRecipe, recipe]);
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
// signed-in account also stores it; a guest's copy stays in `created`, which
// is exactly what "forgotten when you leave" means.
@@ -847,6 +892,7 @@ export function Workspace() {
edited: value !== def.defaultValue,
swatch: SLIDER_SWATCH[def.key]?.(value),
onChange: (value) => setAdjustmentOnce(def.set(value)),
onReset: () => setAdjustmentOnce(def.set(def.defaultValue)),
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
onBack: () => {
const g = PARAM_GROUP[def.key];
@@ -1053,6 +1099,12 @@ export function Workspace() {
setOpenParam(null);
setOpenGroup(null);
setShowRecipes(false);
// Under CREATE the button belongs to the form above it: it clears the
// draft, which live-previews its defaults straight onto the photo.
if (tab === 'create') {
setCreateReset((n) => n + 1);
return;
}
reset();
},
});
@@ -1167,12 +1219,14 @@ export function Workspace() {
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,
active: recipe.name === preset.name && !recipe.isCustom,
drag: preset.id,
onClick: () => applyRecipe({ ...preset }),
})),
];
@@ -1208,18 +1262,18 @@ export function Workspace() {
/>
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} />
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} />
<div className="chips">
{/* column 1 — the tab's own chips, RESET pinned at its head */}
{/* 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">
<ChipRow chips={[resetChip()]} />
{tab === 'create' ? (
<>
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
@@ -1240,6 +1294,11 @@ export function Workspace() {
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* RESET is the way out of an edit, not one more chip: it sits under
a rule at the foot of the column so it reads as its own thing. */}
<div className="reset-row">
<ChipRow chips={[resetChip()]} />
</div>
</div>
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}