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