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:
+438
-179
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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"><</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user