Add self-contained docker/ stack for the web UI

`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
This commit is contained in:
2026-09-17 17:43:03 +07:00
parent 608901bd0f
commit 8c6e7930db
57 changed files with 9359 additions and 2 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { api } from '../api';
// Email + password only: the cookie is set by the API container, this dialog
// just drives it. The demo is always one click away — the app is usable signed
// out, and only export resolution / saving are gated (see config/tiers.ts).
export function AuthModal({
onClose,
onDone,
}: {
onClose: () => void;
onDone: () => void;
}) {
const { t } = useI18n();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await (mode === 'login' ? api.login(email, password) : api.signup(email, password));
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<form className="modal" onSubmit={submit}>
<h2>{mode === 'login' ? t('auth.loginTitle') : t('auth.signupTitle')}</h2>
<input
className="field"
type="email"
required
autoComplete="email"
placeholder={t('auth.email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
className="field"
type="password"
required
minLength={8}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="hint">{t('auth.passwordHint')}</p>
{error ? <p className="err">{error}</p> : null}
<button type="submit" className="btn primary" disabled={busy}>
{busy ? t('auth.busy') : mode === 'login' ? t('nav.login') : t('nav.signup')}
</button>
<button type="button" className="btn ghost" onClick={() => setMode(mode === 'login' ? 'signup' : 'login')}>
{mode === 'login' ? t('auth.noAccount') : t('auth.haveAccount')}
</button>
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
</form>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
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.
export interface ChipDef {
key: string;
label: string;
value?: string;
active?: boolean;
pro?: boolean;
disabled?: boolean;
onClick: () => void;
}
export interface SliderDef {
key: string;
label: string;
value: number;
min: number;
max: number;
step: number;
display: string;
onChange: (value: number) => void;
onClose: () => void;
}
export function ChipRow({ chips }: { chips: ChipDef[] }) {
return (
<div className="chip-row">
{chips.map((chip) => (
<button
key={chip.key}
type="button"
className={`chip${chip.active ? ' on' : ''}`}
aria-pressed={!!chip.active}
disabled={chip.disabled}
onClick={chip.onClick}
>
{chip.label}
{chip.value ? <span className="val">{chip.value}</span> : null}
{chip.pro ? <span className="pro">PRO</span> : null}
</button>
))}
</div>
);
}
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>
</div>
</div>
);
}
export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
// The centre column: drop target, preview canvas, and the PEEK comparison.
// Dragging a photo anywhere on the page works, not just on the frame — the
// whole stage is the drop zone.
export function ImageStage({
previewUrl,
originalUrl,
busy,
error,
peek,
onPeek,
onFile,
}: {
previewUrl: string | null;
originalUrl: string | null;
busy: string | null;
error: string | null;
peek: boolean;
onPeek: (on: boolean) => void;
onFile: (file: File) => void;
}) {
const { t } = useI18n();
const inputRef = useRef<HTMLInputElement>(null);
const [over, setOver] = useState(false);
// \ toggles PEEK; the key is the app's own shortcut, kept identical on web.
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === '\\') onPeek(true);
};
const up = (e: KeyboardEvent) => {
if (e.key === '\\') onPeek(false);
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, [onPeek]);
const stop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const drop = useCallback(
(e: React.DragEvent) => {
stop(e);
setOver(false);
const file = e.dataTransfer?.files?.[0];
if (file) onFile(file);
},
[onFile, stop]
);
return (
<div
className="stage"
onDragOver={(e) => {
stop(e);
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={drop}
onContextMenu={(e) => e.preventDefault()}
onMouseDown={(e) => {
if (e.button === 2) onPeek(true);
}}
onMouseUp={() => onPeek(false)}
>
{!previewUrl ? (
<div
className={`dropzone${over ? ' over' : ''}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
stop(e);
setOver(true);
}}
onDrop={drop}
>
<strong>{t('drop.title')}</strong>
<span>{t('drop.hint')}</span>
<p className="hint">{t('drop.demoLimit', { px: 2048 })}</p>
</div>
) : (
<div className="canvas-wrap">
<img src={peek && originalUrl ? originalUrl : previewUrl} alt="preview" />
{peek ? <div className="busy">{t('drop.peeking')}</div> : null}
</div>
)}
{previewUrl ? (
<div className="toolbar">
<button type="button" className="btn" onClick={() => inputRef.current?.click()}>
{t('drop.replace')}
</button>
<span className="hint">{t('drop.peekHint')}</span>
</div>
) : null}
{busy ? <div className="busy">{busy}</div> : null}
{error ? <p className="err">{error}</p> : null}
<input
ref={inputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onFile(file);
e.target.value = '';
}}
/>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { MsgKey } from '../i18n/vi';
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 const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
{ id: 'light', label: 'tab.light', glyph: '☀' },
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
];
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {
const { t } = useI18n();
return (
<nav className="rail" aria-label="tool tabs">
{TABS.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={tab === entry.id}
onClick={() => onTab(entry.id)}
>
<span className="glyph" aria-hidden="true">{entry.glyph}</span>
{t(entry.label)}
</button>
))}
</nav>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from 'react';
import { useI18n, LANGS } from '../i18n/I18nProvider';
import { ACCENTS, useTheme } from '../theme/ThemeProvider';
import type { User } from '../api';
// Header: the app's TopBar minus everything camera-only, plus the two controls
// the web build adds — theme (light/dark + accent group) and language.
export function TopBar({
recipeName,
user,
exporting,
canUndo,
onUndo,
onReset,
onExport,
onSave,
onAuth,
onLogout,
}: {
recipeName: string;
user: User | null;
exporting: boolean;
canUndo: boolean;
onUndo: () => void;
onReset: () => void;
onExport: () => void;
onSave: () => void;
onAuth: () => void;
onLogout: () => void;
}) {
const { t, lang, setLang } = useI18n();
const { mode, accent, setMode, setAccent } = useTheme();
const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null);
const wrap = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menu) return;
const away = (e: MouseEvent) => {
if (!wrap.current?.contains(e.target as Node)) setMenu(null);
};
document.addEventListener('mousedown', away);
return () => document.removeEventListener('mousedown', away);
}, [menu]);
return (
<header className="header" ref={wrap}>
<a className="brand" href="/">
RecipesCam <small>web</small>
</a>
<span className="recipe-name">{recipeName}</span>
<span className="spacer" />
<button type="button" className="btn ghost" disabled={!canUndo} onClick={onUndo}>{t('act.undo')}</button>
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
<button type="button" className="btn" onClick={onSave}>{t('save.save')}</button>
<button type="button" className="btn primary" disabled={exporting} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
◐
</button>
{menu === 'theme' ? (
<div className="popover">
<div className="chip-row">
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}>Light</button>
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}>Dark</button>
</div>
<div className="chip-row" style={{ marginTop: 8 }}>
{ACCENTS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${accent === entry.id ? ' on' : ''}`}
onClick={() => setAccent(entry.id)}
>
{entry.label}
</button>
))}
</div>
</div>
) : null}
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
{lang.toUpperCase()}
</button>
{menu === 'lang' ? (
<div className="popover">
{LANGS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${lang === entry.id ? ' on' : ''}`}
onClick={() => {
setLang(entry.id);
setMenu(null);
}}
>
{entry.label}
</button>
))}
</div>
) : null}
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" onClick={() => setMenu(menu === 'account' ? null : 'account')}>
{user ? user.email.split('@')[0] : t('nav.guest')}
</button>
{menu === 'account' ? (
<div className="popover">
{user ? (
<>
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
<button type="button" className="btn" onClick={onLogout}>{t('nav.logout')}</button>
</>
) : (
<button type="button" className="btn primary" onClick={onAuth}>{t('nav.login')}</button>
)}
</div>
) : null}
</div>
</header>
);
}