// Gallery — three-column editorial viewer, replicated from the Embassy Citadel // gallery layout (v4/gallery.html) and re-skinned in The Universe's ivory/gold // brand on this app's React/Babel stack. The client rejected the prior 3D fanned // card deck — this is our standard project gallery layout. // // REFERENCE LAYOUT (Embassy Citadel · v4/gallery.html — the stronger of the two // references; Embassy One shares the same anatomy but Citadel maps 1:1 to our // data and matches our just-rebuilt amenities screen): // · LEFT editorial rail — eyebrow + big serif "Gallery" + tagline + rule, then // a CATEGORY list (All + 4 cats) where each row is icon-free label · NN count // and one row is active (gold tint). // · CENTER hero plate — one large lead image (gold-hairline inner frame, a // Citadel × Embassy-One detail), soft caption gradient with tag + title, a // "View full screen" maximize chip, and a floating prev / NN—NN / next pill. // A tablet-safe DOUBLE-BUFFERED crossfade swaps the lead image with no // undecoded/blank frame revealed. // · RIGHT thumbs rail — a vertical THUMBNAIL stack of every image in the // active category; active thumb carries a gold ring; scrolls when long. // // LIGHTBOX (full-screen, lives over the whole app like amenities does not need): // contain image + caption + prev/next + close + counter, ESC / ← / → keys, // pointer-swipe, and the same double-buffered crossfade. PANORAMA items // (src contains '-pano', 2:1 wide renders) are LETTERBOXED and made // horizontally PANNABLE — drag scrubs across the wide render with a hint. // // Data: GALLERY_ITEMS / GALLERY_CATS (window globals, data.jsx). Captions are // CURATED (item.tag / item.title) — never auto-generated. Ends with // window.Gallery = Gallery. const GALLERY_KEYS_ID = 'uni-gallery-keys'; function ensureGalleryKeys() { if (typeof document === 'undefined') return; if (document.getElementById(GALLERY_KEYS_ID)) return; const s = document.createElement('style'); s.id = GALLERY_KEYS_ID; s.textContent = ` @keyframes galHeroIn { 0%{opacity:0; transform:scale(1.05)} 100%{opacity:1; transform:scale(1.02)} } @keyframes galThumbIn { 0%{opacity:0; transform:translateX(14px)} 100%{opacity:1; transform:translateX(0)} } @keyframes uniPanoHint { 0%,100%{transform:translateX(0)} 50%{transform:translateX(7px)} } .gal-stack::-webkit-scrollbar { width: 8px; } .gal-stack::-webkit-scrollbar-track { background: transparent; } .gal-stack::-webkit-scrollbar-thumb { background: rgba(176,138,63,0.30); border-radius: 4px; } .gal-stack::-webkit-scrollbar-thumb:hover { background: rgba(176,138,63,0.55); } /* Double-buffered crossfade layer (hero + lightbox) */ .uni-xf-img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; opacity:0; transition: opacity 280ms ease-out; transform: translateZ(0); -webkit-backface-visibility:hidden; backface-visibility:hidden; will-change: opacity; } .uni-xf-img.is-active { opacity:1; } .uni-lb-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; display:block; opacity:0; transition: opacity 280ms ease-out; transform: translateZ(0); -webkit-backface-visibility:hidden; backface-visibility:hidden; will-change: opacity; } .uni-lb-img.is-active { opacity:1; } `; document.head.appendChild(s); } // Panorama detector — 2:1 wide renders are flagged by '-pano' in the src. const isPano = (it) => !!it && typeof it.src === 'string' && it.src.indexOf('-pano') !== -1; const pad2 = (n) => String(n).padStart(2, '0'); // ============================================================================ // XfadeImage — double-buffered crossfade. Two stacked layers (A/B); on // src change the NEXT image is decoded on the hidden buffer, then opacity- // crossfaded in over the old one. No src swap on a visible layer → never reveals // an undecoded/blank frame, which is the root cause of swap flicker on tablets. // `cls` = 'uni-xf-img' (cover, hero) or 'uni-lb-img' (contain, lightbox). // `styleFor(isFront)` lets the lightbox inject panorama pan styles per buffer. // ============================================================================ function XfadeImage({ src, alt, cls, styleFor }) { const [srcA, setSrcA] = React.useState(src); const [srcB, setSrcB] = React.useState(''); const [front, setFront] = React.useState(0); // 0 = A shown, 1 = B shown const firstRef = React.useRef(true); React.useEffect(() => { if (firstRef.current) { firstRef.current = false; return; } const showA = front === 1; // fade INTO A if B is front const url = src; const img = new Image(); const commit = () => { if (showA) { setSrcA(url); setFront(0); } else { setSrcB(url); setFront(1); } }; img.onload = commit; img.onerror = commit; img.src = url; if (img.complete && img.naturalWidth) commit(); }, [src]); const sf = styleFor || (() => undefined); return ( {alt { e.currentTarget.style.opacity = 0; }} /> {srcB && {alt { e.currentTarget.style.opacity = 0; }} />} ); } // ============================================================================ // CategoryRow — left-rail entry: label · NN count, active = gold tint. // (mirrors amenities CategoryRow, sans the per-id self-drawing icon) // ============================================================================ function CategoryRow({ label, count, active, onClick }) { return ( ); } // ============================================================================ // ThumbCard — right-rail thumbnail: photo + tag + title, active = gold ring. // ============================================================================ function ThumbCard({ item, idx, active, onClick }) { return ( ); } // ============================================================================ // Gallery — the screen. // ============================================================================ function Gallery() { ensureGalleryKeys(); const t = useLoop(); const allItems = GALLERY_ITEMS; const cats = ['All', ...GALLERY_CATS]; const [catIdx, setCatIdx] = React.useState(0); const [subIdx, setSubIdx] = React.useState(0); const [open, setOpen] = React.useState(null); // lightbox item or null const stackRef = React.useRef(null); const cat = cats[catIdx]; const items = React.useMemo( () => cat === 'All' ? allItems : allItems.filter(it => it.cat === cat), [cat, allItems] ); const total = items.length; const item = items[subIdx] || items[0]; const e = clamp((t - 0.0) / 0.55); // header / rail reveal const eHero = clamp((t - 0.18) / 0.7); // hero reveal const pickCategory = (i) => { setCatIdx(i); setSubIdx(0); }; const pickSub = (i) => { if (total) setSubIdx((i + total) % total); }; const prevSub = () => pickSub(subIdx - 1); const nextSub = () => pickSub(subIdx + 1); const catCount = (c) => c === 'All' ? allItems.length : allItems.filter(x => x.cat === c).length; // Keyboard ← / → within the active category (deck only — lightbox owns its keys). React.useEffect(() => { const onKey = (ev) => { if (open) return; if (ev.key === 'ArrowLeft') prevSub(); else if (ev.key === 'ArrowRight') nextSub(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [subIdx, catIdx, total, open]); // Keep the active thumb in view inside its scroll container. React.useEffect(() => { const stack = stackRef.current; if (!stack) return; const active = stack.querySelector('[data-active="1"]'); if (!active) return; const aTop = active.offsetTop, aBot = aTop + active.offsetHeight; const pTop = stack.scrollTop, pBot = pTop + stack.clientHeight; if (aTop < pTop) stack.scrollTop = aTop - 10; else if (aBot > pBot) stack.scrollTop = aBot - stack.clientHeight + 10; }, [subIdx, catIdx]); return (
{/* ===== THREE-COLUMN STAGE ====================================== */}
{/* ---- LEFT EDITORIAL RAIL ---- */}
THE VISUAL WORLD · {allItems.length} RENDERS
Every view of
the Universe.
Exteriors · retail · amenities · clubhouse
{/* Category list — flexible rows fill the rail height at any canvas aspect */}
{cats.map((c, i) => ( pickCategory(i)}/> ))}
Tap any image to view full screen
· panoramas pan ·
{/* ---- CENTER HERO PLATE ---- */}
{ if (item) setOpen(item); }}> {total === 0 ? (
No images in this category.
) : ( )}
{/* gold inner rim (Citadel × Embassy-One detail) */}
{/* panorama hint badge (top-left) */} {item && isPano(item) && (
Panorama
)} {/* maximize / full-screen chip (top-right) */} {item && ( )} {/* caption (bottom-left) */} {item && (
{cat.toUpperCase()} · {pad2(subIdx + 1)} OF {pad2(total)}
{item.title}
{item.tag}
Tap to view full screen
)} {/* prev / counter / next control pill (overlay foot) */} {total > 1 && (
ev.stopPropagation()} style={{display:'flex', alignItems:'center', gap:22, padding:'10px 16px', borderRadius:999, background:'rgba(250,246,232,0.92)', border:'1px solid rgba(176,138,63,0.36)', boxShadow:'0 16px 34px -10px rgba(50,32,12,0.45), 0 5px 12px rgba(40,28,10,0.18)', backdropFilter:'blur(8px)'}}>
{pad2(subIdx + 1)}/{pad2(total)}
)}
{/* ---- RIGHT THUMBNAIL STACK ---- */}
In this set
{pad2(total)} TOTAL
{items.map((it, idx) => (
pickSub(idx)}/>
))}
{open && setOpen(null)}/>}
); } function ctrlBtnStyle() { return { width:48, height:48, borderRadius:'50%', flexShrink:0, background:'rgba(250,246,232,0.95)', border:'1px solid rgba(176,138,63,0.36)', color:'var(--gold-deep)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', transition:'background 220ms ease, transform 200ms ease', boxShadow:'0 6px 14px -4px rgba(50,32,12,0.3)', }; } // ============================================================================ // Lightbox — full-bleed viewer, double-buffered crossfade, ESC / ← / → keys. // · Standard image: pointer-swipe + arrow keys + arrow buttons → prev/next. // · Panorama image: LETTERBOXED (contain) AND horizontally PANNABLE — drag // scrubs across the wide render; a hint pulses. Prev/next via arrow buttons. // ============================================================================ function Lightbox({ item, items, onChange, onClose }) { const t = useLoop(); const fadeIn = clamp(t/0.4); const idx = items.findIndex(x => x.id === item.id); const pano = isPano(item); const prev = () => onChange(items[(idx - 1 + items.length) % items.length]); const next = () => onChange(items[(idx + 1) % items.length]); // Pan state (panorama). Reset on image change. const [panX, setPanX] = React.useState(0); React.useEffect(() => { setPanX(0); }, [item.id]); const stageRef = React.useRef(null); const dragRef = React.useRef({ active:false, x:0, base:0, moved:false }); const onLbDown = (e) => { dragRef.current = { active:true, x:e.clientX, base:panX, moved:false }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch(_){} }; const onLbMove = (e) => { const d = dragRef.current; if (!d.active) return; const dx = e.clientX - d.x; if (Math.abs(dx) > 5) d.moved = true; if (pano) { const el = stageRef.current; const boxW = el ? el.clientWidth : 1800; const boxH = el ? el.clientHeight : 1100; const imgW = boxH * 2; // 2:1 image scaled to box height const overflow = Math.max(0, (imgW - boxW) / 2 + 80); setPanX(clamp(d.base + dx, -overflow, overflow)); } }; const onLbUp = (e) => { const d = dragRef.current; if (!d.active) return; d.active = false; const dx = e.clientX - d.x; if (!pano) { const TH = 80; if (dx < -TH) next(); else if (dx > TH) prev(); } }; React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowLeft') prev(); if (e.key === 'ArrowRight') next(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [idx]); // Per-buffer panorama style — a 2:1 image laid out at width:200% centred so it // overflows the box symmetrically; translateX(panX) scrubs it. Standard images // use the .uni-lb-img contain CSS (styleFor returns undefined). const panoStyleFor = (isThisFront) => (pano && isThisFront) ? { objectFit:'contain', height:'100%', width:'200%', left:'50%', marginLeft:'-100%', transform:`translateX(${panX}px)`, transition: dragRef.current.active ? 'none' : 'opacity 280ms ease-out, transform 220ms cubic-bezier(0.22,1,0.36,1)' } : undefined; return (
e.stopPropagation()} style={{ width:'86%', height:'86%', position:'relative', borderRadius:15, overflow:'hidden', background:'#000', opacity: fadeIn, transform:`scale(${0.96+0.04*fadeIn})`, transition:'opacity 220ms ease', }}> {/* Double-buffered image stage */}
{/* caption gradient + content */}
{item.tag}
{item.title}
{/* panorama "drag to pan" hint */} {pano && (
Drag to pan
)} {/* counter + close (top-right) */}
{pad2(idx+1)} / {pad2(items.length)}
{/* prev / next */} {items.length > 1 && ( )}
); } function lbNavBtn(side) { return { position:'absolute', top:'50%', [side]:28, marginTop:-37, width:74, height:74, borderRadius:'50%', background:'rgba(245,241,232,0.08)', border:'1px solid rgba(245,241,232,0.22)', color:'var(--ivory)', display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer', backdropFilter:'blur(8px)', zIndex:3, transition:'background 200ms ease, transform 160ms ease', }; } window.Gallery = Gallery;