/* global React, useViewport, PageIntro, COLLECTIONS */
/*
 * Moodboard — inspiration page.
 *
 * Editorial grid of furniture imagery — real installs, 3D renders and AI
 * studies — for designers to scroll, save, and pull into their own boards.
 *
 * Interaction: HTML5 drag-to-desktop. Each tile sets a DownloadURL on
 * dragstart so dragging the image onto Finder / Explorer downloads it.
 * Supported in Chrome / Edge / Brave; Safari and Firefox fall back to a
 * standard image drag (still placeable into apps; download requires a
 * right-click → Save As).
 *
 * Filter: by collection. "All" shows every image.
 *
 * Data: built from the collection imagery already on the site. Extend
 * as more lifestyle / AI / render images are delivered.
 */

const { useState: useStateMB, useMemo: useMemoMB } = React;

/* Compressed thumbnail path for a moodboard image. Thumbs live under
   assets/_thumbs/… as 720px JPEGs (~90% lighter). If a thumb hasn't been
   generated yet, MoodTile falls back to the original automatically. */
function thumbFor(src) {
  if (!src || src.indexOf("assets/") !== 0) return src;
  return "assets/_thumbs/" + src.slice("assets/".length).replace(/\.(jpe?g|png|webp)$/i, ".jpg");
}

/* Exact intrinsic ratio for a tile. window.IMG_DIMS (images-dims.js) holds
   real [w,h] for every raster image, so the tile reserves its true height up
   front — zero resize on load. Videos and any image missing from the map fall
   back to the collage ASPECT_PATTERN by index. Returns { num: w/h, css }. */
function tileRatio(item, i) {
  const d = (window.IMG_DIMS && window.IMG_DIMS[item.image]) || null;
  if (d && d[0] > 0 && d[1] > 0) return { num: d[0] / d[1], css: `${d[0]} / ${d[1]}` };
  const pat = ASPECT_PATTERN[i % ASPECT_PATTERN.length];
  const [a, b] = pat.split("/").map((s) => parseFloat(s));
  return { num: (a && b) ? a / b : 0.8, css: pat };
}

/* Mix of aspect ratios — repeats every 9 tiles for a collage rhythm.
   Mixes portrait, landscape, square, and tall to break uniform grids. */
const ASPECT_PATTERN = [
  "4 / 5",   // portrait
  "1 / 1",   // square
  "3 / 4",   // portrait
  "16 / 9",  // landscape
  "4 / 5",   // portrait
  "1 / 1",   // square
  "3 / 2",   // landscape
  "2 / 3",   // tall portrait
  "5 / 4",   // wide square
];

/* ------------------------------------------------------------------ */
/* Moodboard items                                                     */
/* Each entry: collection slug, image path, optional caption.          */
/* "kind" is optional metadata for future filters; not surfaced now    */
/* per the brief ("no AI labelling").                                  */
/* ------------------------------------------------------------------ */

/* Curated collection studio / lifestyle imagery. Project installation
   photography is pulled automatically from the PROJECTS registry below,
   so any new project added to the site appears here with no extra step. */
