Photo slots + admin page: place any upload in the strip or a live slot, sign up in place, brand links home

This commit is contained in:
2026-09-18 07:55:54 +07:00
parent bd2f08aaf7
commit 7b79e49c20
11 changed files with 320 additions and 77 deletions
+55 -17
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, type Photo, type User } from './api';
import { api, type Photo, type PhotoSlot, type User } from './api';
import { useI18n } from './i18n/I18nProvider';
import { FONT_SETS, useTheme } from './theme/ThemeProvider';
import { AuthModal } from './ui/AuthModal';
// 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`
@@ -112,6 +113,12 @@ export function Landing() {
const [qrOk, setQrOk] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
// Sign up / log in happens in a dialog on this page, not by a trip to the
// studio: the visitor keeps their scroll position and lands back on the reel.
const [auth, setAuth] = useState<'login' | 'signup' | null>(() => {
const asked = new URLSearchParams(window.location.search).get('auth');
return asked === 'login' ? 'login' : asked ? 'signup' : null;
});
const [uploading, setUploading] = useState(false);
const filePick = useRef<HTMLInputElement>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout>>();
@@ -130,15 +137,13 @@ export function Landing() {
// The community half of the reel: whoever is signed in gets a picker, and
// everybody sees the result on the next load. A guest is sent to sign up.
useEffect(() => {
let alive = true;
api.me().then((r) => alive && setUser(r.user)).catch(() => undefined);
api.listPhotos().then((r) => alive && setPhotos(r.photos)).catch(() => undefined);
return () => {
alive = false;
};
const reload = useCallback(() => {
api.me().then((r) => setUser(r.user)).catch(() => undefined);
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
}, []);
useEffect(reload, [reload]);
const contribute = useCallback(async (file: File) => {
setUploading(true);
try {
@@ -157,7 +162,7 @@ export function Landing() {
const reel = useMemo<Look[]>(
() => [
...REEL,
...photos.map((p) => ({
...photos.filter((p) => p.slot === 'strip').map((p) => ({
tag: '#COMMUNITY',
title: { en: 'Community Frame', vi: 'Khung cộng đồng' },
meta: `#${p.id}`,
@@ -168,6 +173,17 @@ export function Landing() {
[photos],
);
// The three live slots. One random photo out of each set, drawn once per page
// load so the preview does not change under the visitor's eyes on every
// render; the built-in sample stands in while a slot has no curation.
const slotPhoto = useMemo(() => {
const pick = (slot: PhotoSlot, fallback: string) => {
const list = photos.filter((p) => p.slot === slot);
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
};
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
}, [photos]);
// The theme popover closes on a click anywhere else, like the workspace's.
useEffect(() => {
if (!themeMenu) return;
@@ -198,7 +214,7 @@ export function Landing() {
{/* 1 — fixed glass navigation */}
<nav className="lp-nav">
<div className="lp-wrap">
<a className="lp-logo" href="#top" onClick={() => setMenu(false)}>
<a className="lp-logo" href="/" onClick={() => setMenu(false)}>
<img className="lp-logo-mark" src="/assets/RecipesCamIcon.png" alt="" width="30" height="30" />
{/* One span, not two siblings: .lp-logo is a flex row, so a bare
text node + <em> would get the 6px gap between them. */}
@@ -268,8 +284,10 @@ export function Landing() {
{lang.toUpperCase()}
</button>
</span>
{/* Register, as the old landing had: straight into the sign-up dialog. */}
<a className="lp-btn lp-sm" href="/app?auth=1">{t('nav.signup')}</a>
{/* Register: the dialog opens here, on the page the visitor is on. */}
<button type="button" className="lp-btn lp-sm" data-key="nav-signup" onClick={() => setAuth('signup')}>
{t('nav.signup')}
</button>
<button type="button" className="lp-btn lp-sm lp-primary" onClick={() => showToast(toastDownload)}>
{c({ en: 'Download RecipesCam', vi: 'Tải RecipesCam' })}
</button>
@@ -281,7 +299,14 @@ export function Landing() {
<div className="lp-sheet">
{nav.map((entry) => <a key={entry.href} href={entry.href} onClick={() => setMenu(false)}>{c(entry.label)}</a>)}
<a href="/app" onClick={() => setMenu(false)}>{c({ en: 'Open studio', vi: 'Mở studio' })}</a>
<a href="/app?auth=1" onClick={() => setMenu(false)}>{t('nav.signup')}</a>
<button
type="button"
className="lp-btn"
data-key="sheet-signup"
onClick={() => { setMenu(false); setAuth('signup'); }}
>
{t('nav.signup')}
</button>
<button
type="button"
className="lp-btn lp-primary"
@@ -337,7 +362,9 @@ export function Landing() {
</button>
) : (
/* A guest gets the same button; it opens the sign-up dialog. */
<a className="lp-btn lp-primary" data-key="contribute" href="/app?auth=1">{t('photo.contribute')}</a>
<button type="button" className="lp-btn lp-primary" data-key="contribute" onClick={() => setAuth('signup')}>
{t('photo.contribute')}
</button>
)}
<input
ref={filePick}
@@ -393,7 +420,7 @@ export function Landing() {
))}
</div>
<div className="lp-view">
<img src={SAMPLE(1043)} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
<img src={slotPhoto.tester} alt={`${preset.name} preview`} style={{ filter: preset.filter }} />
<div className="lp-hud">
<span>f/<b>1.8</b></span>
<span>SS <b>1/500s</b></span>
@@ -430,7 +457,7 @@ export function Landing() {
</div>
<div className="lp-lab">
<div className="lp-view">
<img src={SAMPLE(1015)} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} style={{ filter: labFilter }} />
<img src={slotPhoto.creator} alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })} style={{ filter: labFilter }} />
<div className="lp-hud"><span>{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}</span><span>TEMP <b>{temp > 0 ? `+${temp}` : temp}</b></span><span>SAT <b>{sat}%</b></span><span>CON <b>{con}%</b></span></div>
</div>
<div className="lp-knobs">
@@ -489,7 +516,7 @@ export function Landing() {
<div className="lp-qr">
<div className="lp-recipe-card">
<div className="lp-shot">
<img src={SAMPLE(1069)} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<img src={slotPhoto.qr} alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })} style={{ filter: PRESETS[2].filter }} />
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">#SUNSET_GLOW</span>
</div>
@@ -637,6 +664,17 @@ export function Landing() {
</div>
</footer>
{auth ? (
<AuthModal
initialMode={auth}
onClose={() => setAuth(null)}
onDone={() => {
setAuth(null);
reload();
}}
/>
) : null}
{toast ? <div className="lp-toast" role="status"><b>{c({ en: 'Coming soon —', vi: 'Sắp có —' })}</b> {toast}</div> : null}
</div>
);