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,64 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { vi, type Dict, type MsgKey } from './vi';
|
||||
import { en } from './en';
|
||||
|
||||
// Two dictionaries, no i18n library: the whole set is a few KB of text and the
|
||||
// `Dict` type makes a missing English key a build error. Reach for a library
|
||||
// (or lazy-loading) when the dictionaries outgrow the bundle — not before.
|
||||
|
||||
export type Lang = 'vi' | 'en';
|
||||
|
||||
const DICTS: Record<Lang, Dict> = { vi, en };
|
||||
const KEY = 'rc.lang';
|
||||
|
||||
function readLang(): Lang {
|
||||
const stored = localStorage.getItem(KEY);
|
||||
if (stored === 'vi' || stored === 'en') return stored;
|
||||
return (navigator.language || '').toLowerCase().startsWith('vi') ? 'vi' : 'en';
|
||||
}
|
||||
|
||||
interface I18nValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: MsgKey, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const Ctx = createContext<I18nValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(readLang);
|
||||
|
||||
const setLang = useCallback((next: Lang) => {
|
||||
localStorage.setItem(KEY, next);
|
||||
setLangState(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
}, [lang]);
|
||||
|
||||
const value = useMemo<I18nValue>(() => ({
|
||||
lang,
|
||||
setLang,
|
||||
t: (key, params) => {
|
||||
let text: string = DICTS[lang][key] ?? vi[key] ?? key;
|
||||
if (params) {
|
||||
for (const [name, v] of Object.entries(params)) text = text.replace(`{${name}}`, String(v));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}), [lang, setLang]);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nValue {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) throw new Error('useI18n used outside I18nProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
export const LANGS: { id: Lang; label: string }[] = [
|
||||
{ id: 'vi', label: vi['lang.name'] },
|
||||
{ id: 'en', label: en['lang.name'] },
|
||||
];
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Dict } from './vi';
|
||||
|
||||
export const en: Dict = {
|
||||
'lang.name': 'English',
|
||||
|
||||
'nav.demo': 'TRY DEMO',
|
||||
'nav.login': 'LOG IN',
|
||||
'nav.signup': 'SIGN UP',
|
||||
'nav.logout': 'Log out',
|
||||
'nav.account': 'Account',
|
||||
'nav.theme': 'Theme',
|
||||
'nav.language': 'Language',
|
||||
'nav.back': 'Back to home',
|
||||
'nav.guest': 'Guest',
|
||||
|
||||
'act.undo': 'UNDO',
|
||||
'act.reset': 'RESET',
|
||||
'act.export': 'EXPORT',
|
||||
'act.exporting': 'Exporting…',
|
||||
'act.cancel': 'Cancel',
|
||||
|
||||
'land.title': 'The same colour recipes, now on your desktop',
|
||||
'land.lede':
|
||||
'RecipesCam turns a photo into a film-looking one with recipes. The web build runs the app\u2019s exact colour pipeline: same recipes, same frames, same GPS stamp.',
|
||||
'land.ctaDemo': 'OPEN THE DEMO',
|
||||
'land.ctaSignup': 'CREATE AN ACCOUNT',
|
||||
'land.f1t': 'Film recipes',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… plus EXPOSURE, WB, CLARITY, HDF and VIGNETTING.',
|
||||
'land.f2t': 'Frames & watermark',
|
||||
'land.f2d': 'Polaroid, classic border, WALL FRAME, custom text and a GPS stamp read from the photo\u2019s EXIF.',
|
||||
'land.f3t': 'Processed on your own machine',
|
||||
'land.f3d': 'The photo never leaves the browser. The server only keeps accounts and the recipes you save.',
|
||||
'land.badgePlay': 'Get it on Google Play',
|
||||
'land.badgeApp': 'Download on the App Store',
|
||||
'land.storeSoon': 'Coming soon',
|
||||
|
||||
'drop.title': 'Drag & drop a photo here',
|
||||
'drop.hint': 'or click to pick one · paste from the clipboard with Ctrl+V',
|
||||
'drop.replace': 'Choose another photo',
|
||||
'drop.peeking': 'SHOWING THE ORIGINAL',
|
||||
'drop.peekHint': 'Hold right-click or the \\ key to compare with the original',
|
||||
'drop.demoLimit': 'Demo build: exports are capped at {px}px and carry a watermark.',
|
||||
|
||||
'tab.presets': 'PRESETS',
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
'sec.saved': 'SAVED',
|
||||
'sec.savedEmpty': 'No saved recipes yet. Log in and hit SAVE to sync them.',
|
||||
'sec.frame': 'FRAME',
|
||||
'sec.crop': 'CROP',
|
||||
'sec.rotate': 'ROTATE',
|
||||
'sec.watermark': 'WATERMARK',
|
||||
'sec.gps': 'GPS',
|
||||
|
||||
'wb.temp': 'COLOR TEMP',
|
||||
|
||||
'frame.none': 'NO FRAME',
|
||||
'frame.polaroid': 'RETRO INSTANT',
|
||||
'frame.classic': 'CLASSIC BORDER',
|
||||
'frame.cinematic': 'CINEMATIC',
|
||||
'frame.wall': 'WALL FRAME',
|
||||
'frame.wallLand': 'WALL LANDSCAPE',
|
||||
|
||||
'crop.none': 'NO CROP',
|
||||
'crop.1:1': '1:1',
|
||||
'crop.2:3': '2:3',
|
||||
'crop.3:2': '3:2',
|
||||
'crop.3:4': '3:4',
|
||||
'crop.4:3': '4:3',
|
||||
'crop.16:9': '16:9',
|
||||
'crop.free': 'FREE',
|
||||
|
||||
'rot.0': '0°',
|
||||
'rot.90': '90°',
|
||||
'rot.180': '180°',
|
||||
'rot.270': '270°',
|
||||
|
||||
'wm.text': 'TEXT',
|
||||
'wm.color': 'COLOR',
|
||||
'wm.size': 'SIZE',
|
||||
'wm.gps': 'GPS STAMP',
|
||||
'wm.place': 'PLACE NAME',
|
||||
'wm.placePh': 'e.g. HANOI',
|
||||
'wm.manual': 'ENTER COORDINATES',
|
||||
'wm.lat': 'Latitude',
|
||||
'wm.lng': 'Longitude',
|
||||
'wm.hasExif': 'GPS read from the photo.',
|
||||
'wm.noExif': 'This photo has no GPS — type it in if you want the stamp.',
|
||||
|
||||
'auth.loginTitle': 'Log in',
|
||||
'auth.signupTitle': 'Create an account',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Password',
|
||||
'auth.passwordHint': 'At least 8 characters',
|
||||
'auth.noAccount': 'No account yet? Sign up',
|
||||
'auth.haveAccount': 'Already have an account? Log in',
|
||||
'auth.continueDemo': 'Keep using the demo',
|
||||
'auth.busy': 'Working…',
|
||||
'auth.loggedInAs': 'Signed in as {email}',
|
||||
|
||||
'save.title': 'Save recipe',
|
||||
'save.name': 'Recipe name',
|
||||
'save.save': 'SAVE',
|
||||
'save.saved': 'Saved "{name}"',
|
||||
'save.needLogin': 'Log in to save a recipe.',
|
||||
|
||||
'err.noWebgl':
|
||||
'The browser refused a WebGL context — this build needs WebGL to process photos (the CPU path is thousands of times slower).',
|
||||
'err.decode': 'Could not read this image. Try another JPG or PNG.',
|
||||
'err.generic': 'Something went wrong: {msg}',
|
||||
'err.offline': 'Could not reach the server.',
|
||||
|
||||
'meta.ready': 'Ready',
|
||||
'meta.rendered': 'Rendered in {ms}ms · {w}×{h}',
|
||||
'meta.exported': 'Downloaded {name} ({kb}KB, 300 ppi)',
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -0,0 +1,131 @@
|
||||
// Vietnamese dictionary — the source of truth for key names (`en.ts` is typed
|
||||
// as `Dict`, so a missing or misspelled key there is a typecheck error).
|
||||
// Not translated anywhere, in any language: film-sim names, user recipe names,
|
||||
// user watermark text — they are proper nouns, see WEB_PLAN.md.
|
||||
|
||||
export const vi = {
|
||||
'lang.name': 'Tiếng Việt',
|
||||
|
||||
'nav.demo': 'DÙNG THỬ',
|
||||
'nav.login': 'ĐĂNG NHẬP',
|
||||
'nav.signup': 'ĐĂNG KÝ',
|
||||
'nav.logout': 'Đăng xuất',
|
||||
'nav.account': 'Tài khoản',
|
||||
'nav.theme': 'Giao diện',
|
||||
'nav.language': 'Ngôn ngữ',
|
||||
'nav.back': 'Về trang chủ',
|
||||
'nav.guest': 'Khách',
|
||||
|
||||
'act.undo': 'HOÀN TÁC',
|
||||
'act.reset': 'ĐẶT LẠI',
|
||||
'act.export': 'XUẤT ẢNH',
|
||||
'act.exporting': 'Đang xuất…',
|
||||
'act.cancel': 'Huỷ',
|
||||
|
||||
'land.title': 'Cùng công thức màu, giờ trên máy tính',
|
||||
'land.lede':
|
||||
'RecipesCam biến ảnh chụp thành ảnh có "chất" bằng công thức phim. Bản web chạy đúng pipeline màu của app: cùng công thức, cùng khung, cùng dấu GPS.',
|
||||
'land.ctaDemo': 'DÙNG THỬ NGAY',
|
||||
'land.ctaSignup': 'TẠO TÀI KHOẢN',
|
||||
'land.f1t': 'Công thức phim',
|
||||
'land.f1d': 'PROVIA, VELVIA, CLASSIC CHROME… cộng bộ chỉnh tay EXPOSURE, WB, CLARITY, HDF, VIGNETTING.',
|
||||
'land.f2t': 'Khung & watermark',
|
||||
'land.f2d': 'Khung polaroid, viền classic, WALL FRAME, watermark chữ và dấu GPS đọc từ EXIF của ảnh.',
|
||||
'land.f3t': 'Xử lý ngay trên máy bạn',
|
||||
'land.f3d': 'Ảnh không bao giờ rời khỏi trình duyệt. Máy chủ chỉ giữ tài khoản và công thức bạn lưu.',
|
||||
'land.badgePlay': 'Tải trên Google Play',
|
||||
'land.badgeApp': 'Tải trên App Store',
|
||||
'land.storeSoon': 'Sắp có',
|
||||
|
||||
'drop.title': 'Kéo & thả ảnh vào đây',
|
||||
'drop.hint': 'hoặc bấm để chọn ảnh · dán từ clipboard bằng Ctrl+V',
|
||||
'drop.replace': 'Đổi ảnh khác',
|
||||
'drop.peeking': 'ĐANG XEM ẢNH GỐC',
|
||||
'drop.peekHint': 'Giữ chuột phải hoặc phím \\ để so ảnh gốc',
|
||||
'drop.demoLimit': 'Bản demo: ảnh xuất ra tối đa {px}px và có watermark.',
|
||||
|
||||
'tab.presets': 'PRESETS',
|
||||
'tab.favorited': 'FAVORITED',
|
||||
'tab.light': 'LIGHT',
|
||||
'tab.wb': 'WB',
|
||||
'tab.fx': 'FX',
|
||||
'tab.frame': 'FRAME',
|
||||
|
||||
'sec.filmSims': 'PHOTO STYLE',
|
||||
'sec.recipes': 'RECIPES',
|
||||
'sec.saved': 'ĐÃ LƯU',
|
||||
'sec.savedEmpty': 'Chưa lưu công thức nào. Đăng nhập rồi bấm LƯU để đồng bộ.',
|
||||
'sec.frame': 'KHUNG',
|
||||
'sec.crop': 'CẮT',
|
||||
'sec.rotate': 'XOAY',
|
||||
'sec.watermark': 'WATERMARK',
|
||||
'sec.gps': 'GPS',
|
||||
|
||||
'wb.temp': 'COLOR TEMP',
|
||||
|
||||
'frame.none': 'KHÔNG KHUNG',
|
||||
'frame.polaroid': 'RETRO INSTANT',
|
||||
'frame.classic': 'VIỀN CLASSIC',
|
||||
'frame.cinematic': 'ĐIỆN ẢNH',
|
||||
'frame.wall': 'WALL FRAME',
|
||||
'frame.wallLand': 'WALL LANDSCAPE',
|
||||
|
||||
'crop.none': 'KHÔNG CẮT',
|
||||
'crop.1:1': '1:1',
|
||||
'crop.2:3': '2:3',
|
||||
'crop.3:2': '3:2',
|
||||
'crop.3:4': '3:4',
|
||||
'crop.4:3': '4:3',
|
||||
'crop.16:9': '16:9',
|
||||
'crop.free': 'TỰ DO',
|
||||
|
||||
'rot.0': '0°',
|
||||
'rot.90': '90°',
|
||||
'rot.180': '180°',
|
||||
'rot.270': '270°',
|
||||
|
||||
'wm.text': 'CHỮ',
|
||||
'wm.color': 'MÀU',
|
||||
'wm.size': 'CỠ',
|
||||
'wm.gps': 'DẤU GPS',
|
||||
'wm.place': 'TÊN ĐỊA ĐIỂM',
|
||||
'wm.placePh': 'VD: HÀ NỘI',
|
||||
'wm.manual': 'NHẬP TOẠ ĐỘ',
|
||||
'wm.lat': 'Vĩ độ',
|
||||
'wm.lng': 'Kinh độ',
|
||||
'wm.hasExif': 'Đã đọc GPS từ ảnh.',
|
||||
'wm.noExif': 'Ảnh không có GPS — nhập tay nếu muốn đóng dấu.',
|
||||
|
||||
'auth.loginTitle': 'Đăng nhập',
|
||||
'auth.signupTitle': 'Tạo tài khoản',
|
||||
'auth.email': 'Email',
|
||||
'auth.password': 'Mật khẩu',
|
||||
'auth.passwordHint': 'Ít nhất 8 ký tự',
|
||||
'auth.noAccount': 'Chưa có tài khoản? Đăng ký',
|
||||
'auth.haveAccount': 'Đã có tài khoản? Đăng nhập',
|
||||
'auth.continueDemo': 'Tiếp tục dùng thử',
|
||||
'auth.busy': 'Đang xử lý…',
|
||||
'auth.loggedInAs': 'Đã đăng nhập: {email}',
|
||||
|
||||
'save.title': 'Lưu công thức',
|
||||
'save.name': 'Tên công thức',
|
||||
'save.save': 'LƯU',
|
||||
'save.saved': 'Đã lưu "{name}"',
|
||||
'save.needLogin': 'Đăng nhập để lưu công thức.',
|
||||
|
||||
'err.noWebgl':
|
||||
'Trình duyệt không cấp được WebGL — bản dựng này cần WebGL để xử lý ảnh (chạy CPU chậm gấp hàng nghìn lần).',
|
||||
'err.decode': 'Không đọc được ảnh này. Thử JPG hoặc PNG khác.',
|
||||
'err.generic': 'Có lỗi xảy ra: {msg}',
|
||||
'err.offline': 'Không gọi được máy chủ.',
|
||||
|
||||
'meta.ready': 'Sẵn sàng',
|
||||
'meta.rendered': 'Kết xuất {ms}ms · {w}×{h}',
|
||||
'meta.exported': 'Đã tải {name} ({kb}KB, 300 ppi)',
|
||||
} as const;
|
||||
|
||||
// `vi` is `as const` so the key set is exact; `Dict` widens the values to
|
||||
// string, which is what lets `en.ts` hold different text under the same keys
|
||||
// while a missing or misspelled key stays a typecheck error.
|
||||
export type Dict = { [K in keyof typeof vi]: string };
|
||||
export type MsgKey = keyof Dict;
|
||||
Reference in New Issue
Block a user