diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 90f447a..698c612 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -120,7 +120,10 @@ export function Workspace() { const [exporting, setExporting] = useState(false); const [user, setUser] = useState(null); const [saved, setSaved] = useState([]); - const [authOpen, setAuthOpen] = useState(false); + // null = closed. The landing's register link is /app?auth=1, so it opens the + // dialog straight on the sign-up tab. + const [authMode, setAuthMode] = useState<'login' | 'signup' | null>(() => + (new URLSearchParams(window.location.search).has('auth') ? 'signup' : null)); const renderToken = useRef(0); @@ -282,7 +285,7 @@ export function Workspace() { const saveRecipe = useCallback(async () => { if (!user) { - setAuthOpen(true); + setAuthMode('login'); return; } const name = window.prompt(t('save.name'), recipe.name); @@ -688,7 +691,7 @@ export function Workspace() { onReset={reset} onExport={exportPhoto} onSave={saveRecipe} - onAuth={() => setAuthOpen(true)} + onAuth={() => setAuthMode('login')} onLogout={async () => { await api.logout().catch(() => undefined); setUser(null); @@ -791,11 +794,12 @@ export function Workspace() { /> - {authOpen ? ( + {authMode ? ( setAuthOpen(false)} + initialMode={authMode} + onClose={() => setAuthMode(null)} onDone={async () => { - setAuthOpen(false); + setAuthMode(null); const me = await api.me().catch(() => null); if (me) setUser(me.user); }} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index fa500c9..b921673 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -1,17 +1,23 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { useI18n } from './i18n/I18nProvider'; +import { useTheme } from './theme/ThemeProvider'; -// Landing page, built to the landing blueprint: dark cinematic 10-section -// funnel. Marketing copy is English (the blueprint's own copy) — the workspace -// keeps its VI/EN dictionary. The store URLs are still unknown (WEB_PLAN.md W6), -// so both badges raise the toast instead of navigating; fill STORE_LINKS in and -// drop the onClick to go live. +// Landing page, built to the landing blueprint: the 10-section funnel. The +// marketing prose is bilingual and lives here next to the markup as `Txt` +// pairs — the app dictionary holds UI strings, this is a page of copy, and the +// blueprint stays the source of truth for it. The page follows the same theme +// and language switches as the workspace (buttons in the nav, top-right). +// The store URLs are still unknown (WEB_PLAN.md W6), so the store badges raise +// the toast instead of navigating; fill STORE_LINKS in and drop the onClick. const STORE_LINKS = { play: '#', appstore: '#' }; +type Txt = { en: string; vi: string }; + const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`; interface Look { tag: string; - title: string; + title: Txt; meta: string; photo: string; filter: string; @@ -21,56 +27,62 @@ interface Look { // emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page // does not need a 12MP render; swap in real graded stills when we have them. const REEL: Look[] = [ - { tag: '#KODAK_PORTRA_400', title: 'Golden Hour Portrait', meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' }, - { tag: '#FUJI_CLASSIC_CHROME', title: 'Quiet Street, 7am', meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' }, - { tag: '#CYBER_NEON_88', title: 'Neon Crossing', meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' }, - { tag: '#CINESTILL_800T', title: 'Harbour Tungsten', meta: 'ISO 800 · GRAIN 40 · WARMTH -14', photo: SAMPLE(1043), filter: 'saturate(1.1) contrast(1.1) hue-rotate(186deg) brightness(.96)' }, - { tag: '#ILFORD_HP5_BW', title: 'Alley Study', meta: 'ISO 400 · GRAIN 55 · MONO', photo: SAMPLE(1050), filter: 'grayscale(1) contrast(1.35) brightness(1.02)' }, - { tag: '#VELVIA_50', title: 'Ridge Line', meta: 'ISO 50 · GRAIN 12 · WARMTH +6', photo: SAMPLE(1069), filter: 'saturate(1.55) contrast(1.22)' }, - { tag: '#ACROS_100', title: 'Stone Facade', meta: 'ISO 100 · GRAIN 30 · MONO', photo: SAMPLE(1025), filter: 'grayscale(1) contrast(1.18) brightness(1.06)' }, - { tag: '#ETRNA_VINTAGE', title: 'Faded Postcard', meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' }, + { tag: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' }, + { tag: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' }, + { tag: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' }, + { tag: '#CINESTILL_800T', title: { en: 'Harbour Tungsten', vi: 'Cảng đèn vàng' }, meta: 'ISO 800 · GRAIN 40 · WARMTH -14', photo: SAMPLE(1043), filter: 'saturate(1.1) contrast(1.1) hue-rotate(186deg) brightness(.96)' }, + { tag: '#ILFORD_HP5_BW', title: { en: 'Alley Study', vi: 'Hẻm nhỏ' }, meta: 'ISO 400 · GRAIN 55 · MONO', photo: SAMPLE(1050), filter: 'grayscale(1) contrast(1.35) brightness(1.02)' }, + { tag: '#VELVIA_50', title: { en: 'Ridge Line', vi: 'Sống núi' }, meta: 'ISO 50 · GRAIN 12 · WARMTH +6', photo: SAMPLE(1069), filter: 'saturate(1.55) contrast(1.22)' }, + { tag: '#ACROS_100', title: { en: 'Stone Facade', vi: 'Mặt tường đá' }, meta: 'ISO 100 · GRAIN 30 · MONO', photo: SAMPLE(1025), filter: 'grayscale(1) contrast(1.18) brightness(1.06)' }, + { tag: '#ETRNA_VINTAGE', title: { en: 'Faded Postcard', vi: 'Bưu thiếp bạc màu' }, meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' }, ]; interface Preset { id: string; + // Stock and recipe names are proper nouns — never translated (see i18n/vi.ts). label: string; name: string; - desc: string; + desc: Txt; filter: string; // warmth, grain, contrast, halation bloom — 0..100 metrics: [number, number, number, number]; } const PRESETS: Preset[] = [ - { id: 'portra', label: 'Kodak Portra 400', name: 'PORTRA 400', desc: 'Warm daylight stock, forgiving highlights and natural skin tones — the everyday portrait recipe.', filter: 'sepia(.18) saturate(1.15) contrast(1.03) brightness(1.05)', metrics: [72, 34, 48, 22] }, - { id: 'chrome', label: 'Fuji Classic Chrome', name: 'CLASSIC CHROME', desc: 'Deep greens, muted reds and a documentary edge straight out of the rangefinder era.', filter: 'saturate(.76) contrast(1.16) hue-rotate(-8deg)', metrics: [38, 22, 68, 14] }, - { id: 'sunset', label: 'Warm Sunset Glow', name: 'SUNSET GLOW', desc: 'Amber backlight with a soft halation around every highlight, made for late golden hour.', filter: 'sepia(.34) saturate(1.35) contrast(1.06) brightness(1.06)', metrics: [88, 28, 52, 61] }, - { id: 'cyber', label: "Cyber Neon '88", name: 'CYBER NEON 88', desc: 'Magenta and cyan push, crushed blacks and a bloom that reads like wet asphalt at night.', filter: 'saturate(1.8) contrast(1.3) hue-rotate(266deg)', metrics: [24, 46, 84, 78] }, - { id: 'mono', label: 'Monochromatic Film', name: 'ACROS 100', desc: 'High-contrast black and white with a fine grain structure and clean mid-tone separation.', filter: 'grayscale(1) contrast(1.4) brightness(1.02)', metrics: [6, 41, 92, 18] }, + { id: 'portra', label: 'Kodak Portra 400', name: 'PORTRA 400', desc: { en: 'Warm daylight stock, forgiving highlights and natural skin tones — the everyday portrait recipe.', vi: 'Phim chân dung ánh sáng ban ngày ấm, vùng sáng mềm và tông da tự nhiên — công thức chụp hằng ngày.' }, filter: 'sepia(.18) saturate(1.15) contrast(1.03) brightness(1.05)', metrics: [72, 34, 48, 22] }, + { id: 'chrome', label: 'Fuji Classic Chrome', name: 'CLASSIC CHROME', desc: { en: 'Deep greens, muted reds and a documentary edge straight out of the rangefinder era.', vi: 'Xanh lá sâu, đỏ trầm và chất tài liệu đúng kiểu máy rangefinder.' }, filter: 'saturate(.76) contrast(1.16) hue-rotate(-8deg)', metrics: [38, 22, 68, 14] }, + { id: 'sunset', label: 'Warm Sunset Glow', name: 'SUNSET GLOW', desc: { en: 'Amber backlight with a soft halation around every highlight, made for late golden hour.', vi: 'Ngược sáng hổ phách với quầng sáng mềm quanh vùng cháy, dành cho giờ vàng muộn.' }, filter: 'sepia(.34) saturate(1.35) contrast(1.06) brightness(1.06)', metrics: [88, 28, 52, 61] }, + { id: 'cyber', label: "Cyber Neon '88", name: 'CYBER NEON 88', desc: { en: 'Magenta and cyan push, crushed blacks and a bloom that reads like wet asphalt at night.', vi: 'Đẩy magenta và cyan, đen bị nén và quầng sáng như nhựa đường ướt ban đêm.' }, filter: 'saturate(1.8) contrast(1.3) hue-rotate(266deg)', metrics: [24, 46, 84, 78] }, + { id: 'mono', label: 'Monochromatic Film', name: 'ACROS 100', desc: { en: 'High-contrast black and white with a fine grain structure and clean mid-tone separation.', vi: 'Đen trắng tương phản cao, hạt mịn và vùng trung tính tách bạch.' }, filter: 'grayscale(1) contrast(1.4) brightness(1.02)', metrics: [6, 41, 92, 18] }, ]; -const BAR_LABELS = ['WARMTH', 'FILM GRAIN', 'CONTRAST', 'HALATION BLOOM']; +const BAR_LABELS: Txt[] = [ + { en: 'WARMTH', vi: 'ĐỘ ẤM' }, + { en: 'FILM GRAIN', vi: 'HẠT PHIM' }, + { en: 'CONTRAST', vi: 'TƯƠNG PHẢN' }, + { en: 'HALATION BLOOM', vi: 'QUẦNG SÁNG' }, +]; -interface Feature { title: string; text: string; icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw' } +interface Feature { icon: 'aperture' | 'grain' | 'batch' | 'qr' | 'hsl' | 'raw'; title: Txt; text: Txt } const FEATURES: Feature[] = [ - { icon: 'aperture', title: 'Live LUT Viewfinder', text: 'See the recipe through the lens before you press the shutter — no round trip to an editor.' }, - { icon: 'grain', title: '35mm & 120 Film Grain', text: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.' }, - { icon: 'batch', title: 'Batch Color Editing', text: 'Apply one recipe across hundreds of photos at once and export the whole set in a single pass.' }, - { icon: 'qr', title: 'QR Recipe Sharing', text: 'Export a recipe as a scannable card and let a friend import the exact look in one scan.' }, - { icon: 'hsl', title: 'HSL & Selective Color', text: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.' }, - { icon: 'raw', title: 'RAW Support & 4K Export', text: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.' }, + { icon: 'aperture', title: { en: 'Live LUT Viewfinder', vi: 'Ngắm LUT trực tiếp' }, text: { en: 'See the recipe through the lens before you press the shutter — no round trip to an editor.', vi: 'Xem công thức ngay trong ống kính trước khi bấm máy — không cần vòng qua trình chỉnh sửa.' } }, + { icon: 'grain', title: { en: '35mm & 120 Film Grain', vi: 'Hạt phim 35mm & 120' }, text: { en: 'Authentic grain structures plus halation bloom, tuned per stock rather than one global overlay.', vi: 'Cấu trúc hạt đúng chất phim kèm quầng sáng, tinh chỉnh theo từng loại phim thay vì một lớp phủ chung.' } }, + { icon: 'batch', title: { en: 'Batch Color Editing', vi: 'Chỉnh màu hàng loạt' }, text: { en: 'Apply one recipe across hundreds of photos at once and export the whole set in a single pass.', vi: 'Áp một công thức cho hàng trăm ảnh cùng lúc và xuất cả bộ trong một lượt.' } }, + { icon: 'qr', title: { en: 'QR Recipe Sharing', vi: 'Chia sẻ công thức bằng QR' }, text: { en: 'Export a recipe as a scannable card and let a friend import the exact look in one scan.', vi: 'Xuất công thức thành thẻ quét được để bạn bè nhập đúng tông màu chỉ bằng một lần quét.' } }, + { icon: 'hsl', title: { en: 'HSL & Selective Color', vi: 'HSL & màu chọn lọc' }, text: { en: 'Fine-tune eight individual colour channels when a stock needs a nudge, not a replacement.', vi: 'Tinh chỉnh tám kênh màu riêng khi loại phim chỉ cần nhích một chút, không phải thay hẳn.' } }, + { icon: 'raw', title: { en: 'RAW Support & 4K Export', vi: 'Hỗ trợ RAW & xuất 4K' }, text: { en: 'Full EXIF retention, 300 ppi print metadata and lossless JPEG at up to 4K.', vi: 'Giữ nguyên EXIF, metadata in 300 ppi và JPEG không mất dữ liệu tới 4K.' } }, ]; const QUOTES = [ - { text: 'I grade a whole trip before the flight lands. The QR hand-off means my editor gets the exact same look, not a screenshot.', name: 'Mai Anh', role: '@maianh.travel · Travel Blogger' }, - { text: 'Classic Chrome with a whisper of grain is 90% of my feed now. It reads like film without the scanning workflow.', name: 'Daniel Rowe', role: '@rowe.frames · Street Photographer' }, - { text: 'The live viewfinder is the whole trick — clients pick a recipe on set instead of asking for fifteen revisions later.', name: 'Trúc Lâm', role: '@truclam.studio · Content Creator' }, + { text: { en: 'I grade a whole trip before the flight lands. The QR hand-off means my editor gets the exact same look, not a screenshot.', vi: 'Tôi chỉnh màu cả chuyến đi trước khi máy bay hạ cánh. Chia sẻ bằng QR nên editor nhận đúng tông màu, không phải ảnh chụp màn hình.' }, name: 'Mai Anh', role: { en: '@maianh.travel · Travel Blogger', vi: '@maianh.travel · Blogger du lịch' } }, + { text: { en: 'Classic Chrome with a whisper of grain is 90% of my feed now. It reads like film without the scanning workflow.', vi: 'Classic Chrome thêm một chút hạt phim giờ chiếm 90% feed của tôi. Nhìn ra chất phim mà không phải scan.' }, name: 'Daniel Rowe', role: { en: '@rowe.frames · Street Photographer', vi: '@rowe.frames · Nhiếp ảnh đường phố' } }, + { text: { en: 'The live viewfinder is the whole trick — clients pick a recipe on set instead of asking for fifteen revisions later.', vi: 'Ngắm trực tiếp là điểm ăn tiền — khách chọn công thức ngay tại chỗ thay vì đòi sửa mười lăm lần sau đó.' }, name: 'Trúc Lâm', role: { en: '@truclam.studio · Content Creator', vi: '@truclam.studio · Nhà sáng tạo nội dung' } }, ]; const FAQ = [ - { q: 'Does it preserve original image quality?', a: 'Yes. The web studio renders from your untouched original at export time, writes 300 ppi JFIF density and keeps the camera EXIF and GPS tags. RAW and 4K export are covered on the Pro plan.' }, - { q: 'How do I scan a recipe QR code?', a: 'Open the Scan QR button inside the app and point it at the card. The recipe imports with its full parameter set — no manual re-typing.' }, - { q: 'Is there an export watermark?', a: 'No watermarks on your own exports. Only the free guest tier on the web adds a small corner mark; sign in and it disappears.' }, + { q: { en: 'Does it preserve original image quality?', vi: 'Ảnh gốc có bị giảm chất lượng không?' }, a: { en: 'Yes. The web studio renders from your untouched original at export time, writes 300 ppi JFIF density and keeps the camera EXIF and GPS tags. RAW and 4K export are covered on the Pro plan.', vi: 'Không. Web studio render từ ảnh gốc chưa chỉnh khi xuất, ghi mật độ JFIF 300 ppi và giữ nguyên EXIF cùng dấu GPS của máy ảnh. Xuất RAW và 4K nằm trong gói Pro.' } }, + { q: { en: 'How do I scan a recipe QR code?', vi: 'Quét mã QR công thức thế nào?' }, a: { en: 'Open the Scan QR button inside the app and point it at the card. The recipe imports with its full parameter set — no manual re-typing.', vi: 'Mở nút Quét QR trong app rồi chĩa vào thẻ. Công thức được nhập kèm toàn bộ thông số — không phải gõ lại tay.' } }, + { q: { en: 'Is there an export watermark?', vi: 'Ảnh xuất có watermark không?' }, a: { en: 'No watermarks on your own exports. Only the free guest tier on the web adds a small corner mark; sign in and it disappears.', vi: 'Ảnh bạn xuất không có watermark. Chỉ khách chưa đăng nhập trên web mới có một vết nhỏ ở góc; đăng nhập là mất.' } }, ]; function Icon({ name }: { name: Feature['icon'] }) { @@ -86,6 +98,8 @@ function Icon({ name }: { name: Feature['icon'] }) { } export function Landing() { + const { t, lang, setLang } = useI18n(); + const { mode, setMode } = useTheme(); const [menu, setMenu] = useState(false); const [presetId, setPresetId] = useState(PRESETS[0].id); const [temp, setTemp] = useState(0); @@ -95,29 +109,35 @@ export function Landing() { const [qrOk, setQrOk] = useState(true); const toastTimer = useRef>(); + // Marketing copy, picked by the workspace's language switch. + const c = useCallback((v: Txt) => v[lang], [lang]); + const preset = PRESETS.find((p) => p.id === presetId) ?? PRESETS[0]; - const showToast = useCallback((text: string) => { - setToast(text); + const showToast = useCallback((text: Txt) => { + setToast(c(text)); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 3400); - }, []); + }, [c]); useEffect(() => () => clearTimeout(toastTimer.current), []); // The simulator's output: three knobs, one CSS filter string, applied live. const labFilter = `sepia(${(Math.abs(temp) / 260).toFixed(3)}) hue-rotate(${(-temp * 0.45).toFixed(1)}deg) saturate(${sat}%) contrast(${con}%)`; - const nav = [ - ['#film', 'Film Strip'], - ['#presets', 'Try Recipes'], - ['#creator', 'Custom Recipe Creator'], - ['#features', 'Features'], - ['#qr', 'QR Code'], - ['#pricing', 'Pricing'], - ] as const; + const nav: { href: string; label: Txt }[] = [ + { href: '#film', label: { en: 'Film Strip', vi: 'Dải phim' } }, + { href: '#presets', label: { en: 'Try Recipes', vi: 'Thử công thức' } }, + { href: '#creator', label: { en: 'Custom Recipe Creator', vi: 'Tạo công thức riêng' } }, + { href: '#features', label: { en: 'Features', vi: 'Tính năng' } }, + { href: '#qr', label: { en: 'QR Code', vi: 'Mã QR' } }, + { href: '#pricing', label: { en: 'Pricing', vi: 'Bảng giá' } }, + ]; + + const toastDownload: Txt = { en: 'Download links land with the public release — the web studio is live now.', vi: 'Link tải sẽ có khi phát hành chính thức — web studio đã chạy được ngay.' }; + const toastStore: Txt = { en: 'Store listing arrives with the public release — use the web studio in the meantime.', vi: 'Cửa hàng ứng dụng sẽ mở khi phát hành chính thức — trong lúc chờ, hãy dùng web studio.' }; return ( -
+
{/* 1 — fixed glass navigation */}