diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index e2730fe..168ab66 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -292,6 +292,9 @@ export function Workspace() { const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); + // 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([]); @@ -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() { />
- +
- {/* 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 */}
- {tab === 'create' ? ( <> void addCreatedRecipe(draft)} onExport={exportRecipeFile} @@ -1240,6 +1294,11 @@ export function Workspace() { )} {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: 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. */} +
+ +
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index e48d007..309d5de 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -15,7 +15,7 @@ } .brand { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-decoration: none; } .brand img { border-radius: 8px; } -.brand em { font-style: normal; font-weight: 400; color: var(--text-dim); } +.brand em { font-style: normal; font-weight: 400; color: var(--accent); } /* The page name, top-left beside the mark — the same pair /admin and /profile carry, so every screen says where the visitor is. */ .page-name { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); } @@ -96,6 +96,9 @@ /* CREATE RECIPES draws a two-up form, so its column is the wide one. */ .col-create { width: 300px; } .col-sub { background: var(--bg-elev); } +/* RESET is the way out of an edit, not one more chip: a rule and some air keep + it apart from the tab's own buttons at the foot of the column. */ +.reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); } .chip-row { display: flex; flex-wrap: wrap; gap: 6px; } /* In a column the chips stack: one chip per row, full width. */ .col .chip-row { flex-direction: column; align-items: stretch; } @@ -330,7 +333,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } /* The mark and the page name, top-left, always a way back to the landing page — the same pair the studio's TopBar carries. */ .adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; } -.adm-brand em { font-style: normal; font-weight: 400; color: var(--text-dim); } +.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); } .adm-brand img { border-radius: 8px; } .adm-slot { display: flex; flex-direction: column; gap: 3px; letter-spacing: 0.04em; } .adm-slot select { width: 100%; } @@ -439,9 +442,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } height: 26px; border-radius: 999px; overflow: hidden; - background: var(--bg-sunken); - border: 1px solid var(--border); - color: var(--text-dim); + background: var(--accent-soft); + border: 1px solid var(--accent-line); + color: var(--accent); font-size: 11px; font-weight: 600; line-height: 1; @@ -451,6 +454,8 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .avatar-row { display: flex; align-items: center; gap: 10px; } .avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; } .account-btn { display: inline-flex; align-items: center; gap: 7px; } +/* The signed-in name rides the accent with the face beside it. */ +.btn.account-btn { color: var(--accent); } /* --- responsive --------------------------------------------------------- */ @media (max-width: 860px) { diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index bb75438..d1bd367 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -14,6 +14,9 @@ export interface ChipDef { amberValue?: boolean; pro?: boolean; disabled?: boolean; + // Present when the chip itself can be dragged somewhere: the payload the drop + // target reads back (the recipe's id, for the FAVORITED rail button). + drag?: string; onClick: () => void; } @@ -31,6 +34,8 @@ export interface SliderDef { // swatch under the ruler so the hue moves with the number. Absent = no swatch. swatch?: string; onChange: (value: number) => void; + // Double-clicking the track puts the parameter back to its default. + onReset: () => void; // "<" — closes the ruler and reopens the strip it was launched from. onBack: () => void; } @@ -46,6 +51,15 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) { className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}`} aria-pressed={!!chip.active} disabled={chip.disabled} + draggable={!!chip.drag} + onDragStart={ + chip.drag + ? (e) => { + e.dataTransfer.setData('text/plain', chip.drag!); + e.dataTransfer.effectAllowed = 'copy'; + } + : undefined + } onClick={chip.onClick} > {chip.label} @@ -92,6 +106,7 @@ export function SliderRow({ slider }: { slider: SliderDef }) { step={slider.step} value={slider.value} onChange={(e) => slider.onChange(Number(e.target.value))} + onDoubleClick={slider.onReset} /> {slider.swatch ? (
{ + if (cropEditing) return; + const v = viewRef.current; + if (v.s !== 1) { + setView({ s: 1, x: 0, y: 0 }); + return; + } + const b = baseRect(); + const nw = imgRef.current?.naturalWidth ?? 0; + if (!b || !(nw > 0)) return; + const s = Math.min(ZOOM_MAX, Math.max(1, nw / b.w)); + if (s === 1) return; + setView({ s, ...clampPan(s, 0, 0) }); + }} style={ view.s === 1 ? undefined diff --git a/docker/frontend/src/ui/RecipeCreatePanel.tsx b/docker/frontend/src/ui/RecipeCreatePanel.tsx index 3473b1c..a1d61aa 100644 --- a/docker/frontend/src/ui/RecipeCreatePanel.tsx +++ b/docker/frontend/src/ui/RecipeCreatePanel.tsx @@ -58,10 +58,14 @@ export function RecipeCreatePanel({ onPreview, onSave, onExport, + resetToken = 0, }: { currentBase?: BaseFilter; current?: ColorAdjustments | null; signedIn: boolean; + // Bumped by the RESET chip above the form: the draft drops back to the state + // the form opened with. + resetToken?: number; onPreview: (draft: Pick) => void; onSave: (draft: RecipeDraft) => void; onExport: (draft: RecipeDraft) => void; @@ -155,6 +159,17 @@ export function RecipeCreatePanel({ return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true }; }; + // RESET clears the draft: back to the seed simulation, its standard values and + // an empty name. The form previews live, so the photo follows it home. + useEffect(() => { + if (resetToken === 0) return; + selectSim(seedBase); + setName(''); + setWbRed(String(seedAdj.wbRed ?? 0)); + setWbBlue(String(seedAdj.wbBlue ?? 0)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetToken]); + // Realtime: every knob applies to the photo on screen as it is typed, so the // effect of the recipe is visible before saving it. Opening the tab is not an // edit — the first run (the seeded values) is skipped. diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index 16af260..da62a5b 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -17,8 +17,18 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'create', label: 'tab.create', glyph: '✎' }, ]; -export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) { +export function ToolRail({ + tab, + onTab, + onDropRecipe, +}: { + tab: TabId; + onTab: (tab: TabId) => void; + // A recipe chip dragged onto FAVORITED is filed there (see App's favorite()). + onDropRecipe?: (id: string) => void; +}) { const { t } = useI18n(); + const favorited = (id: TabId) => id === 'favorited'; return (