const COLLECTION_IMAGES = [
  // Gateway
  ["gateway", "images/moodboard/gateway/01.webp"],
  ["gateway", "images/moodboard/gateway/02.webp"],
  ["gateway", "images/moodboard/gateway/03.webp"],
  ["gateway", "images/moodboard/gateway/04.webp"],
  ["gateway", "images/moodboard/gateway/05.webp"],
  // Trio
  ["trio", "images/moodboard/trio/01.jpg"],
  ["trio", "images/moodboard/trio/02.jpg"],
  ["trio", "images/moodboard/trio/03.jpg"],
  ["trio", "images/moodboard/trio/04.jpg"],
  ["trio", "images/moodboard/trio/05.jpg"],
  ["trio", "images/moodboard/trio/06.jpg"],
  ["trio", "images/moodboard/trio/07.jpg"],
  ["trio", "images/moodboard/trio/08.jpg"],
  ["trio", "images/moodboard/trio/09.jpg"],
  // Passenger Terminal Expo 2026, London — Derlot stand
  ["trio", "images/moodboard/trio/10.jpg"],
  ["trio", "images/moodboard/trio/11.jpg"],
  ["trio", "images/moodboard/trio/12.jpg"],
  ["trio", "images/moodboard/trio/13.jpg"],
  ["platform", "images/moodboard/platform/01.jpg"],
  ["rio", "images/moodboard/rio/01.jpg"],
  // Platform
  ["platform", "images/moodboard/platform/02.webp"],
  ["platform", "images/moodboard/platform/03.webp"],
  ["platform", "images/moodboard/platform/04.webp"],
  // Stump / Stump Recycled
  ["stump", "images/moodboard/stump/01.webp"],
  ["stump-r", "images/moodboard/stump-r/01.webp"],
  ["stump-r", "images/moodboard/stump-r/02.webp"],
  // Pipeline
  ["pipeline", "images/moodboard/pipeline/01.webp"],
  // Prisma
  ["prisma", "images/moodboard/prisma/01.webp"],
  // Caterpillar
  ["caterpillar", "images/moodboard/caterpillar/01.webp"],
  // Mochi
  ["mochi", "images/moodboard/mochi/01.webp"],
  // Twig
  ["twig", "images/moodboard/twig/01.webp"],
  // Autobahn
  ["autobahn", "images/moodboard/autobahn/01.jpg"],
  // Volar
  ["volar", "images/moodboard/volar/01.webp"],
  // Biggie
  ["biggie", "images/moodboard/biggie/01.webp"],
  // Strap — outback campaign (Florian Groehn, 2019)
  ["strap", "images/moodboard/strap/01.webp"],
  ["strap", "images/moodboard/strap/02.webp"],
  ["strap", "images/moodboard/strap/03.webp"],
  ["strap", "images/moodboard/strap/04.webp"],
  ["strap", "images/moodboard/strap/05.webp"],
  ["strap", "images/moodboard/strap/06.webp"],
  ["strap", "images/moodboard/strap/07.webp"],
  ["strap", "images/moodboard/strap/08.webp"],
  ["strap", "images/moodboard/strap/09.webp"],
  ["strap", "images/moodboard/strap/10.webp"],
  ["strap", "images/moodboard/strap/11.webp"],
  ["strap", "images/moodboard/strap/12.webp"],
  ["strap", "images/moodboard/strap/13.webp"],
  ["strap", "images/moodboard/strap/14.webp"],
  // Picket
  ["picket", "images/moodboard/picket/01.webp"],
  // Pinto
  ["pinto", "images/moodboard/pinto/01.webp"],
  // Hext
  ["hext", "images/moodboard/hext/01.webp"],
  // Yeti
  ["yeti", "images/moodboard/yeti/01.jpg"],
  // Pillar
  ["pillar", "images/moodboard/pillar/01.webp"],
  // Guell
  ["guell", "images/moodboard/guell/01.webp"],
  // Fit
  ["fit", "images/moodboard/fit/01.webp"],
  // Tetromino Soft
  ["tetromino-s", "images/moodboard/tetromino-s/01.webp"],
  // Tonne
  ["tonne", "images/moodboard/tonne/01.webp"],
  // Rio
  ["rio", "images/moodboard/rio/02.jpg"],
  // S1
  ["s1", "images/moodboard/s1/01.webp"],
  // Pill
  ["pill", "images/moodboard/pill/01.jpg"],
  // Kono
  ["kono", "images/moodboard/kono/01.jpg"],
  ["kono", "images/moodboard/kono/02.jpg"],
  ["kono", "images/moodboard/kono/03.jpg"],
  ["kono", "images/moodboard/kono/04.jpg"],
  ["kono", "images/moodboard/kono/05.jpg"],
  // Wombat
  ["wombat", "images/moodboard/wombat/01.jpg"],
  ["wombat", "images/moodboard/wombat/02.jpg"],
  ["wombat", "images/moodboard/wombat/03.jpg"],
  ["wombat", "images/moodboard/wombat/04.jpg"],
  // Moodboard tag overrides — re-file specific project shots under the
  // collection they actually show (curated entries win the de-dupe).
  ["mochi", "images/moodboard/mochi/02.jpg"],
  ["cup",   "images/moodboard/cup/01.webp"],
  ["biggie", "images/moodboard/biggie/02.webp"],
  // Bolet
  ["bolet", "images/moodboard/bolet/01.jpg"],
  ["bolet", "images/moodboard/bolet/02.jpg"],
  ["bolet", "images/moodboard/bolet/03.jpg"],
  ["bolet", "images/moodboard/bolet/04.jpg"],
  ["bolet", "images/moodboard/bolet/05.jpg"],
  // Coral
  ["coral", "images/moodboard/coral/01.jpg"],
  ["coral", "images/moodboard/coral/02.jpg"],
  ["coral", "images/moodboard/coral/03.jpg"],
  ["coral", "images/moodboard/coral/04.jpg"],
  ["coral", "images/moodboard/coral/05.jpg"],
  // Kink
  ["kink", "images/moodboard/kink/01.jpg"],
  ["kink", "images/moodboard/kink/02.jpg"],
  ["kink", "images/moodboard/kink/03.jpg"],
  ["kink", "images/moodboard/kink/04.jpg"],
  ["kink", "images/moodboard/kink/05.jpg"],
  // Seed
  ["seed", "images/moodboard/seed/01.png"],
  ["seed", "images/moodboard/seed/02.jpg"],
  ["seed", "images/moodboard/seed/03.jpg"],
  ["seed", "images/moodboard/seed/04.jpg"],
  // Ivi
  ["ivi", "images/moodboard/ivi/01.jpg"],
  ["ivi", "images/moodboard/ivi/02.jpg"],
  ["ivi", "images/moodboard/ivi/03.jpg"],
  ["ivi", "images/moodboard/ivi/04.jpg"],
  ["ivi", "images/moodboard/ivi/05.jpg"],
  // Mass
  ["mass", "images/moodboard/mass/01.jpg"],
  ["mass", "images/moodboard/mass/02.jpg"],
  ["mass", "images/moodboard/mass/03.jpg"],
  ["mass", "images/moodboard/mass/04.jpg"],
  // Aviation in-situ renders
  ["gateway",     "images/moodboard/gateway/06.jpg"],
  ["gateway",     "images/moodboard/gateway/07.jpg"],
  ["gateway",     "images/moodboard/gateway/08.jpg"],
  ["pipeline",    "images/moodboard/pipeline/02.jpg"],
  ["mochi",       "images/moodboard/mochi/03.jpg"],
  ["mochi",       "images/moodboard/mochi/04.jpg"],
  ["mochi",       "images/moodboard/mochi/05.jpg"],
  ["prisma",      "images/moodboard/prisma/02.jpg"],
  ["prisma",      "images/moodboard/prisma/03.jpg"],
  ["autobahn",    "images/moodboard/autobahn/02.jpg"],
  ["caterpillar", "images/moodboard/caterpillar/02.jpg"],
  ["caterpillar", "images/moodboard/caterpillar/03.jpg"],
  ["twig",        "images/moodboard/twig/02.jpg"],
];

