From d31d9458276c446b80a3a89f51113d6f5bcef80f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 18:56:17 +0700 Subject: [PATCH] Add the CREATE RECIPES tab to the web app The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding from the look on screen and clamping the same way. SAVE RECIPE applies the new look, lists it under RECIPES and, when signed in, stores it on the account; a guest's copy stays in memory and goes away with the page. Signed-in users can also export the recipe as the app's encrypted .recipe file (shared/utils /recipeShare.ts vendored byte-identical from the RN project). --- docker/README.md | 2 +- docker/frontend/shared/utils/recipeShare.ts | 99 ++++++ docker/frontend/src/App.tsx | 79 ++++- docker/frontend/src/engine/imageOps.ts | 4 +- docker/frontend/src/i18n/en.ts | 9 + docker/frontend/src/i18n/vi.ts | 9 + docker/frontend/src/styles/app.css | 23 ++ docker/frontend/src/ui/RecipeCreatePanel.tsx | 298 +++++++++++++++++++ docker/frontend/src/ui/ToolRail.tsx | 3 +- 9 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 docker/frontend/shared/utils/recipeShare.ts create mode 100644 docker/frontend/src/ui/RecipeCreatePanel.tsx diff --git a/docker/README.md b/docker/README.md index 8855198..0791c21 100644 --- a/docker/README.md +++ b/docker/README.md @@ -45,7 +45,7 @@ project (`@shopify/react-native-skia` is aliased to `src/engine/skiaShim.ts` in `vite.config.ts` + `tsconfig.json`, so those files compile unchanged): `cinemaShader` `colorUtils` `defaultRecipes` `exifWrite` `frameUtils` `jpegDpi` -`paramDefs` `skiaImage` `toneShader`. +`paramDefs` `recipeShare` `skiaImage` `toneShader`. The landing page needs no CDN: `frontend/public/assets/Syne.woff2` is the bundled display face and `frontend/public/assets/samples/s*.jpg` are the six diff --git a/docker/frontend/shared/utils/recipeShare.ts b/docker/frontend/shared/utils/recipeShare.ts new file mode 100644 index 0000000..61c3c31 --- /dev/null +++ b/docker/frontend/shared/utils/recipeShare.ts @@ -0,0 +1,99 @@ +import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types'; +import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes'; + +// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the +// recipe JSON, so a shared recipe imports back into the app on another phone. +// +// ponytail: obfuscation, not cryptography — the key ships inside the app and +// the keystream is a plain xorshift. It stops hand-editing and casual peeking, +// which is all a shared look needs; swap in expo-crypto/AES at the same two +// functions if the format ever has to resist a determined attacker. +const KEY = 'RecipesCam::recipe-share::v1'; +const ALGORITHM = 'xor16-v1'; + +const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none']; +const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe']; + +const hash = (s: string): number => { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +}; + +// xorshift32: one 16-bit word of keystream per call. +const stream = (seed: number) => { + let s = seed >>> 0 || 0x9e3779b9; + return () => { + s ^= s << 13; + s >>>= 0; + s ^= s >>> 17; + s ^= s << 5; + s >>>= 0; + return s & 0xffff; + }; +}; + +// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names +// with Vietnamese diacritics included) survives the round trip. +const scramble = (text: string, salt: number): string => { + const k = stream(hash(`${KEY}|${salt}`)); + let out = ''; + for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0'); + return out; +}; + +const unscramble = (hex: string, salt: number): string => { + if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.'); + const k = stream(hash(`${KEY}|${salt}`)); + let out = ''; + for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k()); + return out; +}; + +// The shareable half of a recipe: never the local id, and only known fields, so +// a file from a newer version cannot smuggle anything into storage. +export function exportRecipeXml(recipe: Recipe): string { + const shareable = { + name: recipe.name, + baseFilter: recipe.baseFilter, + adjustments: recipe.adjustments, + frameId: recipe.frameId, + useGeotag: recipe.useGeotag, + }; + const salt = Math.floor(Math.random() * 0xffffffff) >>> 0; + return [ + '', + ``, + ` ${scramble(JSON.stringify(shareable), salt)}`, + '', + '', + ].join('\n'); +} + +// Throws with a user-readable reason. Fields are validated and clamped to what +// this build understands: a recipe from a newer app imports as best it can +// instead of poisoning the store. +export function importRecipeXml(xml: string): Omit { + const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1]; + const payload = /([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, ''); + if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.'); + + let raw: any; + try { + raw = JSON.parse(unscramble(payload, parseInt(salt, 16))); + } catch { + raw = null; + } + if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') { + throw new Error('Recipe file is damaged or from another app.'); + } + + const name = raw.name.trim().slice(0, 40) || 'Imported recipe'; + const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none'; + const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial) }; + const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none'; + return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag }; +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 698c612..397a5d3 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ChipRow, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColum import { ImageStage } from './ui/ImageStage'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; +import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; @@ -12,6 +13,7 @@ import { api, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; +import { exportRecipeXml } from '../shared/utils/recipeShare'; import { writeJpegExif } from '../shared/utils/exifWrite'; import { CROP_W_H, type CropRatio, type FrameId, type GPSInfo, type Recipe } from '../shared/types'; import type { MsgKey } from './i18n/vi'; @@ -120,6 +122,9 @@ export function Workspace() { const [exporting, setExporting] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); + // Recipes made in this session, guest ones included. A guest's copy lives + // here and nowhere else, so reloading the page is what deletes it. + const [created, setCreated] = useState([]); // null = closed. The landing's register link is /app?auth=1, so it opens the // dialog straight on the sign-up tab. const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => @@ -300,6 +305,41 @@ export function Workspace() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [recipe, user, t]); + // 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. + const addCreatedRecipe = useCallback( + async (draft: RecipeDraft) => { + const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true }; + setCreated((list) => [entry, ...list]); + applyRecipe(entry); + if (!user) return; + try { + const stored = await api.createRecipe(entry.name, entry); + setSaved((list) => [stored.recipe, ...list]); + } catch (err) { + setError(t('err.generic', { msg: String(err) })); + } + }, + [user, applyRecipe, t] + ); + + // The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the + // app's own file, byte-identical). Signed in only — a guest's recipe is never + // written to disk. + const exportRecipeFile = useCallback( + (draft: RecipeDraft) => { + if (!user) return; + const xml = exportRecipeXml({ ...draft, id: 'share' }); + download( + new TextEncoder().encode(xml), + `${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`, + 'application/xml' + ); + }, + [user] + ); + const exportPhoto = useCallback(async () => { if (!engine || !source) return; setExporting(true); @@ -660,6 +700,9 @@ export function Workspace() { return isWmPanel(openGroup) ? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()] : framesRow(); + // CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row. + case 'create': + return []; default: return []; } @@ -671,12 +714,21 @@ export function Workspace() { ...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 }), - })); + // RECIPES = what this session made (newest first) + the bundled presets. + const recipeStrip: ChipDef[] = [ + ...created.map((entry) => ({ + key: entry.id, + label: entry.name, + active: recipe.id === entry.id, + onClick: () => applyRecipe(entry), + })), + ...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'); @@ -723,7 +775,18 @@ export function Workspace() { ) : null} - {/* 4. FRAME -> WATERMARK sub-panel: the mark's own text + place */} + {/* 4. CREATE -> the phone's recipe form */} + {tab === 'create' ? ( + void addCreatedRecipe(draft)} + onExport={exportRecipeFile} + /> + ) : null} + + {/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */} {tab === 'frame' && isWmPanel(openGroup) ? (
{markOn ? ( @@ -775,7 +838,7 @@ export function Workspace() {
) : null} - {/* 5. main chip row, RESET pinned outside the scroll */} + {/* 6. main chip row, RESET pinned outside the scroll */}
diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts index 13b8b9f..efeefd4 100644 --- a/docker/frontend/src/engine/imageOps.ts +++ b/docker/frontend/src/engine/imageOps.ts @@ -48,8 +48,8 @@ export async function readGps(bytes: Uint8Array): Promise { } } -export function download(bytes: Uint8Array, filename: string): void { - const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: 'image/jpeg' })); +export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void { + const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type })); const a = document.createElement('a'); a.href = url; a.download = filename; diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 1cedbbe..cc73e81 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -32,6 +32,15 @@ export const en: Dict = { 'tab.wb': 'WB', 'tab.fx': 'FX', 'tab.frame': 'FRAME', + 'tab.create': 'CREATE RECIPES', + + 'create.title': 'CREATE RECIPES', + 'create.save': 'SAVE RECIPE', + 'create.export': 'EXPORT .RECIPE FILE', + 'create.guestHint': + 'Guest: the recipe stays in RECIPES until you close the page. Log in to save it to your account and export it.', + 'create.userHint': + 'Saved to your account. Export the .recipe file to share it — encrypted exactly like the app’s.', 'sec.filmSims': 'PHOTO STYLE', 'sec.recipes': 'RECIPES', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 9b70c06..53ab3f7 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -35,6 +35,15 @@ export const vi = { 'tab.wb': 'WB', 'tab.fx': 'FX', 'tab.frame': 'FRAME', + 'tab.create': 'CREATE RECIPES', + + 'create.title': 'TẠO CÔNG THỨC', + 'create.save': 'LƯU CÔNG THỨC', + 'create.export': 'XUẤT TỆP .RECIPE', + 'create.guestHint': + 'Khách: công thức chỉ nằm trong RECIPES tới khi đóng trang. Đăng nhập để lưu vào tài khoản và xuất tệp.', + 'create.userHint': + 'Đã lưu vào tài khoản. Xuất tệp .recipe để chia sẻ — tệp được mã hóa đúng như trên app.', 'sec.filmSims': 'PHOTO STYLE', 'sec.recipes': 'RECIPES', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 1f1b591..5e292db 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -121,6 +121,29 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; } +/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */ +.create-form { + border-top: 1px solid var(--border-soft); + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.create-label { + font-size: 10px; + letter-spacing: 0.12em; + color: var(--text-dim); + font-weight: 600; +} +.create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +.create-num { display: flex; flex-direction: column; gap: 3px; min-width: 0; } +.create-num span { font-size: 10px; letter-spacing: 0.12em; color: var(--text-dim); font-weight: 600; } +.create-num.wide { grid-column: 1 / -1; } +.create-shift { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.create-shift .field { width: 64px; } +.create-actions { display: flex; gap: 6px; margin-top: 4px; } +.create-actions .btn { flex: 1 1 auto; } + /* --- stage -------------------------------------------------------------- */ .stage { flex: 1 1 auto; diff --git a/docker/frontend/src/ui/RecipeCreatePanel.tsx b/docker/frontend/src/ui/RecipeCreatePanel.tsx new file mode 100644 index 0000000..ead5c51 --- /dev/null +++ b/docker/frontend/src/ui/RecipeCreatePanel.tsx @@ -0,0 +1,298 @@ +import { useState } from 'react'; +import { ChipRow, type ChipDef } from './ChipColumn'; +import { useI18n } from '../i18n/I18nProvider'; +import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes'; +import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types'; + +// The phone's RecipeCreateModal, minus the modal: the same rows in the same +// order with the same clamping. CREATE opens on the look live on screen (the +// phone's rule — a recipe is a snapshot of what the user is looking at). + +export type RecipeDraft = Omit; + +// WB presets mirror the phone's WB-tab chips but keep pure presets (no -R +// variants — those are the separate Red/Blue offset boxes below). +const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [ + { key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 }, + { key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 }, + { key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 }, + { key: 'shade', label: 'SHADE', temperature: 8000, tint: 2 }, + { key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 }, + { key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 }, +]; +const WB_KEYS = WB_PRESETS.map((w) => w.key); + +const DR_OPTS = [ + { v: 'auto', d: 'AUTO' }, + { v: '100', d: 'DR100' }, + { v: '200', d: 'DR200' }, + { v: '400', d: 'DR400' }, +]; +const CC_OPTS = [ + { v: 'none', d: 'OFF' }, + { v: 'weak', d: 'WEAK' }, + { v: 'strong', d: 'STRONG' }, +]; +const GRAIN_OPTS = [ + { v: '0', d: 'OFF' }, + { v: '3', d: 'WEAK' }, + { v: '6', d: 'STRONG' }, +]; + +const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); +const int = (s: string, fallback: number, lo: number, hi: number) => { + const n = parseInt(s, 10); + return Number.isFinite(n) ? clamp(n, lo, hi) : fallback; +}; + +const wbKeyOf = (s: ColorAdjustments) => + WB_KEYS.find((k) => { + const p = WB_PRESETS.find((w) => w.key === k)!; + return p.temperature === s.temperature && p.tint === s.tint; + }) ?? 'custom'; + +export function RecipeCreatePanel({ + currentBase, + current, + signedIn, + onSave, + onExport, +}: { + currentBase?: BaseFilter; + current?: ColorAdjustments | null; + signedIn: boolean; + onSave: (draft: RecipeDraft) => void; + onExport: (draft: RecipeDraft) => void; +}) { + const { t } = useI18n(); + + const seedBase: BaseFilter = + currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia'; + const seedAdj: ColorAdjustments = + current ?? FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS; + + // Every sim's standard values as form state — used to seed the form when the + // SIMULATION chip changes. + const valuesOf = (b: BaseFilter, src?: ColorAdjustments) => { + const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS; + return { + dr: String(s.dynamicRange ?? 'auto'), + grain: String(s.grain ?? 0), + cc: s.colorChrome ?? 'none', + ccb: s.colorChromeBlue ?? 'none', + wbKey: wbKeyOf(s), + highlight: String(s.highlight), + shadow: String(s.shadow), + color: String(s.saturation), + nr: String(s.denoise), + sharp: String(s.sharpening), + clarity: String(s.clarity), + ev: String(s.exposureCompensation), + }; + }; + + const [sim, setSim] = useState(seedBase); + const [name, setName] = useState(''); + const [dr, setDr] = useState(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange)); + const [grain, setGrain] = useState(String(seedAdj.grain ?? 0)); + const [cc, setCc] = useState(seedAdj.colorChrome ?? 'none'); + const [ccb, setCcb] = useState(seedAdj.colorChromeBlue ?? 'none'); + const [wbKey, setWbKey] = useState(wbKeyOf(seedAdj)); + const [wbRed, setWbRed] = useState(String(seedAdj.wbRed ?? 0)); + const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0)); + const [fHigh, setFHigh] = useState(String(seedAdj.highlight)); + const [fShadow, setFShadow] = useState(String(seedAdj.shadow)); + const [fColor, setFColor] = useState(String(seedAdj.saturation)); + const [fNr, setFNr] = useState(String(seedAdj.denoise)); + const [fSharp, setFSharp] = useState(String(seedAdj.sharpening)); + const [fClarity, setFClarity] = useState(String(seedAdj.clarity)); + const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation)); + + // Switching the simulation reloads its standard values into the form. + const selectSim = (b: BaseFilter) => { + setSim(b); + const v = valuesOf(b); + setDr(v.dr); + setGrain(v.grain); + setCc(v.cc); + setCcb(v.ccb); + setWbKey(v.wbKey); + setFHigh(v.highlight); + setFShadow(v.shadow); + setFColor(v.color); + setFNr(v.nr); + setFSharp(v.sharp); + setFClarity(v.clarity); + setFEv(v.ev); + }; + + // Same assembly as the phone's handleSave: the live look rides along for every + // knob the form has no row for, so the saved recipe matches the screen. + const build = (): RecipeDraft => { + const wb = WB_PRESETS.find((w) => w.key === wbKey); + const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS; + const adjustments: ColorAdjustments = { + ...DEFAULT_ADJUSTMENTS, + ...seedAdj, + temperature: wb ? wb.temperature : seedAdj.temperature, + tint: wb ? wb.tint : seedAdj.tint, + wbRed: int(wbRed, 0, -9, 9), + wbBlue: int(wbBlue, 0, -9, 9), + highlight: int(fHigh, simAdj.highlight, -10, 10), + shadow: int(fShadow, simAdj.shadow, -10, 10), + saturation: int(fColor, simAdj.saturation, -10, 10), + denoise: int(fNr, simAdj.denoise, -10, 10), + sharpening: int(fSharp, simAdj.sharpening, -10, 10), + clarity: int(fClarity, simAdj.clarity, -10, 10), + grain: int(grain, seedAdj.grain, 0, 10), + colorChrome: cc as ColorAdjustments['colorChrome'], + colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'], + dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'], + exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3), + }; + return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true }; + }; + + const opts = ( + prefix: string, + options: { v: string; d: string }[], + value: string, + onPick: (v: string) => void + ): ChipDef[] => + options.map((o) => ({ + key: `${prefix}:${o.v}`, + label: o.d, + active: value === o.v, + onClick: () => onPick(o.v), + })); + + const num = ( + label: string, + value: string, + set: (s: string) => void, + ph?: string + ) => ( + + ); + + return ( +
+

{t('create.title')}

+ + + + SIMULATION + ({ + key: s.id, + label: s.name, + active: sim === s.baseFilter, + onClick: () => selectSim(s.baseFilter), + }))} + /> + + DYNAMIC RANGE + + +
+ {[ + { label: 'HIGHLIGHT', value: fHigh, set: setFHigh }, + { label: 'SHADOW', value: fShadow, set: setFShadow }, + { label: 'COLOR', value: fColor, set: setFColor }, + { label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' }, + { label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' }, + { label: 'CLARITY', value: fClarity, set: setFClarity }, + { label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' }, + ].map((f) => num(f.label, f.value, f.set, f.ph))} +
+ + GRAIN EFFECT + + + COLOR CHROME EFFECT + + + COLOR CHROME EFFECT BLUE + + + WHITE BALANCE + ({ v: w.key, d: w.label })), wbKey, setWbKey), + ...(wbKey === 'custom' + ? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }] + : []), + ]} + /> + +
+ WB SHIFT + RED + setWbRed(e.target.value)} + /> + BLUE + setWbBlue(e.target.value)} + /> +
+ +
+ ISO + {} }]} /> +
+ +
+ + +
+

{signedIn ? t('create.userHint') : t('create.guestHint')}

+
+ ); +} diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index fc82dd0..d85700a 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -3,7 +3,7 @@ import { useI18n } from '../i18n/I18nProvider'; // The app's six tabs, in the same order as the phone rail. The rail is vertical // on desktop and becomes the bottom bar under 860px (see styles/app.css). -export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame'; +export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame' | 'create'; export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'presets', label: 'tab.presets', glyph: '◉' }, @@ -12,6 +12,7 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'wb', label: 'tab.wb', glyph: '◐' }, { id: 'fx', label: 'tab.fx', glyph: '✦' }, { id: 'frame', label: 'tab.frame', glyph: '▣' }, + { id: 'create', label: 'tab.create', glyph: '✎' }, ]; export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {