From e1f6943f8ed10259e99e254a43ef78a46e7f35bc Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 17:03:46 +0700 Subject: [PATCH] web: step the creator and QR previews through their tagged stills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/src/Landing.tsx | 121 +++++++++++++++++++------------- 1 file changed, 71 insertions(+), 50 deletions(-) diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index bc20244..05a8a0c 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -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 ( + <> + + + + ); +} + interface Look { tag: string; title: Txt; @@ -186,6 +208,8 @@ export function Landing() { const account = useRef(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 && ( - <> - - - - )} + setTesterIdx((i) => (i + d + slotPhoto.testers.length) % slotPhoto.testers.length)} + />
f/1.8 SS 1/500s @@ -767,13 +776,19 @@ export function Landing() {
- {slotPhoto.creator && ( + {slotPhoto.creators.length > 0 && ( )} + setCreatorIdx((i) => (i + d + slotPhoto.creators.length) % slotPhoto.creators.length)} + />
{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}TEMP {temp > 0 ? `+${temp}` : temp}SAT {sat}%CON {con}%
@@ -832,19 +847,25 @@ export function Landing() {
- {slotPhoto.qr && ( + {curQr && ( p.id === 'sunset') ?? PRESETS[0]).filter} /> )}
- +
SUNSET GLOW