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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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