web: step the creator and QR previews through their tagged stills

The landing's tester preview has had a ‹ › pair for a while; the custom recipe
creator and the QR recipe card still showed one arbitrary frame from whatever
the curator tagged for them. Both now cycle their own slot the same way, from a
random start so the page does not look identical on every load, wrapping at
both ends.

The QR card's payload is not decoration: it is the link of the frame on screen,
so the code under it is redrawn from the same frame the arrows land on. A slot
holding fewer than two stills gets no arrows, since there is nowhere to step.

The tester's arrows move to the shared FrameArrows component, which is what the
two new pairs use — one implementation, three slots.
This commit is contained in:
2026-09-22 17:03:46 +07:00
parent bb51b83399
commit e1f6943f8e
+71 -50
View File
@@ -82,6 +82,28 @@ function Shot({ src, alt, filter }: { src: string; alt: string; filter?: string
);
}
// The ‹ › pair that steps a preview through the stills the curator tagged for
// that slot, wrapping at both ends. A slot with one frame — or none — gets no
// arrows: there is nowhere to step.
function FrameArrows({ count, id, labels, onStep }: {
count: number;
id: string;
labels: { prev: string; next: string };
onStep: (delta: number) => void;
}) {
if (count < 2) return null;
return (
<>
<button type="button" className="lp-arrow lp-arrow-prev" data-key={`${id}-prev`} aria-label={labels.prev} onClick={() => onStep(-1)}>
{'\u2039'}
</button>
<button type="button" className="lp-arrow lp-arrow-next" data-key={`${id}-next`} aria-label={labels.next} onClick={() => onStep(1)}>
{'\u203a'}
</button>
</>
);
}
interface Look {
tag: string;
title: Txt;
@@ -186,6 +208,8 @@ export function Landing() {
const account = useRef<HTMLSpanElement>(null);
const [presetId, setPresetId] = useState(PRESETS[0].id);
const [testerIdx, setTesterIdx] = useState(0);
const [creatorIdx, setCreatorIdx] = useState(0);
const [qrIdx, setQrIdx] = useState(0);
const [temp, setTemp] = useState(0);
const [sat, setSat] = useState(100);
const [con, setCon] = useState(100);
@@ -293,37 +317,38 @@ export function Landing() {
[photos],
);
// The three live sections. 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; a section whose slot holds nothing simply shows no frame. A photo
// the curator put in several sections can turn up in each of them.
// The three live sections, each cycling the stills the curator tagged for its
// slot. Every set is drawn from a random start once per page load, so the
// preview does not change under the visitor's eyes on every render; a slot
// that holds nothing simply shows no frame. A photo the curator put in
// several slots can turn up in each of them.
const slotPhoto = useMemo(() => {
const pick = (slot: PhotoSlot) => {
const list = photos.filter((p) => p.slots.includes(slot));
return list.length ? list[Math.floor(Math.random() * list.length)] : null;
};
const src = (slot: PhotoSlot) => {
const p = pick(slot);
return p ? api.photoUrl(p.id) : null;
};
const urls = (slot: PhotoSlot) => photos.filter((p) => p.slots.includes(slot)).map((p) => api.photoUrl(p.id));
// A plain start index on top of the whole set keeps the frame random per
// load without an effect that would reset it on every re-render.
const start = (n: number) => (n ? Math.floor(Math.random() * n) : 0);
// The QR card's payload is the curated photo's own look, as the link the
// phone app downloads it from.
// The tester cycles every still the curator put in its slot, so a recipe
// can be checked against more than one frame.
const curated = photos.filter((p) => p.slots.includes('tester')).map((p) => api.photoUrl(p.id));
const testers = curated;
const qr = pick('qr');
const qrs = photos.filter((p) => p.slots.includes('qr')).map((p) => ({
url: api.photoUrl(p.id),
link: p.hasPreset ? new URL(api.presetUrl(p.id), window.location.origin).href : `${window.location.origin}/app`,
}));
const testers = urls('tester');
const creators = urls('creator');
return {
testers,
// A plain step count on top of one random start keeps the frame random
// per load without an effect that would reset it on every re-render.
testerStart: Math.floor(Math.random() * testers.length),
creator: src('creator'),
qr: qr ? api.photoUrl(qr.id) : null,
qrLink: qr?.hasPreset ? new URL(api.presetUrl(qr.id), window.location.origin).href : `${window.location.origin}/app`,
testerStart: start(testers.length),
creators,
creatorStart: start(creators.length),
qrs,
qrStart: start(qrs.length),
};
}, [photos]);
// The QR card cycles its stills the way the previews do, but the payload has
// to follow: the card is rendered from the one on screen.
const curQr = slotPhoto.qrs.length ? slotPhoto.qrs[(slotPhoto.qrStart + qrIdx) % slotPhoto.qrs.length] : null;
// The theme popover closes on a click anywhere else, like the workspace's.
useEffect(() => {
if (!themeMenu) return;
@@ -709,28 +734,12 @@ export function Landing() {
filter={preset.filter}
/>
)}
{slotPhoto.testers.length > 1 && (
<>
<button
type="button"
className="lp-arrow lp-arrow-prev"
data-key="lp-tester-prev"
aria-label={c({ en: 'Previous frame', vi: 'Khung trước' })}
onClick={() => setTesterIdx((i) => (i - 1 + slotPhoto.testers.length) % slotPhoto.testers.length)}
>
{'\u2039'}
</button>
<button
type="button"
className="lp-arrow lp-arrow-next"
data-key="lp-tester-next"
aria-label={c({ en: 'Next frame', vi: 'Khung sau' })}
onClick={() => setTesterIdx((i) => (i + 1) % slotPhoto.testers.length)}
>
{'\u203a'}
</button>
</>
)}
<FrameArrows
count={slotPhoto.testers.length}
id="lp-tester"
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
onStep={(d) => setTesterIdx((i) => (i + d + slotPhoto.testers.length) % slotPhoto.testers.length)}
/>
<div className="lp-hud">
<span>f/<b>1.8</b></span>
<span>SS <b>1/500s</b></span>
@@ -767,13 +776,19 @@ export function Landing() {
</div>
<div className="lp-lab">
<div className="lp-view">
{slotPhoto.creator && (
{slotPhoto.creators.length > 0 && (
<Shot
src={slotPhoto.creator}
src={slotPhoto.creators[(slotPhoto.creatorStart + creatorIdx) % slotPhoto.creators.length]}
alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })}
filter={labFilter}
/>
)}
<FrameArrows
count={slotPhoto.creators.length}
id="lp-creator"
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
onStep={(d) => setCreatorIdx((i) => (i + d + slotPhoto.creators.length) % slotPhoto.creators.length)}
/>
<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">
@@ -832,19 +847,25 @@ export function Landing() {
<div className="lp-qr">
<div className="lp-recipe-card">
<div className="lp-shot">
{slotPhoto.qr && (
{curQr && (
<Shot
src={slotPhoto.qr}
src={curQr.url}
alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })}
filter={(PRESETS.find((p) => p.id === 'sunset') ?? PRESETS[0]).filter}
/>
)}
<span className="lp-grain" aria-hidden="true" />
<span className="lp-tagline">#SUNSET_GLOW</span>
<FrameArrows
count={slotPhoto.qrs.length}
id="lp-qr"
labels={{ prev: c({ en: 'Previous frame', vi: 'Khung trước' }), next: c({ en: 'Next frame', vi: 'Khung sau' }) }}
onStep={(d) => setQrIdx((i) => (i + d + slotPhoto.qrs.length) % slotPhoto.qrs.length)}
/>
</div>
<div className="lp-rc-row">
<div className="lp-rc-qr">
<QrCode url={slotPhoto.qrLink} />
<QrCode url={curQr ? curQr.link : `${window.location.origin}/app`} />
</div>
<div className="lp-rc-meta">
<b>SUNSET GLOW</b>