← back to Fleet Tv

public/mosaic.html

224 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fleet TV — Mosaic</title>
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  :root {
    --bg: #07080a;
    --fg: #e8e6e0;
    --dim: #6a6862;
    --rule: rgba(255,255,255,0.08);
  }
  html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; }
  body { padding: 0; min-height: 100vh; }

  .head {
    position: sticky; top: 0; z-index: 5;
    display: flex; align-items: baseline; gap: 1.5rem;
    padding: 1rem 1.5rem;
    background: linear-gradient(180deg, rgba(7,8,10,0.92), rgba(7,8,10,0.6));
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--rule);
  }
  .head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
  .head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
  .head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
  .head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
  .head .stats { margin-left: auto; display: flex; gap: 1.5rem; color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
  .head .stats b { color: var(--fg); font-weight: 500; }

  .grid {
    columns: 5 220px;
    column-gap: 0.6rem;
    padding: 0.6rem;
  }

  figure {
    break-inside: avoid;
    margin: 0 0 0.6rem;
    position: relative;
    overflow: hidden;
    border-radius: 4px;
    background: #1a1c20;
    cursor: pointer;
    transform: translateY(8px);
    opacity: 0;
    animation: fadeIn 0.6s ease forwards;
  }
  @keyframes fadeIn { to { transform: translateY(0); opacity: 1; } }

  figure img {
    display: block; width: 100%; height: auto;
    transition: transform 0.6s ease;
  }
  figure:hover img { transform: scale(1.04); }

  figure figcaption {
    position: absolute; left: 0; right: 0; bottom: 0;
    padding: 0.65rem 0.8rem 0.55rem;
    background: linear-gradient(0deg, rgba(0,0,0,0.85), rgba(0,0,0,0));
    color: #fff;
    transform: translateY(105%);
    transition: transform 0.35s ease;
  }
  figure:hover figcaption { transform: translateY(0); }
  figure figcaption .t { font-size: 0.78rem; line-height: 1.25; max-height: 2.4em; overflow: hidden; }
  figure figcaption .v { display: block; margin-top: 0.2rem; font-size: 0.62rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }

  .pill {
    position: absolute; top: 0.5rem; left: 0.5rem;
    padding: 0.18rem 0.55rem 0.2rem;
    font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase;
    background: rgba(0,0,0,0.7);
    border: 1px solid currentColor;
    border-radius: 999px;
    color: #fff;
    backdrop-filter: blur(4px);
  }

  .splash {
    position: fixed; inset: 0; display: grid; place-items: center;
    background: var(--bg); z-index: 10;
    transition: opacity 0.5s ease;
  }
  .splash.hide { opacity: 0; pointer-events: none; }
  .splash .mark {
    font-size: 1rem; letter-spacing: 0.42em; text-transform: uppercase;
    color: #c9a14b;
    animation: pulse 1.6s ease-in-out infinite;
  }
  @keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } }

  .empty { padding: 4rem; text-align: center; color: var(--dim); }

  /* — Lightbox — */
  .lightbox {
    position: fixed; inset: 0; z-index: 50;
    display: none; align-items: center; justify-content: center;
    background: rgba(0,0,0,0.92); padding: 2rem;
  }
  .lightbox.open { display: flex; }
  .lightbox img {
    max-width: 92vw; max-height: 80vh;
    border-radius: 4px;
    box-shadow: 0 30px 100px rgba(0,0,0,0.7);
  }
  .lightbox .info {
    position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%);
    text-align: center; color: var(--fg);
    background: rgba(20,22,26,0.92); padding: 1rem 1.5rem; border-radius: 4px;
    backdrop-filter: blur(6px);
    max-width: 80vw;
  }
  .lightbox .info .t { font-size: 1rem; line-height: 1.3; }
  .lightbox .info .v { font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.4rem; opacity: 0.7; }
  .lightbox .info a { display: inline-block; margin-top: 0.6rem; color: #c9a14b; text-decoration: none; font-size: 0.75rem; letter-spacing: 0.16em; text-transform: uppercase; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
  .lightbox .close { position: absolute; top: 1.5rem; right: 1.5rem; color: var(--fg); font-size: 1rem; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
</style>
</head>
<body>

<div class="splash" id="splash"><div class="mark">Mosaic · loading the fleet</div></div>

<div class="head">
  <h1>Fleet TV</h1>
  <span class="crumb"><a href="/">← back to wall</a> · mosaic</span>
  <div class="stats">
    <span>products · <b id="stat-total">—</b></span>
    <span>sites · <b id="stat-sites">—</b></span>
    <span>refresh · <b id="stat-refresh">— · R</b></span>
  </div>
</div>

<div class="grid" id="grid"></div>
<div class="empty" id="empty" style="display:none">no products available — fleet may still be warming up</div>

<div class="lightbox" id="lightbox">
  <span class="close" id="lb-close">close ✕</span>
  <img id="lb-img" alt="">
  <div class="info">
    <div class="t" id="lb-title">—</div>
    <div class="v" id="lb-vendor">—</div>
    <a id="lb-link" target="_blank" rel="noopener noreferrer">view on site →</a>
  </div>
</div>

<script>
(() => {
  const $ = (id) => document.getElementById(id);
  const grid = $('grid'), splash = $('splash'), empty = $('empty');
  const lightbox = $('lightbox'), lbImg = $('lb-img'), lbTitle = $('lb-title'),
        lbVendor = $('lb-vendor'), lbLink = $('lb-link'), lbClose = $('lb-close');

  async function load() {
    grid.innerHTML = '';
    splash.classList.remove('hide');
    try {
      const r = await fetch('/api/products-mosaic?per=6', { cache: 'no-store' });
      const j = await r.json();
      const sites = new Set(j.items.map(i => i.site));
      $('stat-total').textContent = j.total.toLocaleString();
      $('stat-sites').textContent = String(sites.size);
      $('stat-refresh').textContent = new Date(j.generatedAt).toTimeString().slice(0,5) + ' · R';
      if (!j.items.length) { empty.style.display = 'block'; }
      else {
        const frag = document.createDocumentFragment();
        j.items.forEach((p, i) => {
          const fig = document.createElement('figure');
          fig.style.animationDelay = (Math.min(i, 80) * 22) + 'ms';
          fig.dataset.idx = String(i);
          fig.innerHTML = `
            <span class="pill" style="color:${p.accent}">${p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m)}</span>
            <img loading="lazy" referrerpolicy="no-referrer" src="${p.image}" alt="${escape(p.title)}" onerror="this.parentNode.style.display='none'">
            <figcaption><span class="t">${escape(p.title)}</span><span class="v">${escape(p.vendor || p.site)}</span></figcaption>
          `;
          fig.addEventListener('click', () => openLightbox(p));
          frag.appendChild(fig);
        });
        grid.appendChild(frag);
      }
    } catch (e) {
      console.error(e);
      empty.style.display = 'block';
      empty.textContent = 'failed to load mosaic — ' + e.message;
    } finally {
      setTimeout(() => splash.classList.add('hide'), 250);
    }
  }

  function openLightbox(p) {
    lbImg.src = p.image;
    lbTitle.textContent = p.title || '(untitled)';
    lbVendor.textContent = (p.vendor || '') + (p.vendor && p.site ? ' · ' : '') + p.site;
    lbLink.href = p.productUrl;
    lightbox.classList.add('open');
  }
  lbClose.addEventListener('click', () => lightbox.classList.remove('open'));
  lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('open'); });

  document.addEventListener('keydown', (e) => {
    if (e.key.toLowerCase() === 'r') load();
    if (e.key === 'Escape') lightbox.classList.remove('open');
    if (e.key.toLowerCase() === 'w') location.href = '/';
    if (e.key.toLowerCase() === 's') location.href = '/stats.html';
    if (e.key === '/') { e.preventDefault(); location.href = '/search.html'; }
    if (e.key.toLowerCase() === 't') location.href = '/today.html';
    if (e.key.toLowerCase() === 'a') location.href = '/atlas.html';
    if (e.key.toLowerCase() === 'x') location.href = '/matrix.html';
  });

  function escape(s) {
    return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
  }

  load();
  // Auto-refresh every 5 minutes (newest products first when DW catalog updates)
  setInterval(load, 5 * 60 * 1000);
})();
</script>
</body>
</html>