/* A few looping clips, woven through the board to break the rhythm of stills.
   Muted, autoplay only while on-screen, light footprint. */
const COLLECTION_VIDEOS = [
  ["autobahn", "images/moodboard/autobahn/03.mp4"],
  ["cocoon",   "images/moodboard/cocoon/01.mp4"],
  ["pipeline", "images/moodboard/pipeline/03.mp4"],
  ["volar",    "images/moodboard/volar/02.mp4"],
  ["s1",       "images/moodboard/s1/02.mp4"],
  ["caterpillar", "images/moodboard/caterpillar/04.mp4"],
];

/* Build the full moodboard set: curated collection imagery first, then
   every project's hero + gallery photography pulled live from the
   PROJECTS registry (so new projects flow in automatically). De-duped. */
function buildMoodboardItems() {
  const items = [];
  const seen = new Set();
  const seenName = new Set();
  const push = (slug, image, media) => {
    if (!image || seen.has(image)) return;
    if (media !== "video" && !/\.(webp|jpe?g|png)$/i.test(image)) return;
    // De-dupe by full path. (Filename-only dedup is unsafe now that images are
    // organised into per-slug folders with sequential names — every slug has an
    // "01.webp", so a filename key would collide across collections.)
    seen.add(image);
    items.push({ slug, image, media: media || "image" });
  };
  try {
    const colVids = (typeof COLLECTION_VIDEOS !== "undefined" && COLLECTION_VIDEOS) || [];
    colVids.forEach(([slug, image]) => push(slug, image, "video"));
    const colImgs = (typeof COLLECTION_IMAGES !== "undefined" && COLLECTION_IMAGES) || [];
    colImgs.forEach(([slug, image]) => push(slug, image));
    const projs = (window && window.PROJECTS) || [];
    projs.forEach((p) => {
      if (!p) return;
      const slug = (p.collections && p.collections[0] && p.collections[0].slug) || "all";
      // Only pull real project photography. Images under assets/collections/
      // are generic catalogue placeholders used as stand-in heroes by
      // un-photographed projects — skipping them removes board double-ups.
      const pushProj = (src) => { if (src && src.indexOf("assets/collections/") !== 0) push(slug, src); };
      const heroes = Array.isArray(p.hero) ? p.hero : (p.hero ? [p.hero] : []);
      heroes.forEach((h) => pushProj(h));
      const gallery = Array.isArray(p.gallery) ? p.gallery : [];
      gallery.forEach((g) => g && pushProj(g.src));
    });
    // Manifest-driven: include anything dropped into images/moodboard/<slug>/
    // (full-path dedup above skips images already shown). Run
    // `node generate-manifests.js` after adding files.
    if (window.IMG && window.IMG.moodboardAll) {
      window.IMG.moodboardAll().forEach(([slug, src, type]) => push(slug, src, type === "video" ? "video" : "image"));
    }
  } catch (e) { /* never let the board crash the page */ }
  // Deterministic round-robin interleave: group by collection, then emit one
  // image from each collection in rotation. Collections are never clustered,
  // and the order is STABLE across loads — so position-based review comments
  // stay pinned to the same image.
  const bySlug = new Map();
  items.forEach((it) => {
    if (!bySlug.has(it.slug)) bySlug.set(it.slug, []);
    bySlug.get(it.slug).push(it);
  });
  const queues = [...bySlug.values()];
  const woven = [];
  let added = true;
  while (added) {
    added = false;
    for (const q of queues) {
      if (q.length) { woven.push(q.shift()); added = true; }
    }
  }
  // Spread videos evenly across the whole board (with the first landing on the
  // opening page). Each video is the first item of its collection queue, so the
  // round-robin above emits them all in the first pass — clustering them top-left.
  const vids = woven.filter((it) => it.media === "video");
  if (vids.length) {
    const imgs = woven.filter((it) => it.media !== "video");
    const n = imgs.length, k = vids.length;
    const targets = new Map();
    for (let j = 0; j < k; j++) targets.set(Math.round((j + 0.4) * n / k), vids[j]);
    const out = [];
    imgs.forEach((it, i) => {
      if (targets.has(i)) out.push(targets.get(i));
      out.push(it);
    });
    // any not placed (e.g. n < k) get appended
    let placed = out.filter((x) => x.media === "video").length;
    while (placed < k) out.push(vids[placed++]);
    return out;
  }
  return woven;
}

