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.
This commit is contained in:
2026-09-17 18:11:18 +07:00
parent c00c605dd7
commit dace19aec4
3 changed files with 490 additions and 211 deletions
+438 -179
View File
@@ -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<TabId, MsgKey> = {
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<CropRatio, 'free'>[] = ['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<Record<string, GroupKey>> = { 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<Exclude<CropRatio, 'free'>>('none');
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0);
const [openParam, setOpenParam] = useState<string | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('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<Recipe[]>([]);
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<Exclude<GroupKey, 'wm'>, {
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<CropRatio, 'free'>),
},
// 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<GroupKey, 'wm'>): 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<GroupKey, 'wm'>): 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() {
<ToolRail tab={tab} onTab={setTab} />
<div className="chips">
{tab === 'frame' ? (
<>
<Section title={t('sec.frame')}>
<ChipRow
chips={FRAMES.map((frame) => ({
key: frame.id,
label: t(frame.label),
active: frameId === frame.id,
onClick: () => {
remember();
setFrameId(frame.id);
},
}))}
/>
</Section>
<Section title={t('sec.crop')}>
<ChipRow
chips={CROPS.map((ratio) => ({
key: ratio,
label: t(`crop.${ratio}` as 'crop.none'),
active: crop === ratio,
onClick: () => setCrop(ratio),
}))}
/>
</Section>
<Section title={t('sec.rotate')}>
<ChipRow
chips={ROTATIONS.map((deg) => ({
key: String(deg),
label: t(`rot.${deg}` as 'rot.0'),
active: rotation === deg,
onClick: () => setRotation(deg),
}))}
/>
</Section>
<Section title={t('sec.watermark')}>
<ChipRow
chips={[
{
key: 'mark',
label: t('wm.text'),
active: markOn,
onClick: () => setMarkOn(!markOn),
},
]}
/>
{markOn ? (
<input
className="field"
style={{ marginTop: 6 }}
value={markText}
placeholder="RECIPESCAM"
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
</Section>
<Section title={t('sec.gps')}>
<ChipRow
chips={[
{
key: 'gps',
label: t('wm.gps'),
active: useGeotag,
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
onClick: () => setUseGeotag(!useGeotag),
},
]}
/>
{/* 1. ruler for the open slider */}
{slider ? <SliderRow slider={slider} /> : null}
{/* 2. the open group's options */}
{openGroup && openGroup !== 'wm' ? (
<div className="strip">
<ChipRow chips={stripChips(openGroup)} />
</div>
) : null}
{/* 3. PRESETS -> RECIPES strip */}
{tab === 'presets' && showRecipes ? (
<div className="strip">
{recipeStrip.length === 0 ? (
<p className="hint">{t('sec.savedEmpty')}</p>
) : (
<ChipRow chips={recipeStrip} />
)}
</div>
) : null}
{/* 4. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
{tab === 'frame' && isWmPanel(openGroup) ? (
<div className="strip wm-extra">
{markOn ? (
<input
className="field"
style={{ marginTop: 6 }}
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
{!gps ? (
<div className="chip-row" style={{ marginTop: 6 }}>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
) : null}
</Section>
</>
) : (
<>
<Section title={t(SECTION_TITLE[tab])}>
<ChipRow chips={chips} />
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
</Section>
{slider ? <SliderRow slider={slider} /> : null}
</>
)}
) : null}
{useGeotag ? (
<>
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
{!gps ? (
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
) : null}
</>
) : null}
</div>
) : null}
{/* 5. main chip row, RESET pinned outside the scroll */}
<div className="main-row">
<ChipRow chips={[resetChip()]} />
<ChipRow chips={chips} />
</div>
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
</div>
<ImageStage
+25 -2
View File
@@ -76,6 +76,13 @@
}
.chips h2 { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.14em; color: var(--text-dim); font-weight: 600; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
/* One strip at a time: the ruler, the open group's options, the recipe list and
the watermark fields each sit on their own row above the main chip row. */
.strip { border-top: 1px solid var(--border-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
/* RESET stays pinned at full size; the tab's chips take the rest and scroll. */
.main-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.main-row > .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; }
+27 -30
View File
@@ -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[] }) {
<button
key={chip.key}
type="button"
className={`chip${chip.active ? ' on' : ''}`}
data-key={chip.key}
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}`}
aria-pressed={!!chip.active}
disabled={chip.disabled}
onClick={chip.onClick}
@@ -49,30 +55,21 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
export function SliderRow({ slider }: { slider: SliderDef }) {
return (
<div className="slider-row">
<div className="head">
<span>{slider.label}</span>
<b>{slider.display}</b>
</div>
<input
type="range"
min={slider.min}
max={slider.max}
step={slider.step}
value={slider.value}
onChange={(e) => slider.onChange(Number(e.target.value))}
/>
<div className="chip-row">
<button type="button" className="chip ghost" onClick={slider.onClose}>—</button>
<button type="button" className="back" onClick={slider.onBack} aria-label="back">&lt;</button>
<div className="ruler">
<div className="head">
<span>{slider.label}</span>
<b className={slider.edited ? 'amber' : ''}>{slider.display}</b>
</div>
<input
type="range"
min={slider.min}
max={slider.max}
step={slider.step}
value={slider.value}
onChange={(e) => slider.onChange(Number(e.target.value))}
/>
</div>
</div>
);
}
export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}