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 }) {