/* Built once at module load — Moodboard.jsx loads after Projects.jsx, so
   window.PROJECTS is already populated. */
const MB_ITEMS_ALL = buildMoodboardItems();

/* ------------------------------------------------------------------ */
/* Tile                                                                */
/* ------------------------------------------------------------------ */

function MoodVideo({ src, hover }) {
  const ref = useRefMB(null);
  useEffectMB(() => {
    const v = ref.current;
    if (!v) return;
    const obs = new IntersectionObserver((entries) => {
      entries.forEach((en) => {
        if (en.isIntersecting) { v.play().catch(() => {}); }
        else { try { v.pause(); } catch (e) {} }
      });
    }, { threshold: 0.25 });
    obs.observe(v);
    return () => obs.disconnect();
  }, []);
  return (
    <video
      ref={ref}
      src={src} srcSet={window.IMG && window.IMG.srcsetFor(src)} sizes={window.IMG && window.IMG.sizes()}
      muted
      loop
      playsInline
      preload="none"
      style={{
        width: "100%", height: "100%", objectFit: "cover", display: "block",
        transition: "transform 900ms cubic-bezier(0.2,0.6,0.2,1)",
        transform: hover ? "scale(1.02)" : "scale(1)",
      }}
    />
  );
}

function MoodTile({ item, collectionName, aspect }) {
  const [hover, setHover] = useStateMB(false);
  const isVideo = item.media === "video";

  // Drag-to-desktop. Set DownloadURL so Chrome/Edge save the file when
  // dragged out. Format: "<mime>:<filename>:<absolute-url>".
  const handleDragStart = (e) => {
    try {
      const filename = item.image.split("/").pop() || "derlot.webp";
      const ext = (filename.split(".").pop() || "webp").toLowerCase();
      const mime = ext === "png" ? "image/png" : (ext === "jpg" || ext === "jpeg") ? "image/jpeg"
        : ext === "mp4" ? "video/mp4" : "image/webp";
      const url = new URL(item.image, window.location.href).href;
      e.dataTransfer.setData("DownloadURL", `${mime}:${filename}:${url}`);
      e.dataTransfer.setData("text/uri-list", url);
      e.dataTransfer.setData("text/plain", url);
      e.dataTransfer.effectAllowed = "copy";
    } catch (_) {/* noop — browsers without DataTransfer fall back to default drag */}
  };

  return (
    <div
      draggable="true"
      data-mood-tile="1"
      onDragStart={handleDragStart}
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      title={`${collectionName} — drag to your desktop to save`}
      style={{
        position: "relative",
        // Exact intrinsic ratio (from window.IMG_DIMS) is locked in from the
        // first paint, so the tile never resizes when the image decodes — no
        // reflow, no reordering. The reserved ratio IS the image's true ratio,
        // so object-fit:cover shows the full image (no crop).
        aspectRatio: aspect,
        background: "#e4e2da",
        overflow: "hidden",
        cursor: hover ? "grabbing" : "grab",
        userSelect: "none",
        display: "block",
      }}>
      {isVideo ? (
        <MoodVideo src={item.image} srcSet={window.IMG && window.IMG.srcsetFor(item.image)} sizes={window.IMG && window.IMG.sizes()} hover={hover} />
      ) : (
      <img
        src={thumbFor(item.image)}
        srcSet={window.IMG && window.IMG.srcsetFor(item.image)}
        sizes={window.IMG && window.IMG.sizes("(max-width: 760px) 50vw, 25vw")}
        alt={collectionName}
        loading="lazy"
        draggable="false"
        decoding="async"
        onError={(e) => {
          const im = e.currentTarget;
          // Thumb missing → fall back to the original once; if that also
          // fails, hide the tile so no broken-image icon shows.
          if (im.dataset.fellBack) {
            const t = im.closest("[data-mood-tile]"); if (t) t.style.display = "none";
          } else {
            im.dataset.fellBack = "1";
            im.src = item.image;
          }
        }}
        style={{
          width: "100%", height: "100%", objectFit: "cover", display: "block",
          transition: "transform 900ms cubic-bezier(0.2,0.6,0.2,1)",
          transform: hover ? "scale(1.02)" : "scale(1)",
        }}
      />
      )}
      <div style={{
        position: "absolute", left: 0, right: 0, bottom: 0,
        padding: "16px 14px 12px",
        background: "linear-gradient(180deg, rgba(26,25,23,0) 0%, rgba(26,25,23,0.6) 100%)",
        opacity: hover ? 1 : 0,
        transition: "opacity 240ms ease",
        pointerEvents: "none",
      }}>
        <div style={{
          fontSize: 10, letterSpacing: "0.16em", textTransform: "uppercase",
          color: "rgba(245,243,239,0.7)", fontWeight: 500, marginBottom: 4,
        }}>Drag to save</div>
        <div style={{
          fontSize: 14, letterSpacing: "-0.01em",
          color: "#f5f3ef", fontWeight: 500,
        }}>{collectionName}</div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Filter dropdown                                                     */
/* ------------------------------------------------------------------ */

const { useRef: useRefMB, useEffect: useEffectMB } = React;

function CollectionFilterDropdown({ value, onChange, options }) {
  const [open, setOpen] = useStateMB(false);
  const ref = useRefMB(null);

  useEffectMB(() => {
    if (!open) return;
    const onDocClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDocClick);
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDocClick);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const current = options.find(o => o.slug === value) || options[0];

  return (
    <div ref={ref} style={{ position: "relative", display: "inline-block" }}>
      <button
        onClick={() => setOpen(!open)}
        style={{
          fontFamily: "inherit",
          fontSize: 13,
          letterSpacing: "0.02em",
          fontWeight: 500,
          color: "#1a1917",
          background: "transparent",
          border: "0.5px solid #1a1917",
          borderRadius: 0,
          padding: "10px 16px",
          cursor: "pointer",
          display: "inline-flex",
          alignItems: "center",
          gap: 10,
          minWidth: 220,
          justifyContent: "space-between",
        }}>
        <span>{current.name}</span>
        <span style={{
          display: "inline-block",
          transition: "transform 160ms ease",
          transform: open ? "rotate(180deg)" : "rotate(0deg)",
          fontSize: 10,
          color: "#6c6862",
        }}>▼</span>
      </button>
      {open && (
        <div style={{
          position: "absolute",
          top: "calc(100% - 0.5px)",
          left: 0,
          minWidth: 260,
          maxHeight: 360,
          overflowY: "auto",
          background: "#ffffff",
          border: "0.5px solid #1a1917",
          borderTop: "0.5px solid rgba(26,25,23,0.12)",
          padding: "8px 0",
          zIndex: 30,
          boxShadow: "0 12px 32px rgba(26,25,23,0.08)",
        }}>
          {options.map((opt) => (
            <button
              key={opt.slug}
              onClick={() => { onChange(opt.slug); setOpen(false); }}
              style={{
                display: "block",
                width: "100%",
                textAlign: "left",
                fontFamily: "inherit",
                fontSize: 14,
                lineHeight: 1.4,
                fontWeight: opt.slug === value ? 500 : 400,
                color: "#1a1917",
                background: "transparent",
                border: "none",
                padding: "10px 18px",
                cursor: "pointer",
                letterSpacing: "-0.005em",
              }}
              onMouseEnter={(e) => e.currentTarget.style.background = "#f5f3ef"}
              onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
            >
              {opt.name}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Moodboard page                                                      */
/* ------------------------------------------------------------------ */

function Moodboard() {
  const vw = useViewport();
  const isMobile = vw < 760;
  const cols = vw < 560 ? 2 : vw < 960 ? 3 : 4;

  const [filter, setFilter] = useStateMB("all");

  // Built once at module load from collection imagery + project photography.
  const MB_ITEMS = MB_ITEMS_ALL;

  // Build filter options from collections that have at least one moodboard image.
  const filterOptions = useMemoMB(() => {
    const slugsInBoard = new Set(MB_ITEMS.map((it) => it.slug));
    const colls = (window.COLLECTIONS || []).filter((c) => slugsInBoard.has(c.slug));
    return [{ slug: "all", name: "All" }, ...colls.map((c) => ({ slug: c.slug, name: c.name }))];
  }, [MB_ITEMS]);

  const nameFor = (slug) => {
    const c = (window.COLLECTIONS || []).find((cc) => cc.slug === slug);
    return c ? c.name : slug;
  };

  const filtered = useMemoMB(() => {
    if (filter === "all") return MB_ITEMS;
    return MB_ITEMS.filter((it) => it.slug === filter);
  }, [filter, MB_ITEMS]);

  // Windowing — mount the first batch, then reveal 50 more as the user nears
  // the bottom. Tiles reserve their height before the image loads (see
  // MoodTile), so the grid has real height from the first paint and the
  // sentinel below only enters view once the user actually scrolls — revealing
  // exactly one batch at a time instead of mounting everything up front.
  const PAGE = 50;
  const [visible, setVisible] = useStateMB(PAGE);
  useEffectMB(() => { setVisible(PAGE); }, [filter]);
  const sentinelRef = useRefMB(null);
  useEffectMB(() => {
    const el = sentinelRef.current;
    if (!el) return;
    const obs = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) setVisible((v) => Math.min(v + PAGE, filtered.length));
    }, { rootMargin: "1200px 0px" });
    obs.observe(el);
    return () => obs.disconnect();
  }, [filtered.length, visible]);

  // Fallback reveal — some embedded/preview iframes never fire
  // IntersectionObserver. A throttled scroll/resize listener bumps the window
  // when the viewport nears the bottom, so the board can never stall.
  useEffectMB(() => {
    let ticking = false;
    const check = () => {
      ticking = false;
      const nearBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 1500;
      if (nearBottom) setVisible((v) => (v < filtered.length ? Math.min(v + PAGE, filtered.length) : v));
    };
    const onScroll = () => { if (!ticking) { ticking = true; requestAnimationFrame(check); } };
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    check();
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
    };
  }, [filtered.length, visible]);

  const shown = filtered.slice(0, visible);

  // Fixed-column masonry. Each tile is packed into the currently-shortest
  // column (by accumulated relative height = 1/ratio), in order. Because
  // packing is a deterministic function of the item prefix + its exact ratio,
  // a tile's column NEVER changes as later batches append — so nothing the
  // user is looking at ever re-flows or hops columns. (CSS column-count, by
  // contrast, rebalances every item on each height change.)
  const columns = useMemoMB(() => {
    const heights = new Array(cols).fill(0);
    const buckets = Array.from({ length: cols }, () => []);
    shown.forEach((it, i) => {
      const r = tileRatio(it, i);
      let c = 0;
      for (let k = 1; k < cols; k++) if (heights[k] < heights[c]) c = k;
      buckets[c].push({ it, i, css: r.css });
      heights[c] += (r.num > 0 ? 1 / r.num : 1.25);
    });
    return buckets;
  }, [shown, cols]);

  return (
    <>
      <PageIntro
        eyebrow="Moodboard"
        title="A scrolling reference, for the start of a project."
        body="A working board of Derlot furniture in context — real installations, 3D renders and studio studies. Drag any image to your desktop to save it; filter by collection to focus a search."
      />

      {/* Filter bar */}
      <section style={{
        padding: isMobile ? "20px 20px" : "24px max(24px, 3vw)",
        maxWidth: 1440,
        margin: "0 auto",
        borderTop: "0.5px solid rgba(26,25,23,0.18)",
        borderBottom: "0.5px solid rgba(26,25,23,0.18)",
        display: "flex",
        flexDirection: isMobile ? "column" : "row",
        alignItems: isMobile ? "flex-start" : "center",
        justifyContent: "space-between",
        gap: isMobile ? 14 : 24,
      }}>
        <CollectionFilterDropdown value={filter} onChange={setFilter} options={filterOptions} />
        <div style={{ fontSize: 13, color: "#6c6862" }}>
          <b style={{ fontWeight: 500, color: "#1a1917" }}>{filtered.length}</b>{" "}
          {filtered.length === 1 ? "image" : "images"}
          {filter !== "all" && <span> · {nameFor(filter)}</span>}
        </div>
      </section>

      {/* Grid */}
      <section style={{
        padding: isMobile ? "32px 20px 96px" : "48px max(24px, 3vw) 120px",
        maxWidth: 1440, margin: "0 auto",
      }}>
        {filtered.length === 0 ? (
          <div style={{ padding: "120px 0", textAlign: "center", color: "#6c6862", fontSize: 15 }}>
            No images yet for this collection.
          </div>
        ) : (
          <div style={{ display: "flex", alignItems: "flex-start", gap: 2 }}>
            {columns.map((col, ci) => (
              <div key={ci} style={{ flex: "1 1 0", minWidth: 0, display: "flex", flexDirection: "column", gap: 2 }}>
                {col.map(({ it, i, css }) => (
                  <MoodTile
                    key={`${it.slug}-${i}`}
                    item={it}
                    collectionName={nameFor(it.slug)}
                    aspect={css}
                  />
                ))}
              </div>
            ))}
          </div>
        )}
        {visible < filtered.length && (
          <div ref={sentinelRef} style={{ height: 1 }} />
        )}
        {visible < filtered.length && (
          <div style={{ display: "flex", justifyContent: "center", marginTop: 28 }}>
            <button
              onClick={() => setVisible((v) => Math.min(v + PAGE, filtered.length))}
              style={{
                fontFamily: "inherit", fontSize: 13, letterSpacing: "0.08em",
                textTransform: "uppercase", fontWeight: 500, color: "#1a1917",
                background: "transparent", border: "0.5px solid #1a1917",
                padding: "12px 28px", cursor: "pointer",
              }}
              onMouseEnter={(e) => { e.currentTarget.style.background = "#1a1917"; e.currentTarget.style.color = "#f5f3ef"; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "#1a1917"; }}>
              Load more · {filtered.length - visible} left
            </button>
          </div>
        )}
      </section>

      {/* Quiet drag tip — only desktop */}
      {!isMobile && (
        <section style={{
          padding: "0 max(24px, 3vw) 96px",
          maxWidth: 1440, margin: "0 auto",
        }}>
          <div style={{
            fontSize: 12, letterSpacing: "0.04em", color: "#6c6862",
            borderTop: "0.5px solid rgba(26,25,23,0.18)",
            paddingTop: 20,
            maxWidth: "60ch",
          }}>
            Tip — drag any image straight to a Finder or Explorer window to download.
            For full-resolution renders and project photography, head to{" "}
            <a href="#" style={{ color: "#1a1917", borderBottom: "0.5px solid rgba(26,25,23,0.4)" }}>Downloads</a>.
          </div>
        </section>
      )}
    </>
  );
}

window.Moodboard = Moodboard;
