From dace19aec47a482e1cbdc2fb42fa604f49da5562 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 18:11:18 +0700 Subject: [PATCH] Match the phone's chip-strip panel on the web The chips column was a stack of labelled sections; the phone opens one row at a time. Port that shape: tapping a tool tab shows the tab's chips, tapping a param chip opens its ruler above them, tapping a group chip opens its options strip, and either closes the other. RESET leads the row (amber while dirty) and PHOTO STYLE / RECIPES / WATERMARK are strips instead of inline chips. --- docker/frontend/src/App.tsx | 617 ++++++++++++++++++-------- docker/frontend/src/styles/app.css | 27 +- docker/frontend/src/ui/ChipColumn.tsx | 57 ++- 3 files changed, 490 insertions(+), 211 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 391b08c..90f447a 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ToolRail, type TabId } from './ui/ToolRail'; -import { ChipRow, Section, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; +import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; import { ImageStage } from './ui/ImageStage'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; @@ -21,15 +21,6 @@ import type { MsgKey } from './i18n/vi'; const PREVIEW_MAX = 1600; const PREVIEW_DEBOUNCE_MS = 120; -const SECTION_TITLE: Record = { - presets: 'sec.filmSims', - favorited: 'sec.saved', - light: 'tab.light', - wb: 'tab.wb', - fx: 'tab.fx', - frame: 'sec.frame', -}; - const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'none', label: 'frame.none' }, { id: 'polaroid', label: 'frame.polaroid' }, @@ -42,7 +33,6 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [ // ratios are listed — 'free' (DEFAULT_CROP_RECT + drag on the canvas) lands // with the crop overlay in W3. The type already carries it. const CROPS: Exclude[] = ['none', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; -const ROTATIONS = [0, 90, 180, 270] as const; const TEMP_PRESETS: { label: string; kelvin: number }[] = [ { label: 'AUTO', kelvin: 5500 }, { label: 'DAYLIGHT', kelvin: 5600 }, @@ -53,6 +43,37 @@ const TEMP_PRESETS: { label: string; kelvin: number }[] = [ { label: 'FLUOR', kelvin: 4000 }, ]; +// The option strips, one open at a time (phone's GroupKey set minus the groups +// the web engine has no knob for). +type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm' + | 'wmColor' | 'wmSize' | 'gpsColor' | 'gpsName' | 'gpsTime'; +const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'gpsColor', 'gpsName', 'gpsTime']; +const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); +// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). +const PARAM_GROUP: Partial> = { temperature: 'temp' }; + +const CHROME_OPTIONS = [ + { v: 'none', d: 'OFF' }, + { v: 'weak', d: 'WEAK' }, + { v: 'strong', d: 'STRONG' }, +]; +const DR_OPTIONS = [ + { v: 'auto', d: 'AUTO' }, + { v: '100', d: 'DR100' }, + { v: '200', d: 'DR200' }, + { v: '400', d: 'DR400' }, +]; +const WM_COLORS = [ + { v: '#ffffff', d: 'WHITE' }, + { v: '#f59e0b', d: 'AMBER' }, + { v: '#000000', d: 'BLACK' }, +]; +const WM_SIZES = [ + { v: '1', d: '1x' }, + { v: '1.5', d: '1.5x' }, + { v: '2', d: '2x' }, +]; + interface LoadedSource { bytes: Uint8Array; name: string; @@ -81,6 +102,8 @@ export function Workspace() { const [crop, setCrop] = useState>('none'); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0); const [openParam, setOpenParam] = useState(null); + const [openGroup, setOpenGroup] = useState(null); + const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); const [peek, setPeek] = useState(false); @@ -88,8 +111,10 @@ export function Workspace() { const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' }); const [place, setPlace] = useState(''); const [useGeotag, setUseGeotag] = useState(false); + const [gpsStyle, setGpsStyle] = useState({ color: '#f59e0b', showName: true, showTime: true }); const [markText, setMarkText] = useState(''); const [markOn, setMarkOn] = useState(false); + const [markStyle, setMarkStyle] = useState({ color: '#ffffff', size: 1 }); const [undo, setUndo] = useState([]); const [exporting, setExporting] = useState(false); @@ -146,8 +171,8 @@ export function Workspace() { sharpen: true, photoRotation: rotation, cropWH: crop === 'none' ? undefined : CROP_W_H[crop], - watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } : null, - gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null, + watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null, + gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, }); if (token !== renderToken.current) return; @@ -166,7 +191,7 @@ export function Workspace() { }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, markOn, markText]); + }, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); // --- input --------------------------------------------------------------- const loadFile = useCallback( @@ -224,13 +249,27 @@ export function Workspace() { const reset = useCallback(() => { remember(); - setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...DEFAULT_ADJUSTMENTS }, baseFilter: 'none', cinema: null })); - setSimId(null); + // Back to the startup look, exactly like the phone: the PROVIA sim, every + // knob neutral, no frame/crop, no rotation, both marks off and restyled. + const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); + setRecipe((prev) => ({ + ...(provia ? filmSimToRecipe(provia) : prev), + id: prev.id, + name: prev.name, + isCustom: true, + })); + setSimId(provia ? provia.id : null); setFrameId('none'); setCrop('none'); setRotation(0); setMarkOn(false); + setMarkText(''); + setMarkStyle({ color: '#ffffff', size: 1 }); + setUseGeotag(false); + setGpsStyle({ color: '#f59e0b', showName: true, showTime: true }); setOpenParam(null); + setOpenGroup(null); + setShowRecipes(false); }, [remember]); const undoLast = useCallback(() => { @@ -278,11 +317,11 @@ export function Workspace() { photoRotation: rotation, cropWH: crop === 'none' ? undefined : CROP_W_H[crop], watermark: markOn && markText - ? { text: markText, x: 0.5, y: 0.92, color: '#ffffff', size: 1 } + ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : tier.watermark ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } : null, - gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, showName: true, showTime: true } : null, + gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); @@ -308,19 +347,54 @@ export function Workspace() { } finally { setExporting(false); } - }, [engine, source, recipe, frameId, useGeotag, gps, rotation, crop, markOn, markText, place, user, t]); + }, [engine, source, recipe, frameId, useGeotag, gps, gpsStyle, rotation, crop, markOn, markText, markStyle, place, user, t]); - // --- chips ---------------------------------------------------------------- + // --- strips --------------------------------------------------------------- + // One row at a time, exactly the phone's rule: opening a ruler closes the + // option strip (and vice-versa), and either closes the expanded recipes. + const toggleParam = useCallback( + (key: string) => { + setOpenGroup(null); + setShowRecipes(false); + setOpenParam(openParam === key ? null : key); + }, + [openParam] + ); + + const toggleGroup = useCallback( + (g: GroupKey) => { + const next = openGroup === g ? null : g; + if (next) { + setOpenParam(null); + setShowRecipes(false); + } + setOpenGroup(next); + }, + [openGroup] + ); + + useEffect(() => { + setOpenParam(null); + setOpenGroup(null); + setShowRecipes(false); + }, [tab]); + + // Continuous parameter chips: label + live value; amber text when edited, + // amber border while its ruler is open. const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => - defs.map((def) => ({ - key: def.key, - label: def.label, - value: def.display(def.get(recipe.adjustments)), - active: openParam === def.key, - pro: def.pro && !user, - disabled: !!def.pro && !user, - onClick: () => setOpenParam(openParam === def.key ? null : def.key), - })); + defs.map((def) => { + const value = def.get(recipe.adjustments); + return { + key: def.key, + label: def.label, + value: def.display(value), + active: openParam === def.key, + amberValue: openParam !== def.key && value !== def.defaultValue, + pro: def.pro && !user, + disabled: !!def.pro && !user, + onClick: () => toggleParam(def.key), + }; + }); const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; @@ -335,52 +409,271 @@ export function Workspace() { max: def.max, step: def.step, display: def.display(def.get(recipe.adjustments)), + edited: def.get(recipe.adjustments) !== def.defaultValue, onChange: (value) => setAdjustment(def.set(value)), - onClose: () => setOpenParam(null), + // Back reopens the strip the row was launched from (COLOR TEMP -> TEMP). + onBack: () => { + const g = PARAM_GROUP[def.key]; + setOpenParam(null); + if (g) setOpenGroup(g); + }, }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [openParam, recipe.adjustments, setAdjustment]); - const chips: ChipDef[] = useMemo(() => { - if (tab === 'presets') { - return [ - ...FILM_SIMS.map((sim) => ({ - key: sim.id, - label: sim.name, - active: simId === sim.id, - onClick: () => applyRecipe(filmSimToRecipe(sim)), - })), - ...DEFAULT_RECIPES.slice(0, 6).map((preset) => ({ - key: preset.id, - label: preset.name, - active: recipe.name === preset.name && !recipe.isCustom, - onClick: () => applyRecipe({ ...preset }), - })), - ]; + // Option strips: a chip opens one, each option picks a value. `off` is the + // neutral value the chip glows amber against. + const groupDefs: Record, { + label: string; + off: string; + value: string; + options: { v: string; d: string }[]; + onPick: (v: string) => void; + }> = { + cx: { + label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none', + options: CHROME_OPTIONS, + onPick: (v) => setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] }), + }, + cxb: { + label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none', + options: CHROME_OPTIONS, + onPick: (v) => setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] }), + }, + dr: { + label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'), + options: DR_OPTIONS, + onPick: (v) => setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] }), + }, + // ponytail: fixed ratios only — 'free' needs the drag-on-canvas rect (W3); + // the type already carries it. + crop: { + label: 'CROP', off: 'none', value: crop, + options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c })), + onPick: (v) => setCrop(v as Exclude), + }, + // ponytail: no STRAIGHTEN/AUTO — the web export carries photoRotation only. + rotate: { + label: 'ROTATE', off: '0', value: String(rotation), + options: [ + { v: 'reset', d: 'RESET' }, + { v: '0', d: '0°' }, + { v: '90', d: '90°' }, + { v: '180', d: '180°' }, + { v: '270', d: '270°' }, + ], + onPick: (v) => setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)), + }, + style: { + label: 'PHOTO STYLE', off: 'sim-provia', + value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '', + options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })), + onPick: (v) => { + const sim = FILM_SIMS.find((s) => s.id === v); + if (!sim) return; + setShowRecipes(false); + applyRecipe(filmSimToRecipe(sim)); + }, + }, + // TEMP: the presets plus COLOR TEMP, which opens the ruler above instead + // of picking a value. + temp: { + label: 'TEMP', off: 'auto', value: String(recipe.adjustments.temperature), + options: [ + { v: 'color-temp', d: 'COLOR TEMP' }, + ...TEMP_PRESETS.map((p) => ({ v: String(p.kelvin), d: p.label })), + ], + onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : setAdjustment({ temperature: Number(v) })), + }, + wmColor: { + label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color, + options: WM_COLORS, + onPick: (v) => setMarkStyle((prev) => ({ ...prev, color: v })), + }, + wmSize: { + label: 'TEXT SIZE', off: '1', value: String(markStyle.size), + options: WM_SIZES, + onPick: (v) => setMarkStyle((prev) => ({ ...prev, size: Number(v) })), + }, + gpsColor: { + label: 'COLOR', off: '#f59e0b', value: gpsStyle.color, + options: WM_COLORS, + onPick: (v) => setGpsStyle((prev) => ({ ...prev, color: v })), + }, + gpsName: { + label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF', + options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], + onPick: (v) => setGpsStyle((prev) => ({ ...prev, showName: v === 'on' })), + }, + gpsTime: { + label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF', + options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], + onPick: (v) => setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' })), + }, + }; + + const groupChip = (g: Exclude): ChipDef => ({ + key: `grp-${g}`, + label: groupDefs[g].label, + active: openGroup === g, + amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off, + onClick: () => toggleGroup(g), + }); + + const choiceChips = ( + prefix: string, + options: { v: string; d: string }[], + current: string, + onPick: (v: string) => void + ): ChipDef[] => + options.map((o) => ({ + key: `${prefix}:${o.v}`, + label: o.d, + active: current === o.v, + onClick: () => onPick(o.v), + })); + + const wmOn = useGeotag || markOn; + const wmChips = (): ChipDef[] => [ + { + key: 'gps', + label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', + active: useGeotag, + disabled: !gps && !(gpsManual.lat && gpsManual.lng), + onClick: () => setUseGeotag(!useGeotag), + }, + ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []), + { + key: 'custom-wm', + label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', + active: markOn, + onClick: () => setMarkOn(!markOn), + }, + ...(markOn ? [groupChip('wmColor'), groupChip('wmSize')] : []), + ]; + + // Every tab carries RESET, pinned outside the scrolling chips. + // RESET lights up only while the look differs from what reset() puts back — + // every clause has a matching setter there, or the star never goes out. + const resetDirty = useMemo(() => { + const a = recipe.adjustments; + const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[]) + .some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]); + return tweaked + || (simId ?? 'sim-provia') !== 'sim-provia' + || frameId !== 'none' || crop !== 'none' || rotation !== 0 + || markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1 + || useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime; + }, [recipe, simId, frameId, crop, rotation, markOn, markText, markStyle, useGeotag, gpsStyle]); + + const resetChip = (): ChipDef => ({ + key: 'reset-all', + label: resetDirty ? 'RESET *' : 'RESET', + amberValue: resetDirty, + onClick: () => { + setOpenParam(null); + setOpenGroup(null); + setShowRecipes(false); + reset(); + }, + }); + + const framesRow = (): ChipDef[] => [ + ...FRAMES.map((frame) => ({ + key: `fr:${frame.id}`, + label: t(frame.label), + active: frameId === frame.id, + onClick: () => { + setOpenParam(null); + setOpenGroup(null); + remember(); + setFrameId(frame.id); + }, + })), + groupChip('crop'), + // ponytail: no APPLY — picking a ratio crops immediately, there is no + // framed-but-uncommitted state on the web yet. + { + key: 'grp-rotate', + label: `ROTATE ${rotation}`, + active: openGroup === 'rotate', + amberValue: openGroup !== 'rotate' && rotation !== 0, + onClick: () => toggleGroup('rotate'), + }, + { + key: 'grp-wm', + label: wmOn ? 'WATERMARK ON' : 'WATERMARK', + active: openGroup === 'wm', + amberValue: openGroup !== 'wm' && wmOn, + onClick: () => toggleGroup('wm'), + }, + ]; + + // The main chip row of the active tab, exactly the phone's per-tab sets. + const chips: ChipDef[] = (() => { + switch (tab) { + case 'presets': + return [ + groupChip('style'), + { + key: 'recipes-toggle', + label: 'RECIPES', + active: showRecipes, + onClick: () => { + setOpenGroup(null); + setOpenParam(null); + setShowRecipes((v) => !v); + }, + }, + // ponytail: no CREATE/IMPORT chips — saving goes through the top bar + // and the web build has no recipe import. + ]; + case 'favorited': + return saved.length === 0 + ? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }] + : saved.map((entry) => ({ + key: String(entry.id), + label: entry.name, + active: false, + onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), + })); + case 'light': + return [...paramChips(PARAM_DEFS.iq), groupChip('dr')]; + case 'wb': + return [ + { + // TEMP is the one strip chip that names its value — nothing else + // does. + ...groupChip('temp'), + label: `TEMP ${Math.round(recipe.adjustments.temperature) === 5500 ? 'AUTO' : `${recipe.adjustments.temperature}K`}`, + }, + ...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')), + groupChip('cx'), + groupChip('cxb'), + ]; + case 'fx': + return paramChips(PARAM_DEFS.filters); + case 'frame': + return isWmPanel(openGroup) + ? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()] + : framesRow(); + default: + return []; } - if (tab === 'favorited') { - return saved.map((entry) => ({ - key: String(entry.id), - label: entry.name, - active: false, - onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), - })); - } - if (tab === 'light') return paramChips(PARAM_DEFS.iq); - if (tab === 'wb') { - return [ - ...TEMP_PRESETS.map((preset) => ({ - key: preset.label, - label: preset.label, - active: Math.round(recipe.adjustments.temperature) === preset.kelvin, - onClick: () => setAdjustment({ temperature: preset.kelvin }), - })), - ...paramChips(PARAM_DEFS.wb), - ]; - } - if (tab === 'fx') return paramChips(PARAM_DEFS.filters); - return []; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab, saved, simId, recipe, openParam, user, applyRecipe, setAdjustment]); + })(); + + const stripChips = (g: Exclude): ChipDef[] => [ + { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, + ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick), + ]; + + const recipeStrip: ChipDef[] = DEFAULT_RECIPES.map((preset) => ({ + key: preset.id, + label: preset.name, + active: recipe.name === preset.name && !recipe.isCustom, + onClick: () => applyRecipe({ ...preset }), + })); const status = busy ? busy : rendered ? t('meta.rendered', { ms: rendered.ms, w: rendered.w, h: rendered.h }) : t('meta.ready'); @@ -406,119 +699,85 @@ export function Workspace() {
- {tab === 'frame' ? ( - <> -
- ({ - key: frame.id, - label: t(frame.label), - active: frameId === frame.id, - onClick: () => { - remember(); - setFrameId(frame.id); - }, - }))} - /> -
-
- ({ - key: ratio, - label: t(`crop.${ratio}` as 'crop.none'), - active: crop === ratio, - onClick: () => setCrop(ratio), - }))} - /> -
-
- ({ - key: String(deg), - label: t(`rot.${deg}` as 'rot.0'), - active: rotation === deg, - onClick: () => setRotation(deg), - }))} - /> -
-
- setMarkOn(!markOn), - }, - ]} - /> - {markOn ? ( - setMarkText(e.target.value)} - /> - ) : null} -
-
- setUseGeotag(!useGeotag), - }, - ]} - /> + {/* 1. ruler for the open slider */} + {slider ? : null} + + {/* 2. the open group's options */} + {openGroup && openGroup !== 'wm' ? ( +
+ +
+ ) : null} + + {/* 3. PRESETS -> RECIPES strip */} + {tab === 'presets' && showRecipes ? ( +
+ {recipeStrip.length === 0 ? ( +

{t('sec.savedEmpty')}

+ ) : ( + + )} +
+ ) : null} + + {/* 4. FRAME -> WATERMARK sub-panel: the mark's own text + place */} + {tab === 'frame' && isWmPanel(openGroup) ? ( +
+ {markOn ? ( setPlace(e.target.value)} + value={markText} + placeholder="TYPE WATERMARK TEXT" + maxLength={80} + onChange={(e) => setMarkText(e.target.value)} /> -

{gps ? t('wm.hasExif') : t('wm.noExif')}

- {!gps ? ( -
- setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} - onBlur={() => { - const lat = Number(gpsManual.lat); - const lng = Number(gpsManual.lng); - if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { - setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' }); - setUseGeotag(true); - } - }} - /> - setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} - /> -
- ) : null} -
- - ) : ( - <> -
- - {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} -
- {slider ? : null} - - )} + ) : null} + {useGeotag ? ( + <> + setPlace(e.target.value)} + /> +

{gps ? t('wm.hasExif') : t('wm.noExif')}

+ {!gps ? ( +
+ setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} + onBlur={() => { + const lat = Number(gpsManual.lat); + const lng = Number(gpsManual.lng); + if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { + setGps({ latitude: lat, longitude: lng, locality: place, timestamp: Date.now(), source: 'device' }); + setUseGeotag(true); + } + }} + /> + setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} + /> +
+ ) : null} + + ) : null} +
+ ) : null} + + {/* 5. main chip row, RESET pinned outside the scroll */} +
+ + +
+ {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} .chip-row:last-child { flex: 1 1 auto; min-width: 0; } +.wm-extra { border-top-color: var(--accent-line); } .chip { border: 1px solid var(--border); background: var(--bg-elev); @@ -88,13 +95,29 @@ gap: 6px; } .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); } .chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; } .chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); } .chip:disabled { opacity: 0.4; cursor: not-allowed; } -.slider-row { border-top: 1px solid var(--border-soft); padding-top: 10px; display: flex; flex-direction: column; gap: 6px; } +/* The ruler row: "<" back button, label + live value, full-width slider. */ +.slider-row { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; display: flex; align-items: center; gap: 10px; } +.slider-row .ruler { flex: 1 1 auto; min-width: 0; } .slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); } -.slider-row .head b { color: var(--accent); font-family: var(--mono); letter-spacing: 0; } +.slider-row .head b { color: var(--text); font-family: var(--mono); letter-spacing: 0; } +.slider-row .head b.amber { color: var(--accent); } +.slider-row .back { + flex: 0 0 auto; + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text-dim); + border-radius: 999px; + padding: 4px 10px; + font-family: var(--mono); + font-weight: 700; + cursor: pointer; +} input[type="range"] { width: 100%; accent-color: var(--accent); } .hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; } diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 34078ee..16e1e16 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -1,13 +1,15 @@ -import type { ReactNode } from 'react'; - -// One column, one open slider row at a time — the same shape as the app's -// AdjustmentPanel: a chip row per group, and the tapped chip's row opens -// underneath it. +// One strip at a time — the same shape as the phone's AdjustmentPanel: a ruler +// row for the open slider, an options strip for the open group, then the tab's +// chip row. Chip labels are the phone's own strings (they are not translated +// there either). export interface ChipDef { key: string; label: string; value?: string; + // amber border while this chip's row/strip is open, amber text when the value + // it carries is not its default. active?: boolean; + amberValue?: boolean; pro?: boolean; disabled?: boolean; onClick: () => void; @@ -21,8 +23,11 @@ export interface SliderDef { max: number; step: number; display: string; + // amber value readout once the knob leaves its default (phone's ruler row) + edited: boolean; onChange: (value: number) => void; - onClose: () => void; + // "<" — closes the ruler and reopens the strip it was launched from. + onBack: () => void; } export function ChipRow({ chips }: { chips: ChipDef[] }) { @@ -32,7 +37,8 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) { + +
+
+ {slider.label} + {slider.display} +
+ slider.onChange(Number(e.target.value))} + />
); } - -export function Section({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ); -}