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