← back to Gallery Agentabrams

index.html

238 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>gallery.agentabrams.com — Builds</title>
<meta name="description" content="A gallery of Agent Abrams builds." />
<style>
  :root {
    --bg:#0c0d11; --panel:#14161d; --line:#262a35; --line-soft:#1c1f27;
    --ink:#e9ebf2; --dim:#9aa0ad; --faint:#666c78; --accent:#5eead4;
    --cols: 3;
    --sans: ui-sans-serif, "Helvetica Neue", Arial, system-ui, sans-serif;
    --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
  }
  * { box-sizing:border-box; }
  html,body { margin:0; }
  body { background:var(--bg); color:var(--ink); font-family:var(--sans); }
  a { color:inherit; }

  header.top {
    display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap;
    padding:1.5rem clamp(1rem,4vw,2.5rem) .4rem;
  }
  .brand { font-family:var(--mono); font-size:1rem; letter-spacing:.14em; font-weight:700; }
  .brand .dot { color:var(--accent); }
  .brand .sub { color:var(--faint); font-weight:400; letter-spacing:.08em; }
  header.top .count { margin-left:auto; color:var(--faint); font-size:.78rem; letter-spacing:.1em; font-family:var(--mono); }
  .tagline { padding:0 clamp(1rem,4vw,2.5rem); color:var(--dim); font-size:.9rem; max-width:60ch; }

  /* controls: sort + density (persisted) */
  .controls {
    display:flex; align-items:center; gap:1.2rem; flex-wrap:wrap;
    padding:1.2rem clamp(1rem,4vw,2.5rem) 1.4rem;
    border-bottom:1px solid var(--line-soft);
  }
  .controls label { font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--faint); margin-right:.5rem; }
  .controls select {
    font-family:var(--sans); font-size:.85rem; background:var(--panel); color:var(--ink);
    border:1px solid var(--line); border-radius:4px; padding:.4rem .6rem;
  }
  .controls .density { display:flex; align-items:center; gap:.6rem; }
  .controls input[type=range] { accent-color:var(--accent); width:130px; }

  .grid {
    display:grid; gap:1.2rem;
    grid-template-columns:repeat(var(--cols), 1fr);
    padding:1.6rem clamp(1rem,4vw,2.5rem) 4rem;
  }

  .card {
    position:relative; background:var(--panel);
    border:1px solid var(--line-soft); border-radius:10px; overflow:hidden;
    display:flex; flex-direction:column;
    transition:border-color .2s, transform .2s;
  }
  .card:hover { border-color:var(--line); transform:translateY(-2px); }
  .card .cover { position:relative; aspect-ratio:16/9; background:#000; overflow:hidden; }
  .card .cover video, .card .cover img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:block; }
  .card .cover img { z-index:1; }
  .card .cover video { z-index:2; opacity:0; transition:opacity .4s; }
  .card.playing .cover video { opacity:1; }
  .card .num {
    position:absolute; top:.6rem; left:.6rem; z-index:3;
    font-family:var(--mono); font-size:.7rem; letter-spacing:.12em;
    background:rgba(0,0,0,.65); color:var(--accent);
    border:1px solid rgba(94,234,212,.35); border-radius:4px; padding:.2rem .5rem;
  }
  .card .body { padding:.9rem 1rem 1.1rem; display:flex; flex-direction:column; gap:.5rem; flex:1; }
  .card h2 { margin:0; font-size:1.08rem; line-height:1.2; }
  .card .desc { margin:0; font-size:.82rem; color:var(--dim); line-height:1.5; }
  .card .tags { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.1rem; }
  .card .tag { font-size:.64rem; letter-spacing:.06em; text-transform:uppercase; color:var(--dim);
    border:1px solid var(--line); border-radius:20px; padding:.15rem .5rem; }
  .card .meta { display:flex; align-items:center; gap:.6rem; margin-top:auto; padding-top:.4rem; }
  .card .when { font-family:var(--mono); font-size:.68rem; color:var(--faint); }
  .card .actions { display:flex; gap:.4rem; margin-left:auto; }
  .card .actions button {
    font-family:var(--sans); font-size:.74rem; cursor:pointer;
    background:transparent; color:var(--ink); border:1px solid var(--line);
    border-radius:5px; padding:.32rem .6rem; transition:background .2s,border-color .2s;
  }
  .card .actions button:hover { border-color:var(--accent); }
  .card .actions .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }

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

  /* preview overlay */
  .overlay { position:fixed; inset:0; z-index:100; background:rgba(0,0,0,.82);
    display:none; flex-direction:column; }
  .overlay.open { display:flex; }
  .overlay .obar { display:flex; align-items:center; gap:1rem; padding:.7rem 1rem; background:var(--panel); border-bottom:1px solid var(--line); }
  .overlay .obar .t { font-size:.9rem; font-weight:600; }
  .overlay .obar .sp { flex:1; }
  .overlay .obar button, .overlay .obar a {
    font-size:.78rem; cursor:pointer; text-decoration:none;
    background:transparent; color:var(--ink); border:1px solid var(--line);
    border-radius:5px; padding:.35rem .7rem;
  }
  .overlay .obar .open { background:var(--accent); color:#08110f; border-color:var(--accent); font-weight:600; }
  .overlay iframe { flex:1; width:100%; border:0; background:#000; }

  :focus-visible { outline:2px solid var(--accent); outline-offset:2px; }

  @media (max-width:900px){ :root{ --cols:2; } }
  @media (max-width:560px){ :root{ --cols:1; } .controls{ gap:.8rem; } }
</style>
</head>
<body>
  <header class="top">
    <div class="brand"><span class="dot">◆</span> GALLERY <span class="sub">· agentabrams.com</span></div>
    <div class="count" id="count"></div>
  </header>
  <p class="tagline">Finished builds — self-contained, standalone, and openable in their own window.</p>

  <div class="controls">
    <div><label for="sort">Sort</label>
      <select id="sort">
        <option value="num-asc">Build № ↑</option>
        <option value="newest">Newest</option>
        <option value="oldest">Oldest</option>
        <option value="title">Title A→Z</option>
      </select>
    </div>
    <div class="density"><label for="density">Density</label>
      <input id="density" type="range" min="1" max="5" step="1" value="3" aria-label="Grid density" />
    </div>
  </div>

  <main class="grid" id="grid" aria-live="polite"></main>

  <!-- preview overlay -->
  <div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-label="Build preview">
    <div class="obar">
      <span class="t" id="ovTitle"></span>
      <span class="sp"></span>
      <a class="open" id="ovOpen" target="_blank" rel="noopener">Open in own window ↗</a>
      <button id="ovClose">Close ✕</button>
    </div>
    <iframe id="ovFrame" title="Build preview" allow="autoplay; fullscreen"></iframe>
  </div>

<script>
(function(){
  "use strict";
  const $ = s => document.querySelector(s);
  const grid = $("#grid"), countEl = $("#count");
  let builds = [];

  fetch("builds.json").then(r=>r.json()).then(d=>{
    builds = (d.builds||[]).slice();
    countEl.textContent = builds.length + (builds.length===1?" build":" builds");
    restoreControls();
    render();
    if (location.hash) openPreview(location.hash.slice(1));
  }).catch(()=>{ grid.innerHTML = '<p class="empty">Could not load builds.json</p>'; });

  const fmtDate = s => { try { return new Date(s+"T12:00:00").toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});} catch(e){ return s; } };

  function sorted(){
    const s = $("#sort").value;
    const a = builds.slice();
    if (s==="num-asc") a.sort((x,y)=>x.num-y.num);
    else if (s==="newest") a.sort((x,y)=>(y.added||"").localeCompare(x.added||""));
    else if (s==="oldest") a.sort((x,y)=>(x.added||"").localeCompare(y.added||""));
    else if (s==="title") a.sort((x,y)=>x.title.localeCompare(y.title));
    return a;
  }

  function render(){
    const a = sorted();
    grid.innerHTML = a.map(b => `
      <article class="card" data-id="${b.id}">
        <div class="cover">
          <span class="num">BUILD ${String(b.num).padStart(2,"0")}</span>
          ${b.cover_poster ? `<img src="${b.cover_poster}" alt="" loading="lazy" />` : ""}
          ${b.cover_video ? `<video muted loop playsinline preload="none" src="${b.cover_video}"></video>` : ""}
        </div>
        <div class="body">
          <h2>${b.title}</h2>
          <p class="desc">${b.desc}</p>
          <div class="tags">${(b.tags||[]).map(t=>`<span class="tag">${t}</span>`).join("")}</div>
          <div class="meta">
            <span class="when" title="Added ${b.added}">🕓 ${fmtDate(b.added)}</span>
            <span class="actions">
              <button class="preview" data-id="${b.id}">Preview</button>
              <button class="open" data-open="${b.path}">Open ↗</button>
            </span>
          </div>
        </div>
      </article>`).join("");

    // hover-play the cover video (perf: only load on hover)
    grid.querySelectorAll(".card").forEach(card => {
      const v = card.querySelector("video");
      card.addEventListener("mouseenter", ()=>{ if(v){ card.classList.add("playing"); v.play().catch(()=>{});} });
      card.addEventListener("mouseleave", ()=>{ if(v){ card.classList.remove("playing"); v.pause(); } });
    });
  }

  // Open build in its own window (fleet "open-in-own-window" UX)
  grid.addEventListener("click", e => {
    const openBtn = e.target.closest("[data-open]");
    if (openBtn){ window.open(openBtn.dataset.open, "_blank", "noopener"); return; }
    const prev = e.target.closest(".preview");
    if (prev){ openPreview(prev.dataset.id); }
  });

  const overlay = $("#overlay"), ovFrame = $("#ovFrame"), ovTitle = $("#ovTitle"), ovOpen = $("#ovOpen");
  function openPreview(id){
    const b = builds.find(x=>x.id===id); if(!b) return;
    ovTitle.textContent = `Build ${String(b.num).padStart(2,"0")} · ${b.title}`;
    ovFrame.src = b.path; ovOpen.href = b.path;
    overlay.classList.add("open");
    history.replaceState(null,"","#"+id);
    $("#ovClose").focus();
  }
  function closePreview(){
    overlay.classList.remove("open"); ovFrame.src = "about:blank";
    history.replaceState(null,"",location.pathname);
  }
  $("#ovClose").addEventListener("click", closePreview);
  overlay.addEventListener("mousedown", e => { if(e.target===overlay) closePreview(); });
  document.addEventListener("keydown", e => { if(e.key==="Escape" && overlay.classList.contains("open")) closePreview(); });

  // controls: persist sort + density (Steve's grid rule)
  function restoreControls(){
    const s = localStorage.getItem("gallery:sort"); if(s) $("#sort").value = s;
    const d = localStorage.getItem("gallery:density"); if(d){ $("#density").value = d; document.documentElement.style.setProperty("--cols", d); }
    else document.documentElement.style.setProperty("--cols", $("#density").value);
  }
  $("#sort").addEventListener("change", ()=>{ localStorage.setItem("gallery:sort", $("#sort").value); render(); });
  $("#density").addEventListener("input", ()=>{ const v=$("#density").value; document.documentElement.style.setProperty("--cols", v); localStorage.setItem("gallery:density", v); });
})();
</script>
</body>
</html>