// 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 (
{ e.currentTarget.style.opacity = 0; }} />
{srcB &&
{ 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 */}
{/* panorama "drag to pan" hint */}
{pano && (
)}
{/* 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;