import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { imageURL, shareURL } from './assets.js'; import { detailOf, GENERIC_HEADING } from './detail.js'; /** * Full-screen viewer shared by the interior field and the product catalogue. * * A `photo` is either an interior record ({ id, url, ar, blur, source }) or * a product photo ({ sku, src, images, name, price, specs }). Interior records * carry no `images` field, but keying the gallery off product identity anyway * keeps the two paths unambiguous rather than merely incidental. */ function resolveSrc(photo, width) { if (photo.src) return photo.src; if (typeof photo.url === 'string' && /^https?:\/\//i.test(photo.url)) return photo.url; return imageURL(photo, width); } export default function PhotoViewer({ photo, timeList, onClose }) { const dialog = useRef(null); const frame = useRef(null); const closing = useRef(false); const animations = useRef([]); const zoomOrigin = useRef(null); const [loaded, setLoaded] = useState(false); const [zoomed, setZoomed] = useState(false); // Measured from the decoded image, so a product photo is not letterboxed // against an assumed ratio on first paint. const [measured, setMeasured] = useState(0); const [position, setPosition] = useState(0); // product gallery index const [timePosition, setTimePosition] = useState(0); // time-ordered navigation index /** * A catalogue product, or a photograph? * * Not `Boolean(photo.sku)`: a photograph published on vai-avatar carries a * namespaced `sku` too (`avatarimg:`), which is what lets it share * this viewer and the field with a product. Keying on it announced every such * photograph as "Sản phẩm (avatarimg:...)" and rendered the product price and * spec caption underneath it. `avatarImageId` is present only on records read * from the vai-avatar interior index -- and detail.js derives the flag from the * same field, so the two cannot disagree. */ const isProduct = Boolean(photo.sku) && !photo.avatarImageId; // Only a product has a gallery worth paging through. const gallery = useMemo( () => (isProduct && Array.isArray(photo.images) ? photo.images.filter(Boolean) : null), [isProduct, photo.images], ); /** * Time-ordered list of interiors (newest → oldest) handed down from the * field. When active, the arrows step through photographs by `addedAt`, and * the counter shows both the slot and the date of the current photo. * * If absent, the viewer falls back to the existing behaviour: a single * photograph or a product's own gallery. */ const steppableList = useMemo(() => { if (timeList && Array.isArray(timeList) && timeList.length > 1) { return timeList; } return gallery; }, [timeList, gallery]); /** * The frame actually on screen. * * This has to read `position`, not just `photo`: resolving the URL straight * from the record pins the viewer to the first image, so the arrows would move * the counter while the photograph stayed frozen. */ const src = useMemo( () => { const list = steppableList; const idx = timeList && timeList.length > 1 ? timePosition : position; return (list && list[idx]) ? resolveSrc(list[idx], 2400) : resolveSrc(photo, 2400); }, [steppableList, timePosition, position, photo], ); const preview = steppableList ? resolveSrc(steppableList[timeList && timeList.length > 1 ? timePosition : position], 640) : (photo.previewURL || resolveSrc(photo, 640)); const ar = photo.ar || measured || 4 / 3; const sourceTransform = useCallback(() => { const rect = frame.current.getBoundingClientRect(); const origin = photo.origin; if (!origin || !rect.width || !rect.height) return 'scale(.96)'; return `translate(${origin.x + origin.width / 2 - rect.x - rect.width / 2}px, ${origin.y + origin.height / 2 - rect.y - rect.height / 2}px) scale(${origin.width / rect.width}, ${origin.height / rect.height})`; }, [photo.origin]); // A new frame re-arms the loading fade, the measured ratio and the zoom. This // deliberately does NOT touch `position`: `src` moves while paging, so // resetting the index here would snap every arrow press back to photo 1. useEffect(() => { setLoaded(false); setMeasured(0); setZoomed(false); }, [src]); // Opening a different photo starts that product's gallery from the beginning. useEffect(() => { setPosition(0); setTimePosition(0); }, [photo]); useEffect(() => { const previous = document.activeElement; dialog.current.showModal(); if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { animations.current = [frame.current.animate([ { transform: sourceTransform(), opacity: 0.7 }, { transform: 'none', opacity: 1 }, ], { duration: 560, easing: 'cubic-bezier(.22,1,.36,1)' })]; } return () => { animations.current.forEach(animation => animation.cancel()); if (previous?.isConnected) previous.focus({ preventScroll: true }); }; }, [sourceTransform]); useLayoutEffect(() => { const before = zoomOrigin.current; zoomOrigin.current = null; if (!before || matchMedia('(prefers-reduced-motion: reduce)').matches) return; const after = frame.current.getBoundingClientRect(); animations.current = [frame.current.animate([ { transform: `translate(${before.x + before.width / 2 - after.x - after.width / 2}px, ${before.y + before.height / 2 - after.y - after.height / 2}px) scale(${before.width / after.width}, ${before.height / after.height})` }, { transform: 'none' }, ], { duration: 460, easing: 'cubic-bezier(.22,1,.36,1)' })]; }, [zoomed]); const close = useCallback(async () => { if (closing.current) return; closing.current = true; if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { const from = getComputedStyle(frame.current).transform; animations.current.forEach(animation => animation.cancel()); const animation = frame.current.animate([ { transform: from === 'none' ? 'none' : from, opacity: 1 }, { transform: sourceTransform(), opacity: 0 }, ], { duration: 320, easing: 'cubic-bezier(.4,0,.2,1)', fill: 'forwards' }); animations.current = [animation]; dialog.current.classList.add('is-closing'); await animation.finished.catch(() => {}); } onClose(); }, [onClose, sourceTransform]); /** * Step through the steppable list (product gallery OR time-ordered interiors), * clamped at the ends. * * The clamp lives inside the updater so rapid arrow presses cannot drift the * index past the end and land on an empty frame. */ const step = useCallback(delta => { const total = steppableList?.length ?? 0; if (total < 2) return; const setTime = timeList && timeList.length > 1 ? setTimePosition : setPosition; setTime(current => { const next = current + delta; return next < 0 || next >= total ? current : next; }); }, [steppableList, timeList]); useEffect(() => { if (!steppableList || steppableList.length < 2) return; const onKey = event => { if (event.key === 'ArrowRight') { event.preventDefault(); step(1); } else if (event.key === 'ArrowLeft') { event.preventDefault(); step(-1); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [steppableList, step]); // Escape stays with the dialog's own cancel event; only arrows are added here. const alt = isProduct ? `${photo.name || 'Sản phẩm'}${photo.sku ? ` (${photo.sku})` : ''}` : 'Ảnh nội thất đã phóng to'; /** * A plain label, never a link. * * This used to render "View source ↗" pointed at photo.source. Nothing in the * viewer names or links to the scraped origin any more -- not the button, not * a "Nguồn" row in the fact table. The share link in the toolbar is the single * way out of the app, and the photograph's address is the only URL it needs. */ const caption = {isProduct ? (photo.name || photo.sku) : ((photo.categories || [])[0] || photo.title || 'Interior design')} ; // Paging controls appear only when there is somewhere to go. const stepping = Boolean(steppableList && steppableList.length > 1); const usingTimeList = Boolean(timeList && timeList.length > 1); /** * What the share sheet shows above the URL. * * The record's own name, falling back to the heading the panel uses so the * sheet is never blank. Nothing scraped: a share card titled after daibau.vn * or noithatphuphat.com would carry somebody else's site name into the * reader's own messages, which is the same reason the panel stopped naming * them. */ const shareTitle = (isProduct ? [photo.name, photo.n, photo.sku].find(value => typeof value === 'string' && value.trim()) : [photo.title, detail.heading].find(value => typeof value === 'string' && value.trim()) ) || 'Ảnh nội thất V.AI STUDIO'; const share = shareURL(photo); const specs = isProduct && Array.isArray(photo.specs) ? photo.specs : null; const [shareLabel, setShareLabel] = useState(''); const onShare = useCallback(async ({ url, title, label }) => { const announce = text => { label(text); window.clearTimeout(announce.timer); announce.timer = window.setTimeout(() => label(''), 1800); }; if (navigator.share) { try { await navigator.share({ title, url }); announce('Đã chia sẻ'); } catch { // AbortError here means the sheet was swiped away. Nothing to report. } return; } if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(url); announce('Đã sao chép liên kết'); return; } catch { /* fall through to the legacy path */ } } // Last resort, for a non-secure context or a blocked clipboard: a temporary // input selected and copied synchronously, which still works where the async // API above does not. const field = document.createElement('input'); field.value = url; field.setAttribute('aria-hidden', 'true'); document.body.append(field); field.select(); const copied = document.execCommand?.('copy'); field.remove(); announce(copied ? 'Đã sao chép liên kết' : 'Không chia sẻ được'); }, []); const detail = useMemo(() => detailOf(photo), [photo]); const generic = detail.description.startsWith('Ảnh trong bộ sưu tập'); const hasDetail = Boolean( detail.room || detail.tags.length > 1 || (detail.heading && detail.heading !== GENERIC_HEADING) || (detail.description && !generic), ); const nowIdx = timeList && timeList.length > 1 ? timePosition : position; const currentItem = steppableList ? steppableList[nowIdx] : null; const dateLabel = (currentItem?.addedAt || photo.createdAt || '').slice(0, 10); return { event.preventDefault(); close(); }} onClick={event => { if (event.target === dialog.current) close(); }}>
{share ? <> {/* Two different jobs, two different controls. This button hands the photograph to whatever the reader already uses -- Messages, Zalo, Telegram, the system sheet on iOS and Android. That is the gesture the icon already implied, and the old could not offer it: it opened a browser tab, not the reader's own apps. navigator.share is where the platform's share sheet lives; where it is absent or the user dismisses it, the clipboard takes over so there is always something to paste. A dismissal is not an error -- navigator.share rejects with AbortError every time the sheet is swiped away -- so a refusal is swallowed silently and nothing is announced. */} Xem trang : null} {caption}
{ if (event.target === event.currentTarget) close(); }}> {stepping && nowIdx > 0 && } {stepping && nowIdx < steppableList.length - 1 && }
{isProduct && (photo.price || (specs && specs.length)) ?
{photo.price &&

{photo.price}

} {specs && specs.length > 0 ?
{specs.slice(0, 6).map(([key, value], index) => (
{key}
{value}
))}
: null}
: null} {/* Detail panel, for BOTH kinds of record. Collapsible rather than always-open because the viewer is a photo first: on a phone there is no room for both. Closed by default keeps the first impression the photograph, while the summary line still shows the title and the price.
/ rather than a button plus state: keyboard- and screen-reader-correct for free, and it survives a re-render mid-read. */} {hasDetail ? (
{detail.heading} {photo.price ? {photo.price} : null}
{detail.description ?

{detail.description}

: null} {detail.tags.length > 0 ?
    {detail.tags.map(tag =>
  • {tag}
  • )}
: null} {detail.facts.length > 0 ?
{detail.facts.map(([key, value], index) => (
{key}
{value}
))}
: null} {/* No "Xem bài gốc" link here, and no "Nguồn" row either. The panel answers "what am I looking at", and the share link in the toolbar above already carries the photograph's public address. Naming the scraped origin -- daibau.vn, noithatphuphat.com -- in the table put somebody else's domain in the middle of our own interface, on 1,113 of the 1,222 photographs, for no gain to a reader. */}
) : null} {stepping ? (

{usingTimeList && dateLabel ? {dateLabel} : `${nowIdx + 1} / ${steppableList.length}`}

) : null}
; }