← back to Ventura Corridor
public/gallery.html
217 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0a0a0a">
<title>The Blvd Gallery — Ventura Corridor</title>
<meta name="description" content="A virtual walking gallery of the art that lives inside every restaurant, salon, hotel, and storefront along Ventura Boulevard.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600;700&display=swap">
<style>
:root{
--bg:#0a0a0a;--fg:#f1ece2;--muted:#9a8f7e;--gold:#c9a96e;--rule:rgba(255,255,255,0.10);
--card-bg:#181613;
}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--fg);font-family:'Inter',sans-serif;overflow-x:hidden}
body{min-height:100vh}
/* Header */
.top{position:fixed;top:0;left:0;right:0;display:flex;align-items:center;gap:14px;padding:14px 22px;background:linear-gradient(180deg,rgba(10,10,10,0.95),rgba(10,10,10,0));backdrop-filter:blur(10px);z-index:20}
.top .logo{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--gold);font-family:'Playfair Display',serif;font-weight:700;font-size:1.25em}
.top .logo svg{width:32px;height:32px}
.top .meta{margin-left:auto;display:flex;gap:14px;align-items:center;color:var(--muted);font-size:0.8em;letter-spacing:1.5px;text-transform:uppercase}
.top .meta strong{color:var(--gold);font-weight:600}
.hamburger{margin-left:6px;width:38px;height:38px;border:1px solid var(--rule);border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--fg);font-size:1.1em;cursor:pointer;background:transparent}
/* Hero strip — visible only at the very start */
.hero{position:relative;height:90vh;display:flex;flex-direction:column;justify-content:center;padding:0 28px;scroll-snap-align:start}
.hero h1{font-family:'Playfair Display',serif;font-weight:700;font-size:clamp(2.4rem,7vw,4.6rem);margin:0 0 12px;line-height:1.05;letter-spacing:-0.01em}
.hero h1 em{color:var(--gold);font-style:italic}
.hero p{margin:0 0 18px;color:var(--muted);font-size:clamp(0.95rem,1.5vw,1.15rem);max-width:600px;line-height:1.5}
.hero .start{display:inline-flex;gap:10px;align-items:center;padding:14px 24px;background:var(--gold);color:#0a0a0a;border:0;border-radius:999px;font-family:inherit;font-weight:700;font-size:0.95rem;cursor:pointer;letter-spacing:0.5px}
.hero .start:hover{filter:brightness(1.1)}
.hero .scroll-hint{position:absolute;bottom:32px;left:28px;color:var(--muted);font-size:0.78em;letter-spacing:2px;text-transform:uppercase;animation:pulse 2.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:0.45}50%{opacity:1}}
/* Gallery rail */
.rail{display:flex;gap:24px;padding:60px 24px 100px;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;scroll-padding-left:24px;-webkit-overflow-scrolling:touch}
.rail::-webkit-scrollbar{height:8px}
.rail::-webkit-scrollbar-track{background:rgba(255,255,255,0.03)}
.rail::-webkit-scrollbar-thumb{background:var(--gold);border-radius:4px}
.piece{flex:0 0 380px;scroll-snap-align:start;display:flex;flex-direction:column;background:var(--card-bg);border:1px solid var(--rule);border-radius:14px;overflow:hidden;transition:transform 0.25s,box-shadow 0.25s,border-color 0.25s}
.piece:hover{transform:translateY(-6px);border-color:rgba(201,169,110,0.4);box-shadow:0 16px 32px rgba(0,0,0,0.4)}
.piece .canvas{aspect-ratio:4/5;background:#000;position:relative;display:flex;align-items:flex-end;padding:18px}
.piece .canvas::after{content:'';position:absolute;inset:0;background:linear-gradient(135deg,var(--c1,#222) 0%,var(--c2,#444) 50%,var(--c3,#666) 100%);opacity:0.85}
.piece .canvas .label{position:relative;z-index:2;color:rgba(255,255,255,0.6);font-size:0.7em;letter-spacing:2px;text-transform:uppercase}
.piece .meta{padding:18px 20px;display:flex;flex-direction:column;gap:8px}
.piece .meta h2{margin:0;font-family:'Playfair Display',serif;font-size:1.25em;font-weight:700;line-height:1.25;color:var(--fg)}
.piece .meta h2 em{color:var(--gold);font-style:italic}
.piece .meta .sub{color:var(--muted);font-size:0.78em;letter-spacing:1px;text-transform:uppercase}
.piece .meta .desc{margin:6px 0 4px;font-size:0.9em;color:rgba(255,255,255,0.78);line-height:1.5}
.piece .meta .business{color:var(--muted);font-size:0.78em;font-style:italic;border-top:1px solid var(--rule);padding-top:10px;margin-top:6px}
/* Neighborhood divider */
.neighborhood-divider{flex:0 0 280px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--gold);text-align:center;scroll-snap-align:start}
.neighborhood-divider .name{font-family:'Playfair Display',serif;font-size:2.1em;font-weight:700;letter-spacing:0.02em}
.neighborhood-divider .name em{font-style:italic;color:var(--fg)}
.neighborhood-divider .pos{margin-top:6px;color:var(--muted);font-size:0.78em;letter-spacing:2px;text-transform:uppercase}
/* Bottom geo progress bar */
.geo-bar{position:fixed;bottom:0;left:0;right:0;height:42px;background:rgba(10,10,10,0.92);backdrop-filter:blur(10px);border-top:1px solid var(--rule);z-index:18;display:flex;align-items:center;padding:0 20px;gap:14px;font-size:0.74em;color:var(--muted);letter-spacing:2px;text-transform:uppercase}
.geo-bar .track{flex:1;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;position:relative;overflow:hidden}
.geo-bar .progress{position:absolute;left:0;top:0;bottom:0;background:var(--gold);width:0;transition:width 0.2s}
.geo-bar .end{color:var(--gold);font-weight:700}
.empty{padding:80px 28px;text-align:center;color:var(--muted)}
.empty h2{font-family:'Playfair Display',serif;color:var(--fg);font-size:1.8em;margin:0 0 8px}
/* Mobile */
@media (max-width:600px){
.top{padding:10px 14px}
.top .logo{font-size:1.05em}
.top .meta{font-size:0.7em;gap:8px}
.top .meta span:nth-of-type(2){display:none}
.hero{height:80vh;padding:0 20px}
.hero p{font-size:0.95rem}
.rail{padding:40px 16px 80px;gap:14px}
.piece{flex:0 0 78vw}
.neighborhood-divider{flex:0 0 60vw}
.neighborhood-divider .name{font-size:1.5em}
.geo-bar{height:38px;font-size:0.62em;gap:10px}
}
</style>
</head>
<body>
<header class="top">
<a class="logo" href="/" aria-label="Ventura Corridor home">
<svg viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="20" cy="20" r="18" fill="none" stroke="#c9a96e" stroke-width="1.5"/>
<path d="M6 22 L34 18" stroke="#c9a96e" stroke-width="1.5" fill="none"/>
<circle cx="13" cy="21" r="1.5" fill="#c9a96e"/>
<circle cx="20" cy="20.5" r="1.5" fill="#c9a96e"/>
<circle cx="27" cy="19.5" r="1.5" fill="#c9a96e"/>
</svg>
<span>Ventura Corridor</span>
</a>
<div class="meta">
<span><strong id="m-count">…</strong> Pieces</span>
<span><strong id="m-miles">9.7</strong> Miles</span>
<span><strong id="m-where">Studio City</strong></span>
</div>
<button class="hamburger" aria-label="Menu" id="hb">≡</button>
</header>
<section class="hero">
<h1>The Blvd <em>Gallery</em></h1>
<p>Every restaurant, salon, hotel, and storefront on Ventura Boulevard hosts art that lives inside its walls. Scroll east-to-west — Encino to Studio City — and walk past every piece. Imagined into being by the corridor design system.</p>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<button class="start" id="startBtn">Start the walk →</button>
<a href="/" style="color:var(--muted);font-size:0.85em;text-decoration:none">← back to corridor</a>
</div>
<div class="scroll-hint">⤳ swipe right</div>
</section>
<main class="rail" id="rail">
<div class="empty"><h2>Gallery generating overnight…</h2><p>The 3am job is hydrating qwen3-generated art for every business on the boulevard. Refresh in the morning.</p></div>
</main>
<div class="geo-bar">
<span style="color:var(--gold)">Encino</span>
<div class="track"><div class="progress" id="geoProg"></div></div>
<span class="end">Studio City</span>
</div>
<script>
const NEIGHBORHOODS = ['Encino','Sherman Oaks','Studio City'];
async function load() {
const res = await fetch('/api/gallery/pieces?limit=500');
if (!res.ok) return;
const j = await res.json();
const items = j.rows || [];
document.getElementById('m-count').textContent = items.length.toLocaleString();
const rail = document.getElementById('rail');
if (!items.length) return;
rail.innerHTML = '';
let prevHood = '';
for (const p of items) {
if (p.neighborhood && p.neighborhood !== prevHood) {
const d = document.createElement('div');
d.className = 'neighborhood-divider';
const idx = NEIGHBORHOODS.indexOf(p.neighborhood);
d.innerHTML = `<div class="name">${idx >= 0 ? '<em>'+(idx+1)+'.</em> ' : ''}${p.neighborhood}</div><div class="pos">${(p.position_pct||0).toFixed(0)}% along the blvd</div>`;
rail.appendChild(d);
prevHood = p.neighborhood;
}
rail.appendChild(card(p));
}
}
function card(p) {
const a = document.createElement('article');
a.className = 'piece';
a.dataset.pos = p.position_pct || 0;
a.dataset.id = p.id;
const pal = Array.isArray(p.color_palette) ? p.color_palette : [];
a.style.setProperty('--c1', pal[0] || '#222');
a.style.setProperty('--c2', pal[1] || '#444');
a.style.setProperty('--c3', pal[2] || '#666');
const yr = p.art_year_est ? ` · ${p.art_year_est}` : '';
a.innerHTML = `
<div class="canvas"><div class="label">${(p.art_medium||'').toUpperCase()}${yr}</div></div>
<div class="meta">
<h2>${escapeHtml(p.art_title || '')}</h2>
<div class="sub">${escapeHtml((p.art_medium || '').toUpperCase())}${yr}</div>
<p class="desc">${escapeHtml(p.art_description || '')}</p>
<div class="business">Hosted by ${escapeHtml(p.business_name || '')}${p.business_address ? ' · ' + escapeHtml(p.business_address) : ''}</div>
</div>
`;
return a;
}
function escapeHtml(s) { return String(s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// Geo progress bar: track the leftmost visible piece + update position
const rail = document.getElementById('rail');
const geoProg = document.getElementById('geoProg');
const mWhere = document.getElementById('m-where');
function onScroll() {
const pieces = rail.querySelectorAll('.piece');
if (!pieces.length) return;
const railRect = rail.getBoundingClientRect();
let bestPos = 0, bestPiece = null;
for (const p of pieces) {
const r = p.getBoundingClientRect();
if (r.left >= railRect.left - 10 && r.left < railRect.left + 80) { bestPos = +p.dataset.pos; bestPiece = p; break; }
}
geoProg.style.width = bestPos + '%';
if (bestPos < 33) mWhere.textContent = 'Encino';
else if (bestPos < 67) mWhere.textContent = 'Sherman Oaks';
else mWhere.textContent = 'Studio City';
history.replaceState(null, '', '/gallery.html?pos=' + Math.round(bestPos));
}
rail.addEventListener('scroll', onScroll, { passive: true });
document.getElementById('startBtn').addEventListener('click', () => rail.scrollTo({ left: 0, behavior: 'smooth' }));
document.addEventListener('keydown', e => {
if (e.key === 'ArrowRight') rail.scrollBy({ left: 400, behavior: 'smooth' });
if (e.key === 'ArrowLeft') rail.scrollBy({ left: -400, behavior: 'smooth' });
});
// Initial load
load();
// If ?pos=N in URL, scroll to that position after load
const initialPos = parseInt(new URLSearchParams(location.search).get('pos') || '0', 10);
if (initialPos > 0) {
setTimeout(() => {
const target = [...document.querySelectorAll('.piece')].find(p => +p.dataset.pos >= initialPos);
if (target) target.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}, 500);
}
</script>
</body>
</html>