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
+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>
);
}