// Museum-style single-photo landing.
// One photo per visit, horizontal-first, archive metadata around it.

const { useMemo, useState, useEffect, useRef } = React;

// Single source of truth for the phone breakpoint. Components branch their
// inline styles on this so the desktop layout is untouched above 640px.
const MOBILE_QUERY = '(max-width: 640px)';
function useIsMobile() {
  const [m, setM] = useState(() =>
    typeof window !== 'undefined' && window.matchMedia
      ? window.matchMedia(MOBILE_QUERY).matches : false);
  useEffect(() => {
    if (!window.matchMedia) return;
    const mq = window.matchMedia(MOBILE_QUERY);
    const on = (e) => setM(e.matches);
    mq.addEventListener ? mq.addEventListener('change', on) : mq.addListener(on);
    return () => { mq.removeEventListener ? mq.removeEventListener('change', on) : mq.removeListener(on); };
  }, []);
  return m;
}
// Live viewport width, for sizing the photo frame fluidly on small screens.
function useViewportWidth() {
  const [w, setW] = useState(() => typeof window !== 'undefined' ? window.innerWidth : 1280);
  useEffect(() => {
    const on = () => setW(window.innerWidth);
    window.addEventListener('resize', on);
    return () => window.removeEventListener('resize', on);
  }, []);
  return w;
}
window.useIsMobile = useIsMobile;
window.useViewportWidth = useViewportWidth;

// Each photo is paired with a real track from the photographer's Spotify
// playlist (“Ambient”). When music is on, loading/reloading a photo loads and
// plays its track via Spotify's official IFrame embed. (Full tracks play when
// you're signed in to Spotify in this browser; otherwise a preview plays.)
const BASE_COLLECTION = [
  { id: '001', src: 'assets/photos/XACO5720.jpg',  title: 'Through the windshield', series: 'City · Motion',   year: 2024, location: 'İzmir, TR',     aspect: '16:9',  medium: 'Digital · Print',        edition: '01/08',
    track: { name: 'Tainted',              artist: 'Christoffer Franzen',          uri: 'spotify:track:42nPecfe1kDJIJJFVWM6J1' } },
  { id: '002', src: 'assets/photos/XACO5601.jpg',  title: 'Anvil cloud over Karşıyaka', series: 'Sky',         year: 2024, location: 'İzmir, TR',     aspect: '16:9',  medium: 'Digital · Print',        edition: '02/10',
    track: { name: 'Hiraeth',              artist: 'Scott Buckley',                uri: 'spotify:track:5ufCopwrQuarKWJv1joxmb' } },
  { id: '003', src: 'assets/photos/XACO5591.jpg',  title: 'Tracks at last light',       series: 'City · Dusk', year: 2024, location: 'İzmir, TR',     aspect: '16:9',  medium: 'Digital · Print',        edition: '01/12',
    track: { name: 'Hold This Place',      artist: 'Alice In Winter',              uri: 'spotify:track:5H5zOIZ864CWBq9mheQVSh' } },
  { id: '004', src: 'assets/photos/XACO5065.jpg',  title: 'Aegean rays',                series: 'Coast',       year: 2024, location: 'Bodrum, TR',    aspect: '16:9',  medium: 'Digital · Print',  edition: '03/12',
    track: { name: 'Corals Under The Sun', artist: 'Sivan Talmor, Yehezkel Raz',   uri: 'spotify:track:1YGstu0Rz9EKS6TweVeISB' } },
  { id: '005', src: 'assets/photos/ACOX9476.jpg',  title: 'Cereus, in motion',          series: 'Botanical',   year: 2025, location: 'Studio',        aspect: '4:5',   medium: 'Digital · Print',        edition: '01/06',
    track: { name: 'Papaver',              artist: 'Adam Weiss',                   uri: 'spotify:track:7oLEeE0yHYuOwAmaUJNnGn' } },
  { id: '006', src: 'assets/photos/XACO5375.jpg',  title: 'Bodrum, harbour from above', series: 'Coast',       year: 2024, location: 'Bodrum, TR',    aspect: '21:9',  medium: 'Digital · Print',        edition: '02/10',
    track: { name: 'Billions and Billions', artist: 'Stellardrone',                uri: 'spotify:track:34jk6E3JeCxxDscKMtDKcU' } },

  { id: '007', src: 'assets/photos/DSCF6492.jpg',  title: 'Pylon, coming apart',        series: 'Pylons',      year: 2025, location: 'İzmir, TR',     aspect: '16:9',  medium: 'Digital · Print',        edition: '01/08',
    track: { name: 'Hiraeth',              artist: 'Scott Buckley',                uri: 'spotify:track:5ufCopwrQuarKWJv1joxmb' } },
  { id: '008', src: 'assets/photos/DSCF6491.jpg',  title: 'Three reds on the wire',     series: 'Pylons',      year: 2025, location: 'İzmir, TR',     aspect: '16:9',  medium: 'Digital · Print',        edition: '01/08',
    track: { name: 'Billions and Billions', artist: 'Stellardrone',                uri: 'spotify:track:34jk6E3JeCxxDscKMtDKcU' } },
  { id: '009', src: 'assets/photos/DSCF5987.jpg',  title: 'Gibbous, through cloud',     series: 'Lunar',       year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '01/06',
    track: { name: 'Billions and Billions', artist: 'Stellardrone',                uri: 'spotify:track:34jk6E3JeCxxDscKMtDKcU' } },
  { id: '010', src: 'assets/photos/DSCF5855.jpg',  title: 'Crescent, obscured',         series: 'Lunar',       year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '02/06',
    track: { name: 'Hiraeth',              artist: 'Scott Buckley',                uri: 'spotify:track:5ufCopwrQuarKWJv1joxmb' } },
  { id: '011', src: 'assets/photos/DSCF5973.jpg',  title: 'Bodrum Castle at dusk',      series: 'Coast',       year: 2025, location: 'Bodrum, TR',    aspect: '21:9',  medium: 'Digital · Print',        edition: '01/10',
    track: { name: 'Corals Under The Sun', artist: 'Sivan Talmor, Yehezkel Raz',   uri: 'spotify:track:1YGstu0Rz9EKS6TweVeISB' } },
  { id: '012', src: 'assets/photos/DSCF5970.jpg',  title: 'Marina, blue hour',          series: 'Coast',       year: 2025, location: 'Bodrum, TR',    aspect: '21:9',  medium: 'Digital · Print',        edition: '01/10',
    track: { name: 'Hold This Place',      artist: 'Alice In Winter',              uri: 'spotify:track:5H5zOIZ864CWBq9mheQVSh' } },
  { id: '013', src: 'assets/photos/DSCF5937.jpg',  title: 'Cumulus at evening',         series: 'Sky',         year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '01/10',
    track: { name: 'Hiraeth',              artist: 'Scott Buckley',                uri: 'spotify:track:5ufCopwrQuarKWJv1joxmb' } },
  { id: '014', src: 'assets/photos/DSCF5486.jpg',  title: 'Antennas, last orange',      series: 'Overhead',    year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '01/12',
    track: { name: 'Tainted',              artist: 'Christoffer Franzen',          uri: 'spotify:track:42nPecfe1kDJIJJFVWM6J1' } },
  { id: '015', src: 'assets/photos/DSCF5801.jpg',  title: 'Pole against cumulus',       series: 'Overhead',    year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '02/12',
    track: { name: 'Papaver',              artist: 'Adam Weiss',                   uri: 'spotify:track:7oLEeE0yHYuOwAmaUJNnGn' } },
  { id: '016', src: 'assets/photos/DSCF5798.jpg',  title: 'Venus over the lines',       series: 'Overhead',    year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '03/12',
    track: { name: 'Hold This Place',      artist: 'Alice In Winter',              uri: 'spotify:track:5H5zOIZ864CWBq9mheQVSh' } },
  { id: '017', src: 'assets/photos/DSCF5786.jpg',  title: 'Oleander and the streetlamp', series: 'Overhead',   year: 2025, location: 'İzmir, TR',     aspect: '21:9',  medium: 'Digital · Print',        edition: '04/12',
    track: { name: 'Corals Under The Sun', artist: 'Sivan Talmor, Yehezkel Raz',   uri: 'spotify:track:1YGstu0Rz9EKS6TweVeISB' } },
];

// ─── Collection store ──────────────────────────────────────────
// The built-in works above are the base. Works added through the admin page
// — plus which works are hidden or deleted — live in localStorage so both the
// public page and the admin page read the same collection.
const STORE_KEY = 'mu_store_v1';

function loadStore() {
  try {
    const s = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
    return { added: s.added || [], hidden: s.hidden || [], deleted: s.deleted || [] };
  } catch (e) {
    return { added: [], hidden: [], deleted: [] };
  }
}

function saveStore(store) {
  try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); return true; }
  catch (e) { return false; }
}

function getCollection(store, opts) {
  const includeHidden = opts && opts.includeHidden;
  // Supabase mode: serve from the hydrated in-memory cache (MU.hydrate()
  // keeps it fresh). Hidden works are filtered here for the public view.
  if (window.MU && window.MU.configured) {
    const all = window.MU.cache || [];
    return includeHidden ? all : all.filter(p => !p.hidden);
  }
  // Fallback mode (no Supabase configured): built-ins + localStorage.
  const s = store || loadStore();
  const all = [...BASE_COLLECTION, ...s.added];
  return all.filter(p =>
    !s.deleted.includes(p.id) && (includeHidden || !s.hidden.includes(p.id))
  );
}

// In Supabase mode, seed the cache with the built-ins for an instant first
// paint; MU.hydrate() then swaps in the live database rows.
if (window.MU && window.MU.configured && !window.MU.cache) {
  window.MU.cache = BASE_COLLECTION.slice();
}

function nextWorkId(store) {
  const s = store || loadStore();
  let max = 0;
  [...BASE_COLLECTION, ...s.added].forEach(p => {
    const n = parseInt(p.id, 10);
    if (!isNaN(n) && n > max) max = n;
  });
  return String(max + 1).padStart(3, '0');
}

// A short library of selectable tracks, drawn from the built-in works.
const TRACK_LIBRARY = (() => {
  const seen = new Set(); const out = [];
  BASE_COLLECTION.forEach(p => {
    if (p.track && !seen.has(p.track.uri)) { seen.add(p.track.uri); out.push(p.track); }
  });
  return out;
})();

// Snap an arbitrary pixel ratio to the nearest named aspect.
function snapAspect(w, h) {
  const r = w / h;
  const cands = [['21:9', 21/9], ['16:9', 16/9], ['3:2', 3/2], ['4:3', 4/3], ['1:1', 1], ['4:5', 4/5], ['2:3', 2/3]];
  let best = cands[0], bd = Infinity;
  for (const c of cands) { const d = Math.abs(c[1] - r); if (d < bd) { bd = d; best = c; } }
  return best[0];
}

// Load a File, downscale it, return a JPEG data URL (keeps localStorage small).
function fileToScaledDataURL(file, maxDim, quality) {
  maxDim = maxDim || 1600; quality = quality || 0.82;
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(file);
    const img = new Image();
    img.onload = () => {
      const w = img.naturalWidth, h = img.naturalHeight;
      const scale = Math.min(1, maxDim / Math.max(w, h));
      const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
      const c = document.createElement('canvas');
      c.width = cw; c.height = ch;
      c.getContext('2d').drawImage(img, 0, 0, cw, ch);
      URL.revokeObjectURL(url);
      resolve({ dataURL: c.toDataURL('image/jpeg', quality), width: w, height: h });
    };
    img.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
    img.src = url;
  });
}

function aspectRatio(s) {
  const [a, b] = s.split(':').map(Number);
  return a / b;
}

// Pick the index — different from last visit, persisted in localStorage
function pickIndex(len) {
  const last = Number(localStorage.getItem('lastPhotoIdx') ?? -1);
  let next = Math.floor(Math.random() * len);
  if (len > 1 && next === last) next = (next + 1) % len;
  localStorage.setItem('lastPhotoIdx', String(next));
  return next;
}

// Brutalist token sets — light is the existing palette, dark is the inverse.
const THEMES = {
  light: {
    bg: '#e8e6df',
    fg: '#0a0a0a',
    rule: '#0a0a0a',
    muted: 'rgba(10,10,10,0.55)',
    veryMuted: 'rgba(10,10,10,0.4)',
    matte: '#dcd9d0',
    label: '#0a0a0a',
    panelBg: '#e8e6df',
  },
  dark: {
    bg: '#0e0e0c',
    fg: '#ebe7dc',
    rule: '#3a382f',
    muted: 'rgba(235,231,220,0.55)',
    veryMuted: 'rgba(235,231,220,0.35)',
    matte: '#16150f',
    label: '#ebe7dc',
    panelBg: '#16150f',
  },
};

// ─── Settings popover ────────────────────────────────────────────────
function SettingsPopover({ theme, setTheme, music, setMusic, onClose, anchorRef }) {
  const t = THEMES[theme];
  const ref = useRef(null);

  useEffect(() => {
    function onDoc(e) {
      if (!ref.current) return;
      if (ref.current.contains(e.target)) return;
      if (anchorRef?.current && anchorRef.current.contains(e.target)) return;
      onClose();
    }
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onKey);
    };
  }, [onClose, anchorRef]);

  const row = {
    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
    padding: '12px 14px', borderTop: `1px solid ${t.rule}`,
    fontSize: 10, letterSpacing: '0.12em',
  };
  const segWrap = { display: 'flex', border: `1px solid ${t.rule}` };
  const seg = (active) => ({
    padding: '6px 10px',
    fontFamily: 'inherit',
    fontSize: 10,
    letterSpacing: '0.15em',
    background: active ? t.fg : 'transparent',
    color: active ? t.bg : t.fg,
    border: 'none',
    cursor: 'pointer',
    transition: 'background 160ms, color 160ms',
  });

  return (
    <div
      ref={ref}
      style={{
        position: 'absolute',
        top: 'calc(100% + 10px)',
        right: 0,
        width: 280,
        background: t.panelBg,
        color: t.fg,
        border: `1px solid ${t.rule}`,
        boxShadow: theme === 'dark'
          ? '0 24px 60px rgba(0,0,0,0.6)'
          : '0 24px 60px rgba(0,0,0,0.18)',
        zIndex: 50,
        textAlign: 'left',
      }}
    >
      <div style={{
        padding: '10px 14px',
        fontSize: 9, letterSpacing: '0.18em', color: t.veryMuted,
        display: 'flex', justifyContent: 'space-between', alignItems: 'center',
      }}>
        <span>SETTINGS</span>
        <button
          onClick={onClose}
          style={{ background: 'transparent', border: 'none', color: t.muted, cursor: 'pointer', fontSize: 14, lineHeight: 1, padding: 0 }}
          aria-label="Close settings"
        >×</button>
      </div>

      <div style={row}>
        <span style={{ color: t.muted }}>THEME</span>
        <div style={segWrap}>
          <button style={seg(theme === 'light')} onClick={() => setTheme('light')}>LIGHT</button>
          <button style={seg(theme === 'dark')}  onClick={() => setTheme('dark')}>DARK</button>
        </div>
      </div>

      <div style={row}>
        <span style={{ color: t.muted }}>MUSIC</span>
        <div style={segWrap}>
          <button style={seg(music)}  onClick={() => setMusic(true)}>ON</button>
          <button style={seg(!music)} onClick={() => setMusic(false)}>OFF</button>
        </div>
      </div>
    </div>
  );
}

function Placard({ photo, theme, total, index }) {
  const t = THEMES[theme];
  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: '1fr 1fr 1fr 1fr',
      columnGap: 32,
      rowGap: 8,
      fontSize: 11,
      letterSpacing: '0.05em',
      color: t.fg,
      borderTop: `1px solid ${t.rule}`,
      paddingTop: 16,
    }}>
      <div>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>OBJ</div>
        <div>{photo.id} / {String(index + 1).padStart(3, '0')} OF {String(total).padStart(3, '0')}</div>
      </div>
      <div>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>TITLE</div>
        <div style={{ textTransform: 'none', fontSize: 13 }}>{photo.title}</div>
      </div>
      <div>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>SERIES</div>
        <div>{photo.series.toUpperCase()} · {photo.year}</div>
      </div>
      <div style={{ textAlign: 'right' }}>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>EDITION</div>
        <div>{photo.edition}</div>
      </div>
      <div style={{ gridColumn: '1 / 2' }}>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>LOC</div>
        <div>{photo.location.toUpperCase()}</div>
      </div>
      <div>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>ASPECT</div>
        <div>{photo.aspect}</div>
      </div>
      <div style={{ gridColumn: '3 / 5', textAlign: 'right' }}>
        <div style={{ color: t.veryMuted, marginBottom: 4 }}>MEDIUM</div>
        <div>{photo.medium.toUpperCase()}</div>
      </div>
    </div>
  );
}

// ─── Folder-tab sticking out on the right edge ───
function IndexTab({ theme, open, onClick }) {
  const t = THEMES[theme];
  const mobile = useIsMobile();
  return (
    <button
      onClick={onClick}
      aria-expanded={open}
      aria-label="Open index"
      style={{
        position: 'fixed',
        top: 'auto',
        bottom: mobile ? 18 : 51,
        right: 0,
        width: 34, height: 130,
        background: open ? t.fg : t.bg,
        color: open ? t.bg : t.fg,
        border: `1px solid ${t.rule}`,
        borderRight: open ? `1px solid ${t.rule}` : 'none',
        cursor: 'pointer',
        fontFamily: '"JetBrains Mono", monospace',
        fontSize: 11,
        letterSpacing: '0.28em',
        zIndex: 42,
        display: 'flex',
        alignItems: 'center', justifyContent: 'center',
        opacity: mobile && open ? 0 : 1,
        pointerEvents: mobile && open ? 'none' : 'auto',
        transform: (!mobile && open) ? 'translateX(-460px)' : 'translateX(0)',
        transition: 'background 200ms, color 200ms, transform 360ms cubic-bezier(.2,.8,.2,1), border-color 200ms, opacity 200ms',
        boxShadow: open ? 'none' : (theme === 'dark'
          ? '-8px 8px 24px rgba(0,0,0,0.5)'
          : '-6px 6px 18px rgba(0,0,0,0.08)'),
      }}
      onMouseEnter={(e) => { if (!open && !mobile) e.currentTarget.style.transform = 'translateX(-2px)'; }}
      onMouseLeave={(e) => { if (!open && !mobile) e.currentTarget.style.transform = 'translateX(0)'; }}
    >
      <span style={{
        writingMode: 'vertical-rl',
        transform: 'rotate(180deg)',
        display: 'inline-flex', alignItems: 'center', gap: 10,
      }}>
        <span aria-hidden="true">{open ? '→' : '←'}</span>
        INDEX
      </span>
    </button>
  );
}

// ─── Index drawer — slides in from the right with text-only entries ──
function IndexDrawer({ open, onClose, theme, currentIdx, onSelect, collection = [], organiseMode = false, hiddenIds, onToggleHide, onDelete, zineMode = false, placedIds, requestSeries, myOwn = false, myOwnImages = [], onToggleMyOwn, onUploadOwn, onClearOwn, onTapPlace }) {
  const t = THEMES[theme];
  const mobile = useIsMobile();
  const ownFileRef = useRef(null);

  const [fYear, setFYear] = useState('all');
  const [fAspect, setFAspect] = useState('all');
  const [fLocation, setFLocation] = useState('all');
  const [fSeries, setFSeries] = useState('all');

  // Unique sorted values for each facet
  const uniq = (key) => Array.from(new Set(collection.map(p => p[key])));
  const years = uniq('year').sort((a, b) => b - a);
  const aspects = uniq('aspect').sort();
  const locations = uniq('location').sort();
  // Series is grouped by primary category (text before the first ·), so it
  // matches the series bar on the page — "City · Motion" tallies under "City".
  const seriesKey = (p) => p.series.split('·')[0].trim();
  const series = Array.from(new Set(collection.map(seriesKey))).sort();

  // Apply a series filter requested from the page's series bar.
  useEffect(() => {
    if (requestSeries && requestSeries.n) setFSeries(requestSeries.value);
  }, [requestSeries && requestSeries.n]);

  const entries = collection
    .map((p, i) => ({ p, i }))
    .filter(({ p }) =>
      (fYear === 'all' || String(p.year) === fYear) &&
      (fAspect === 'all' || p.aspect === fAspect) &&
      (fLocation === 'all' || p.location === fLocation) &&
      (fSeries === 'all' || seriesKey(p) === fSeries)
    );

  const filtersActive = fYear !== 'all' || fAspect !== 'all' || fLocation !== 'all' || fSeries !== 'all';

  useEffect(() => {
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    if (open && !zineMode) document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose, zineMode]);

  // Custom dropdown: looks like a brutalist button, opens a popover list on click.
  const [openFilter, setOpenFilter] = useState(null);
  const filterRef = useRef(null);

  useEffect(() => {
    if (!openFilter) return;
    function onDoc(e) {
      if (filterRef.current && filterRef.current.contains(e.target)) return;
      setOpenFilter(null);
    }
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [openFilter]);

  const FilterCell = ({ id, label, value, onChange, options, formatOpt, width = 140 }) => {
    const isOpen = openFilter === id;
    const active = value !== 'all';
    const display = active
      ? (formatOpt ? formatOpt(value) : String(value).toUpperCase())
      : label;

    return (
      <div style={{ position: 'relative', width, flex: 'none' }}>
        <button
          onClick={(e) => { e.stopPropagation(); setOpenFilter(isOpen ? null : id); }}
          style={{
            display: 'flex', alignItems: 'center', justifyContent: 'space-between',
            width: '100%',
            background: active ? t.fg : 'transparent',
            color: active ? t.bg : t.fg,
            border: `1px solid ${t.rule}`,
            padding: '7px 8px',
            fontFamily: 'inherit',
            fontSize: 10, letterSpacing: '0.14em',
            cursor: 'pointer',
            transition: 'background 140ms, color 140ms',
            whiteSpace: 'nowrap',
            textAlign: 'left',
          }}
          aria-expanded={isOpen}
        >
          <span style={{
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0,
          }}>{display}</span>
          <svg width="8" height="8" viewBox="0 0 10 10" style={{
            flex: 'none',
            transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
            transition: 'transform 160ms',
          }} fill="none" stroke="currentColor" strokeWidth="1.4">
            <path d="M1.5 3.5L5 7L8.5 3.5" />
          </svg>
        </button>

        {isOpen && (
          <div
            ref={filterRef}
            style={{
              position: 'absolute', top: 'calc(100% + 4px)', left: 0,
              minWidth: '100%',
              background: t.bg,
              border: `1px solid ${t.rule}`,
              boxShadow: theme === 'dark'
                ? '0 16px 40px rgba(0,0,0,0.6)'
                : '0 16px 40px rgba(0,0,0,0.15)',
              zIndex: 5,
              maxHeight: 240, overflowY: 'auto',
              whiteSpace: 'nowrap',
            }}
          >
            {[['all', 'ALL'], ...options.map(o => [String(o), formatOpt ? formatOpt(o) : String(o).toUpperCase()])].map(([val, lbl]) => {
              const selected = val === String(value);
              return (
                <button
                  key={val}
                  onClick={() => { onChange(val); setOpenFilter(null); }}
                  style={{
                    display: 'block', width: '100%', textAlign: 'left',
                    background: selected ? (theme === 'dark' ? 'rgba(235,231,220,0.08)' : 'rgba(10,10,10,0.06)') : 'transparent',
                    color: t.fg,
                    border: 'none',
                    borderBottom: `1px solid ${t.rule}`,
                    padding: '8px 14px',
                    fontFamily: 'inherit', fontSize: 10, letterSpacing: '0.18em',
                    cursor: 'pointer',
                  }}
                  onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = theme === 'dark' ? 'rgba(235,231,220,0.05)' : 'rgba(10,10,10,0.04)'; }}
                  onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
                >{lbl}</button>
              );
            })}
          </div>
        )}
      </div>
    );
  };

  return (
    <>
      {/* Scrim — starts below the header so top buttons remain reachable.
          Disabled in zine mode so works can be dragged onto the editor. */}
      <div
        onClick={onClose}
        style={{
          position: 'fixed', top: mobile ? 0 : 89, left: 0, right: 0, bottom: 0,
          background: theme === 'dark' ? 'rgba(0,0,0,0.45)' : 'rgba(10,10,10,0.18)',
          opacity: open && (!zineMode || mobile) ? 1 : 0,
          pointerEvents: open && (!zineMode || mobile) ? 'auto' : 'none',
          transition: 'opacity 240ms ease',
          zIndex: 40,
        }}
      />
      {/* Drawer — slides out leftward from behind the folder tab on the right edge */}
      <aside
        aria-hidden={!open}
        style={{
          position: 'fixed',
          top: mobile ? 0 : 89, right: 0, bottom: 0,
          width: mobile ? '100vw' : 460, maxWidth: mobile ? '100vw' : '92vw',
          background: t.bg,
          color: t.fg,
          border: `1px solid ${t.rule}`,
          borderRight: 'none',
          transform: open ? 'translateX(0)' : 'translateX(100%)',
          transition: 'transform 360ms cubic-bezier(.2,.8,.2,1)',
          pointerEvents: open ? 'auto' : 'none',
          boxShadow: open
            ? (theme === 'dark' ? '-30px 0 80px rgba(0,0,0,0.6)' : '-30px 0 80px rgba(0,0,0,0.18)')
            : 'none',
          zIndex: 41,
          display: 'flex', flexDirection: 'column',
          fontFamily: '"JetBrains Mono", monospace',
        }}
      >
        {/* Drawer header */}
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
          padding: '20px 24px',
          borderBottom: `1px solid ${t.rule}`,
        }}>
          <div>
            <div style={{ fontSize: 9, letterSpacing: '0.2em', color: t.veryMuted }}>{zineMode ? (myOwn ? 'YOUR IMAGES · BROWSER ONLY' : (mobile ? 'TAP A WORK → ADDS TO CURRENT PAGE' : 'DRAG A WORK → DROP ON A PAGE')) : 'ARCHIVE'}</div>
            <div style={{ fontSize: 13, letterSpacing: '0.08em', marginTop: 4 }}>
              INDEX · {String(entries.length).padStart(3, '0')}
              {filtersActive && <span style={{ color: t.veryMuted }}> / {String(collection.length).padStart(3, '0')}</span>}
              {' '}{myOwn ? (entries.length === 1 ? 'IMAGE' : 'IMAGES') : 'WORKS'}
            </div>
          </div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            {zineMode && (
              <button
                onClick={() => onToggleMyOwn && onToggleMyOwn()}
                aria-pressed={myOwn}
                title="Build a zine from your own uploaded images (kept in this browser only)"
                style={{
                  background: myOwn ? t.fg : 'transparent', color: myOwn ? t.bg : t.fg,
                  border: `1px solid ${t.rule}`, padding: '6px 11px', cursor: 'pointer',
                  fontFamily: 'inherit', fontSize: 9, letterSpacing: '0.18em', whiteSpace: 'nowrap',
                  transition: 'background 140ms, color 140ms',
                }}
                onMouseEnter={(e) => { if (!myOwn) { e.currentTarget.style.background = t.fg; e.currentTarget.style.color = t.bg; } }}
                onMouseLeave={(e) => { if (!myOwn) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.fg; } }}
              >MY OWN</button>
            )}
            {filtersActive && !myOwn && (
              <button
                onClick={() => { setFYear('all'); setFAspect('all'); setFLocation('all'); setFSeries('all'); }}
                aria-label="Clear filters"
                title="Clear filters"
                style={{
                  background: 'transparent',
                  border: `1px solid ${t.rule}`,
                  color: t.fg,
                  width: 26, height: 26,
                  cursor: 'pointer',
                  display: 'flex', alignItems: 'center', justifyContent: 'center',
                  fontFamily: 'inherit', fontSize: 14, lineHeight: 1,
                  transition: 'background 160ms, color 160ms',
                }}
                onMouseEnter={(e) => { e.currentTarget.style.background = t.fg; e.currentTarget.style.color = t.bg; }}
                onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.fg; }}
              >×</button>
            )}
          </div>
        </div>

        {/* Filter strip — hidden when building from your own uploads */}
        {!myOwn && (
        <div style={{
          padding: '14px 20px',
          borderBottom: `1px solid ${t.rule}`,
          display: 'flex', flexWrap: mobile ? 'wrap' : 'nowrap', gap: 6, alignItems: 'center',
        }}>
          <FilterCell id="series"   label="SERIES"   value={fSeries}   onChange={setFSeries}   options={series} width={mobile ? '47%' : 120} />
          <FilterCell id="location" label="LOCATION" value={fLocation} onChange={setFLocation} options={locations} width={mobile ? '47%' : 106} />
          <FilterCell id="year"     label="YEAR"     value={fYear}     onChange={setFYear}     options={years} width={mobile ? '47%' : 74} />
          <FilterCell id="aspect"   label="ASPECT"   value={fAspect}   onChange={setFAspect}   options={aspects} formatOpt={(o) => o} width={mobile ? '47%' : 74} />
        </div>
        )}

        {/* Your-own upload panel — browser-only images for personal zines */}
        {zineMode && myOwn && (
          <div style={{ padding: '14px 20px', borderBottom: `1px solid ${t.rule}` }}>
            <div
              onClick={() => ownFileRef.current && ownFileRef.current.click()}
              onDragOver={(e) => { e.preventDefault(); }}
              onDrop={(e) => { e.preventDefault(); onUploadOwn && onUploadOwn(e.dataTransfer.files); }}
              style={{ border: `1px dashed ${t.rule}`, padding: '16px 12px', textAlign: 'center', cursor: 'pointer', fontSize: 10, letterSpacing: '0.16em', color: t.fg, transition: 'background 140ms' }}
              onMouseEnter={(e) => { e.currentTarget.style.background = theme === 'dark' ? 'rgba(235,231,220,0.04)' : 'rgba(10,10,10,0.03)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
            >＋ UPLOAD IMAGES</div>
            <input ref={ownFileRef} type="file" accept="image/*" multiple style={{ display: 'none' }}
              onChange={(e) => { onUploadOwn && onUploadOwn(e.target.files); e.target.value = ''; }} />
            <div style={{ marginTop: 10, fontSize: 8, letterSpacing: '0.1em', color: t.veryMuted, lineHeight: 1.8 }}>
              FILES STAY IN THIS BROWSER ONLY — NOTHING IS UPLOADED TO ANY SERVER, AND THEY CLEAR WHEN YOU CLOSE THE TAB.
            </div>
            {myOwnImages.length > 0 && (
              <button onClick={() => onClearOwn && onClearOwn()}
                style={{ marginTop: 8, background: 'none', border: 'none', color: t.muted, cursor: 'pointer', fontFamily: 'inherit', fontSize: 8.5, letterSpacing: '0.14em', textDecoration: 'underline', padding: 0 }}>CLEAR ALL UPLOADS</button>
            )}
          </div>
        )}

        {/* Entries */}
        <div style={{ flex: 1, overflowY: 'auto' }}>
          {entries.length === 0 && (
            <div style={{
              padding: '24px',
              fontSize: 10, letterSpacing: '0.18em', color: t.veryMuted,
            }}>{myOwn ? 'NO IMAGES YET — UPLOAD SOME ABOVE.' : 'NO WORKS MATCH THESE FILTERS.'}</div>
          )}
          {entries.map(({ p, i }) => {
            const active = i === currentIdx;
            const hidden = hiddenIds && hiddenIds.has ? hiddenIds.has(p.id) : false;
            const isPlaced = zineMode && placedIds && placedIds.has(p.id);
            return (
              <div
                key={p.id}
                style={{
                  borderBottom: `1px solid ${t.rule}`,
                  borderLeft: active ? `2px solid ${t.fg}` : '2px solid transparent',
                  background: active ? (theme === 'dark' ? 'rgba(235,231,220,0.04)' : 'rgba(10,10,10,0.04)') : 'transparent',
                  opacity: hidden ? 0.42 : (isPlaced ? 0.5 : 1),
                  transition: 'opacity 160ms',
                }}
              >
                <button
                  draggable={zineMode && !mobile}
                  onDragStart={zineMode && !mobile ? (e) => {
                    e.dataTransfer.setData('text/zine-work', p.id);
                    e.dataTransfer.effectAllowed = 'copy';
                  } : undefined}
                  onClick={() => {
                    if (zineMode) { if (onTapPlace) onTapPlace(p.id); return; }
                    onSelect(i); if (!organiseMode) onClose();
                  }}
                  style={{
                    display: 'block',
                    width: '100%', textAlign: 'left',
                    padding: organiseMode ? '10px 24px 4px' : '10px 24px',
                    background: 'transparent',
                    color: t.fg,
                    border: 'none',
                    cursor: zineMode ? (mobile ? 'pointer' : 'grab') : 'pointer',
                    fontFamily: 'inherit',
                    transition: 'background 160ms',
                  }}
                  onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = theme === 'dark' ? 'rgba(235,231,220,0.03)' : 'rgba(10,10,10,0.03)'; }}
                  onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
                >
                  <div style={{
                    display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
                    gap: 12, marginBottom: 3,
                  }}>
                    <span style={{
                      fontSize: 14, letterSpacing: '0.01em', lineHeight: 1.2,
                      minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                      textDecoration: hidden ? 'line-through' : 'none',
                    }}>{p.title}</span>
                    <span style={{
                      fontSize: 9, letterSpacing: '0.18em', color: t.veryMuted, flex: 'none',
                    }}>{isPlaced ? '✓ ' : ''}{p.year} · {p.aspect}</span>
                  </div>
                  <div style={{
                    fontSize: 9, letterSpacing: '0.12em', color: t.muted,
                  }}>
                    {p.series.toUpperCase()} · {p.location.toUpperCase()}
                  </div>
                </button>
                {organiseMode && (
                  <div style={{ display: 'flex', gap: 8, padding: '0 24px 12px', alignItems: 'center' }}>
                    <button
                      onClick={() => onToggleHide && onToggleHide(p.id)}
                      style={{
                        fontFamily: 'inherit', fontSize: 9, letterSpacing: '0.16em',
                        padding: '5px 10px', cursor: 'pointer',
                        background: hidden ? t.fg : 'transparent', color: hidden ? t.bg : t.fg,
                        border: `1px solid ${t.rule}`, transition: 'background 140ms, color 140ms',
                      }}
                    >{hidden ? 'SHOW' : 'HIDE'}</button>
                    <button
                      onClick={() => onDelete && onDelete(p.id)}
                      style={{
                        fontFamily: 'inherit', fontSize: 9, letterSpacing: '0.16em',
                        padding: '5px 10px', cursor: 'pointer',
                        background: 'transparent', color: t.fg,
                        border: `1px solid ${t.rule}`, transition: 'background 140ms, color 140ms, border-color 140ms',
                      }}
                      onMouseEnter={(e) => { e.currentTarget.style.background = '#b3261e'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = '#b3261e'; }}
                      onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.fg; e.currentTarget.style.borderColor = t.rule; }}
                    >DELETE</button>
                    {hidden && <span style={{ fontSize: 8, letterSpacing: '0.2em', color: t.veryMuted }}>HIDDEN FROM SITE</span>}
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {/* Drawer footer */}
        <div style={{
          padding: '14px 24px',
          borderTop: `1px solid ${t.rule}`,
          fontSize: 9, letterSpacing: '0.18em', color: t.veryMuted,
          display: 'flex', justifyContent: 'space-between',
        }}>
          <span>ESC TO CLOSE</span>
          <span>UPDATED 2026.06.29</span>
        </div>
      </aside>
    </>
  );
}

function Header({ theme, setTheme, music, setMusic, photographerName, onNext, onOpenAbout, siteName = 'MUSEUM OF FORGOTTEN MOMENTS' }) {
  const t = THEMES[theme];
  const mobile = useIsMobile();
  const [openSettings, setOpenSettings] = useState(false);
  const settingsBtnRef = useRef(null);

  const navLinkStyle = { color: 'inherit', textDecoration: 'none', cursor: 'pointer', background: 'transparent', border: 'none', fontFamily: 'inherit', fontSize: 11, letterSpacing: '0.05em', padding: 0 };

  const nextBtn = (
    <button
      onClick={onNext}
      style={{ ...navLinkStyle, display: 'inline-flex', alignItems: 'center', gap: 6 }}
      onMouseEnter={(e) => { e.currentTarget.style.opacity = 0.7; }}
      onMouseLeave={(e) => { e.currentTarget.style.opacity = 1; }}
      aria-label="Next work"
    >
      NEXT WORK
      <span aria-hidden="true">→</span>
    </button>
  );
  const settingsBtn = (
    <button
      ref={settingsBtnRef}
      onClick={() => setOpenSettings(v => !v)}
      style={{ ...navLinkStyle, display: 'inline-flex', alignItems: 'center', gap: 6, color: openSettings ? t.fg : 'inherit' }}
      aria-expanded={openSettings}
      aria-label="Settings"
    >
      <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ display: 'block' }}>
        <circle cx="12" cy="12" r="3" />
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
      </svg>
      SETTINGS
    </button>
  );
  const aboutBtn = (
    <button
      onClick={onOpenAbout}
      style={navLinkStyle}
      onMouseEnter={(e) => { e.currentTarget.style.opacity = 0.7; }}
      onMouseLeave={(e) => { e.currentTarget.style.opacity = 1; }}
    >ABOUT</button>
  );
  const popover = openSettings && (
    <SettingsPopover
      theme={theme}
      setTheme={setTheme}
      music={music}
      setMusic={setMusic}
      onClose={() => setOpenSettings(false)}
      anchorRef={settingsBtnRef}
    />
  );

  if (mobile) {
    // Identity on the left; a control cluster on the right — SETTINGS + ABOUT
    // side by side on top, NEXT WORK on its own line below.
    return (
      <header style={{
        display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 14,
        padding: '14px 18px',
        borderBottom: `1px solid ${t.rule}`,
        fontSize: 11, letterSpacing: '0.05em', color: t.fg,
      }}>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0 }}>
          <div style={{ fontSize: 12, letterSpacing: '0.08em' }}>{photographerName.toUpperCase()}</div>
          <div style={{ fontSize: 9.5, letterSpacing: '0.12em', color: t.muted }}>{siteName}</div>
        </div>
        <nav style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, position: 'relative', flex: 'none' }}>
          <div style={{ display: 'flex', gap: 18, alignItems: 'center' }}>
            {settingsBtn}
            {aboutBtn}
          </div>
          {nextBtn}
          {popover}
        </nav>
      </header>
    );
  }

  return (
    <header style={{
      display: 'grid',
      gridTemplateColumns: '1fr 1fr 1fr 1fr',
      padding: '20px 32px',
      borderBottom: `1px solid ${t.rule}`,
      fontSize: 11,
      letterSpacing: '0.05em',
      color: t.fg,
    }}>
      <div>{photographerName.toUpperCase()}</div>
      <div>{siteName}</div>
      <div>2026.06 · v.01</div>
      <nav style={{ textAlign: 'right', display: 'flex', gap: 20, justifyContent: 'flex-end', alignItems: 'center', position: 'relative' }}>
        {nextBtn}
        {settingsBtn}
        {aboutBtn}
        {popover}
      </nav>
    </header>
  );
}

function Footer({ theme }) {
  const t = THEMES[theme];
  const mobile = useIsMobile();
  return (
    <footer style={{
      display: 'grid', gridTemplateColumns: mobile ? '1fr 1fr' : '1fr 1fr 1fr 1fr',
      gap: mobile ? '6px 16px' : 0,
      padding: mobile ? '14px 18px' : '14px 32px',
      borderTop: `1px solid ${t.rule}`,
      fontSize: 10, letterSpacing: '0.08em',
      color: t.veryMuted,
    }}>
      <div>STATUS / OPEN</div>
      <div style={mobile ? { textAlign: 'right' } : undefined}>UPDATED / 2026.06.29</div>
      <div>ENTRIES / 101</div>
      <div style={{ textAlign: 'right' }}>RANDOMIZED ON LOAD</div>
    </footer>
  );
}

const ASPECT_CAPS = {
  '21:9': { maxW: 1100, maxH: 480 },
  '19:6': { maxW: 1150, maxH: 380 },
  '16:9': { maxW: 880,  maxH: 500 },
  '4:5':  { maxW: 460,  maxH: 580 },
  'default': { maxW: 900, maxH: 520 },
};

function PhotoStage({ photo, theme, refreshKey, music, onToggleMusic, total, diaryEntries, onOpenDiary }) {
  const t = THEMES[theme];
  const mobile = useIsMobile();
  const vw = useViewportWidth();
  const ratio = aspectRatio(photo.aspect);
  const cap = ASPECT_CAPS[photo.aspect] || ASPECT_CAPS.default;

  const heightFromMaxW = cap.maxW / ratio;
  const widthBound = heightFromMaxW <= cap.maxH;
  const baseFrame = widthBound
    ? { width: cap.maxW, height: heightFromMaxW }
    : { width: cap.maxH * ratio, height: cap.maxH };

  // Fit the frame to the viewport on small screens. The matte adds `mattePad`
  // of horizontal padding each side; the stage adds `sidePad`. Scale the frame
  // (and its image) down proportionally if it would overflow.
  const sidePad = mobile ? 14 : 64;
  const mattePad = mobile ? 12 : 18;
  const vPad = mobile ? 54 : 64;
  const availW = Math.max(180, vw - sidePad * 2 - mattePad * 2);
  let fw = baseFrame.width, fh = baseFrame.height;
  if (fw > availW) { fh = Math.round(fh * (availW / fw)); fw = Math.round(availW); }
  const frameStyle = { width: fw, height: fh };
  const mountRef = useRef(null);

  // Print the work exactly as displayed — the whole mount (frame, title, object
  // label, photograph, medium & edition), not just the bare image. Clones the
  // live mount, makes image srcs absolute, drops screen-only chrome, and scales
  // it to fit one page (orientation picked from the mount's proportions).
  const printImage = () => {
    const node = mountRef.current;
    if (!node) return;

    const clone = node.cloneNode(true);
    clone.style.boxShadow = 'none';
    clone.style.margin = '0';
    // Remove interactive / screen-only bits (the field-note tab).
    clone.querySelectorAll('[aria-label="Open field note for this work"]').forEach(el => el.remove());
    // Resolve image srcs to absolute + cancel the entrance animation so the
    // photograph prints fully visible.
    clone.querySelectorAll('img').forEach(im => {
      try { im.setAttribute('src', im.src); } catch (e) { /* ignore */ }
      im.style.animation = 'none';
      im.style.opacity = '1';
    });

    // Print on a blank sheet: drop the matte ground and remap the themed colors
    // to dark-on-white so only the photograph + label text remain — regardless
    // of whether the site is currently in light or dark theme.
    const TT = THEMES[theme];
    const PAPER = { fg: '#111111', muted: 'rgba(17,17,17,0.62)', veryMuted: 'rgba(17,17,17,0.42)', rule: '#111111', bg: '#ffffff' };
    const canon = (c) => { if (!c) return ''; const s = document.createElement('span'); s.style.color = c; return (s.style.color || '').replace(/\s+/g, '').toLowerCase(); };
    const cmap = new Map();
    cmap.set(canon(TT.fg), PAPER.fg);
    cmap.set(canon(TT.muted), PAPER.muted);
    cmap.set(canon(TT.veryMuted), PAPER.veryMuted);
    cmap.set(canon(TT.rule), PAPER.rule);
    cmap.set(canon(TT.matte), PAPER.bg);
    cmap.set(canon(TT.bg), PAPER.bg);
    const remap = (el) => {
      if (!el.style) return;
      ['color', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor'].forEach(prop => {
        const cur = el.style[prop];
        if (cur) { const hit = cmap.get(canon(cur)); if (hit) el.style[prop] = hit; }
      });
      const bg = el.style.backgroundColor || el.style.background;
      if (bg) { const hit = cmap.get(canon(bg)); if (hit) el.style.background = hit; }
    };
    remap(clone);
    clone.querySelectorAll('*').forEach(remap);
    clone.style.background = PAPER.bg;

    const mountW = node.offsetWidth || (frameStyle.width + mattePad * 2);
    const mountH = node.offsetHeight || (frameStyle.height + vPad * 2);
    const landscape = mountW >= mountH;
    const PXMM = 96 / 25.4, MARGIN = 12;
    const page = landscape ? { w: 297, h: 210 } : { w: 210, h: 297 };
    const printW = (page.w - MARGIN * 2) * PXMM;
    const printH = (page.h - MARGIN * 2) * PXMM;
    const scale = Math.min(printW / mountW, printH / mountH, 1);
    const boxW = Math.round(mountW * scale), boxH = Math.round(mountH * scale);

    // Scale from top-left inside a box sized to the SCALED dimensions, so print
    // pagination uses the fitted size (transforms don't affect layout otherwise).
    clone.style.position = 'absolute';
    clone.style.top = '0';
    clone.style.left = '0';
    clone.style.transform = 'scale(' + scale + ')';
    clone.style.transformOrigin = 'top left';

    const title = String(photo.title || 'Work').replace(/[<>&]/g, ' ');
    const frame = document.createElement('iframe');
    frame.setAttribute('aria-hidden', 'true');
    frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;';
    document.body.appendChild(frame);
    const doc = frame.contentWindow.document;
    doc.open();
    doc.write(
      '<!doctype html><html><head><meta charset="utf-8"><title>' + title + '</title>' +
      '<link rel="preconnect" href="https://fonts.googleapis.com">' +
      '<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">' +
      '<style>' +
      '@page{size:A4 ' + (landscape ? 'landscape' : 'portrait') + ';margin:' + MARGIN + 'mm}' +
      '*{box-sizing:border-box}' +
      'html,body{margin:0;padding:0;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;' +
      'font-family:"JetBrains Mono",monospace}' +
      '.wrap{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh}' +
      '.box{position:relative;width:' + boxW + 'px;height:' + boxH + 'px}' +
      '</style></head><body><div class="wrap"><div class="box">' + clone.outerHTML + '</div></div></body></html>'
    );
    doc.close();

    const go = () => {
      try { frame.contentWindow.focus(); frame.contentWindow.print(); }
      finally { setTimeout(() => frame.remove(), 1500); }
    };
    const imgs = Array.from(doc.images || []);
    const waitImg = (im) => new Promise(res => { if (im.complete) res(); else { im.onload = res; im.onerror = res; } });
    const fontsReady = (doc.fonts && doc.fonts.ready) ? doc.fonts.ready.catch(() => {}) : Promise.resolve();
    Promise.all([fontsReady, ...imgs.map(waitImg)]).then(() => setTimeout(go, 120));
  };

  return (
    <div style={{
      flex: '1 0 auto',
      minHeight: mobile ? 380 : 720,
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      padding: mobile ? '22px 14px 18px' : '40px 64px 24px',
      background: t.bg,
      position: 'relative',
    }}>
      <div ref={mountRef} style={{
        width: frameStyle.width + mattePad * 2,
        height: frameStyle.height + vPad * 2,
        position: 'relative',
        background: t.matte,
        padding: `${vPad}px ${mattePad}px`,
        boxShadow: theme === 'dark'
          ? '0 30px 80px rgba(0,0,0,0.6)'
          : '0 30px 80px rgba(0,0,0,0.12)',
      }}>
        {/* Field-note tab — sticks up from the top edge of the mount, like the
            INDEX / TOOLS folder tabs but horizontal. Only shown when a diary
            entry is attached to this work. */}
        {diaryEntries && diaryEntries.length > 0 && (
          <button
            onClick={onOpenDiary}
            title={diaryEntries.length > 1 ? `${diaryEntries.length} field notes on this work` : 'Field note on this work'}
            aria-label="Open field note for this work"
            style={{
              position: 'absolute',
              top: -26, left: 22,
              height: 26,
              padding: '0 12px',
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7,
              background: t.bg,
              color: t.fg,
              border: `1px solid ${t.rule}`,
              borderBottom: 'none',
              cursor: 'pointer',
              fontFamily: '"JetBrains Mono", monospace',
              fontSize: 9, letterSpacing: '0.2em',
              zIndex: 3,
              transition: 'transform 220ms cubic-bezier(.2,.8,.2,1), background 160ms, color 160ms',
              boxShadow: theme === 'dark' ? '0 -8px 20px rgba(0,0,0,0.45)' : '0 -6px 16px rgba(0,0,0,0.07)',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.background = t.fg; e.currentTarget.style.color = t.bg; }}
            onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.background = t.bg; e.currentTarget.style.color = t.fg; }}
          >
            <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" style={{ display: 'block' }} aria-hidden="true">
              <rect x="3" y="2" width="10.5" height="12" />
              <line x1="6" y1="2" x2="6" y2="14" />
              <line x1="8" y1="5.5" x2="11.5" y2="5.5" />
              <line x1="8" y1="8" x2="11.5" y2="8" />
              <line x1="8" y1="10.5" x2="10.5" y2="10.5" />
            </svg>
            {diaryEntries.length > 1 && (
              <span aria-hidden="true">{String(diaryEntries.length).padStart(2, '0')}</span>
            )}
          </button>
        )}
        {[
          { top: -1, left: -1, borderTop: `1px solid ${t.rule}`, borderLeft: `1px solid ${t.rule}` },
          { top: -1, right: -1, borderTop: `1px solid ${t.rule}`, borderRight: `1px solid ${t.rule}` },
          { bottom: -1, left: -1, borderBottom: `1px solid ${t.rule}`, borderLeft: `1px solid ${t.rule}` },
          { bottom: -1, right: -1, borderBottom: `1px solid ${t.rule}`, borderRight: `1px solid ${t.rule}` },
        ].map((p, i) => (
          <div key={i} style={{ position: 'absolute', width: 14, height: 14, ...p, pointerEvents: 'none' }} />
        ))}

        <div style={{
          position: 'absolute',
          top: 18, left: 18, right: 18,
          display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
          color: t.fg, fontFamily: '"JetBrains Mono", monospace',
        }}>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 15, letterSpacing: '0.02em', lineHeight: 1.2, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{photo.title}</div>
            <div style={{ fontSize: 10, letterSpacing: '0.12em', color: t.muted, marginTop: 4 }}>{photo.series.toUpperCase()} · {photo.year}</div>
          </div>
          <div style={{ textAlign: 'right', flex: 'none', marginLeft: 24 }}>
            <div style={{ fontSize: 10, letterSpacing: '0.12em', color: t.veryMuted }}>OBJ</div>
            <div style={{ fontSize: 13, letterSpacing: '0.05em', marginTop: 2 }}>{photo.id} / {String(photo._idx + 1).padStart(3, '0')} OF {String(total).padStart(3, '0')}</div>
          </div>
        </div>

        <img
          key={refreshKey + photo.id}
          src={photo.src}
          alt={photo.title}
          style={{
            display: 'block',
            width: frameStyle.width,
            height: frameStyle.height,
            objectFit: 'cover',
            background: t.matte,
            animation: 'photoIn 900ms cubic-bezier(.2,.8,.2,1) both',
          }}
        />

        <div style={{
          position: 'absolute',
          bottom: 18, left: 18, right: 18,
          display: 'flex', alignItems: 'center',
          color: t.veryMuted, fontFamily: '"JetBrains Mono", monospace',
          fontSize: 10, letterSpacing: '0.1em',
        }}>
          <span style={{ flex: 'none', whiteSpace: 'nowrap' }}>
            {(() => {
              const label = photo.medium.toUpperCase();
              const at = label.lastIndexOf('PRINT');
              if (at < 0) return label;
              return (
                <>
                  {label.slice(0, at)}
                  <button
                    onClick={printImage}
                    title="Print this photograph"
                    style={{
                      font: 'inherit', color: 'inherit', letterSpacing: 'inherit',
                      background: 'none', border: 'none', padding: 0, margin: 0,
                      cursor: 'pointer', textTransform: 'none', verticalAlign: 'baseline',
                      transition: 'color 160ms',
                    }}
                    onMouseEnter={(e) => { e.currentTarget.style.color = t.fg; }}
                    onMouseLeave={(e) => { e.currentTarget.style.color = 'inherit'; }}
                  >{label.slice(at)}</button>
                </>
              );
            })()}
          </span>
          <span style={{ flex: 1, height: 1, background: t.rule, opacity: 0.3, margin: '0 12px' }} />
          <span style={{ flex: 'none', whiteSpace: 'nowrap' }}>EDITION {photo.edition}</span>
        </div>
      </div>

      {photo.track && (
        <div style={{
          marginTop: 20,
          width: frameStyle.width + mattePad * 2,
          display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8,
          color: t.muted, fontFamily: '"JetBrains Mono", monospace',
          fontSize: 11, letterSpacing: '0.08em',
        }}>
          {/* Spotify-style equalizer bars (animated only when music is on) */}
          <span style={{ display: 'inline-flex', alignItems: 'flex-end', gap: 2, height: 10 }} aria-hidden="true">
            {[0, 1, 2].map(i => (
              <span key={i} style={{
                width: 2,
                background: 'currentColor',
                height: 4,
                animation: `eqBar 900ms ease-in-out ${i * 140}ms infinite alternate`,
                animationPlayState: music ? 'running' : 'paused',
                opacity: music ? 0.85 : 0.4,
              }} />
            ))}
          </span>
          <span style={{ letterSpacing: '0.02em', whiteSpace: 'nowrap' }}>
            {photo.track.name} <span style={{ color: t.veryMuted }}>· {photo.track.artist}</span>
          </span>
          <button
            onClick={onToggleMusic}
            aria-label={music ? 'Pause music' : 'Play music'}
            title={music ? 'Pause' : 'Play this track'}
            style={{
              flex: 'none',
              width: 24, height: 24,
              display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
              background: 'transparent',
              color: t.fg,
              border: 'none',
              cursor: 'pointer',
              padding: 0,
              opacity: music ? 1 : 0.7,
              transition: 'opacity 160ms',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.opacity = 1; }}
            onMouseLeave={(e) => { e.currentTarget.style.opacity = music ? 1 : 0.7; }}
          >
            {music ? (
              <svg width="9" height="9" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
                <rect x="2" y="1.5" width="2.6" height="9" />
                <rect x="7.4" y="1.5" width="2.6" height="9" />
              </svg>
            ) : (
              <svg width="9" height="9" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" style={{ marginLeft: 1 }}>
                <path d="M2.5 1.5L10 6L2.5 10.5Z" />
              </svg>
            )}
          </button>
        </div>
      )}
    </div>
  );
}

// ─── Music engine — real Spotify playback via the official IFrame embed ───
// Each photo carries a Spotify track URI. When music is on, loading a photo
// loads + plays its track. Full playback requires being signed in to Spotify
// in this browser; otherwise Spotify serves a preview.
const SPOTIFY_DEFAULT_URI = 'spotify:track:42nPecfe1kDJIJJFVWM6J1';
let _spotifyAPIPromise = null;
function getSpotifyAPI() {
  if (_spotifyAPIPromise) return _spotifyAPIPromise;
  _spotifyAPIPromise = new Promise((resolve) => {
    if (window.__spotifyIFrameAPI) { resolve(window.__spotifyIFrameAPI); return; }
    window.onSpotifyIframeApiReady = (IFrameAPI) => {
      window.__spotifyIFrameAPI = IFrameAPI;
      resolve(IFrameAPI);
    };
  });
  return _spotifyAPIPromise;
}

function useSpotifyPlayer(enabled, uri) {
  const hostRef = useRef(null);
  const controllerRef = useRef(null);
  const lastUriRef = useRef(null);
  const desiredRef = useRef({ enabled, uri });
  desiredRef.current = { enabled, uri };

  const apply = () => {
    const c = controllerRef.current;
    if (!c) return;
    const { enabled, uri } = desiredRef.current;
    if (enabled && uri) {
      if (lastUriRef.current !== uri) {
        try { c.loadUri(uri); } catch (e) {}
        lastUriRef.current = uri;
      }
      try { c.play(); } catch (e) {}
    } else {
      try { c.pause(); } catch (e) {}
    }
  };

  // Create the controller once.
  useEffect(() => {
    let cancelled = false;
    getSpotifyAPI().then((IFrameAPI) => {
      if (cancelled || !hostRef.current || controllerRef.current) return;
      const startUri = desiredRef.current.uri || SPOTIFY_DEFAULT_URI;
      IFrameAPI.createController(
        hostRef.current,
        { uri: startUri, width: 300, height: 80 },
        (controller) => {
          controllerRef.current = controller;
          lastUriRef.current = startUri;
          controller.addListener('ready', () => { apply(); });
        }
      );
    });
    return () => {
      cancelled = true;
      if (controllerRef.current) { try { controllerRef.current.destroy(); } catch (e) {} }
    };
  // eslint-disable-next-line
  }, []);

  // React to enabled / uri changes.
  useEffect(() => { apply(); }, [enabled, uri]);

  return hostRef;
}

// Day → light, evening/night → dark. Treats 7:00–18:59 as daytime.
function themeFromTime() {
  const h = new Date().getHours();
  return (h >= 7 && h < 19) ? 'light' : 'dark';
}

// ─── About overlay — statement + contact, opened from the ABOUT nav link.
// Content is editable from the admin page (stored via the data layer); the
// object passed in is { statement, links:[{id,label,value,kind}] }. ──
function AboutOverlay({ open, onClose, theme, photographerName, about }) {
  const t = THEMES[theme];
  const data = about || (window.MU && window.MU.DEFAULT_ABOUT) || { statement: '', links: [] };

  useEffect(() => {
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    if (open) document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  const rowLabel = { fontSize: 9, letterSpacing: '0.2em', color: t.veryMuted, marginBottom: 5 };
  const rowVal = { fontSize: 13, letterSpacing: '0.04em', color: t.fg };
  const linkVal = { ...rowVal, textDecoration: 'none', transition: 'opacity 160ms' };

  // Render one contact entry by kind: email → mailto, link → external, text → plain.
  const renderEntry = (e) => {
    const hover = {
      onMouseEnter: (ev) => { ev.currentTarget.style.opacity = 0.65; },
      onMouseLeave: (ev) => { ev.currentTarget.style.opacity = 1; },
    };
    if (e.kind === 'email') {
      return <a href={`mailto:${e.value}`} style={linkVal} {...hover}>{e.value}</a>;
    }
    if (e.kind === 'link') {
      const href = /^https?:\/\//i.test(e.value) ? e.value : 'https://' + e.value;
      const display = e.value.replace(/^https?:\/\//i, '');
      return <a href={href} target="_blank" rel="noopener noreferrer" style={linkVal} {...hover}>{display}</a>;
    }
    return <div style={rowVal}>{e.value}</div>;
  };

  const links = data.links || [];
  const statementLines = String(data.statement || '').split('\n');

  return (
    <>
      <div
        onClick={onClose}
        style={{
          position: 'fixed', inset: 0,
          background: theme === 'dark' ? 'rgba(0,0,0,0.55)' : 'rgba(10,10,10,0.22)',
          opacity: open ? 1 : 0,
          pointerEvents: open ? 'auto' : 'none',
          transition: 'opacity 240ms ease',
          zIndex: 60,
        }}
      />
      <div
        role="dialog"
        aria-modal="true"
        aria-label="About"
        aria-hidden={!open}
        style={{
          position: 'fixed', top: '50%', left: '50%',
          transform: open ? 'translate(-50%, -50%)' : 'translate(-50%, -46%)',
          width: 560, maxWidth: '92vw',
          background: t.bg, color: t.fg,
          border: `1px solid ${t.rule}`,
          boxShadow: theme === 'dark' ? '0 40px 100px rgba(0,0,0,0.6)' : '0 40px 100px rgba(0,0,0,0.18)',
          opacity: open ? 1 : 0,
          pointerEvents: open ? 'auto' : 'none',
          transition: 'opacity 240ms ease, transform 320ms cubic-bezier(.2,.8,.2,1)',
          zIndex: 61,
          fontFamily: '"JetBrains Mono", monospace',
        }}
      >
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
          padding: '20px 28px', borderBottom: `1px solid ${t.rule}`,
        }}>
          <div>
            <div style={{ fontSize: 9, letterSpacing: '0.24em', color: t.veryMuted }}>ABOUT</div>
            <div style={{ fontSize: 14, letterSpacing: '0.06em', marginTop: 5 }}>{photographerName.toUpperCase()}</div>
          </div>
          <button
            onClick={onClose}
            aria-label="Close"
            style={{
              background: 'transparent', border: `1px solid ${t.rule}`, color: t.fg,
              width: 28, height: 28, cursor: 'pointer', fontSize: 15, lineHeight: 1,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              transition: 'background 160ms, color 160ms',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.background = t.fg; e.currentTarget.style.color = t.bg; }}
            onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.fg; }}
          >×</button>
        </div>

        {statementLines.some(l => l.trim()) && (
          <div style={{ padding: '28px', fontSize: 14, lineHeight: 1.7, letterSpacing: '0.02em', color: t.muted, textWrap: 'pretty' }}>
            {statementLines.map((line, i) => <div key={i}>{line || '\u00A0'}</div>)}
          </div>
        )}

        {links.length > 0 && (
          <div style={{
            display: 'grid', gridTemplateColumns: '1fr 1fr',
            gap: '22px 28px',
            padding: statementLines.some(l => l.trim()) ? '0 28px 30px' : '28px',
          }}>
            {links.map((e) => (
              <div key={e.id}>
                <div style={rowLabel}>{(e.label || '').toUpperCase()}</div>
                {renderEntry(e)}
              </div>
            ))}
          </div>
        )}

        <div style={{
          padding: '14px 28px', borderTop: `1px solid ${t.rule}`,
          fontSize: 9, letterSpacing: '0.18em', color: t.veryMuted,
          display: 'flex', justifyContent: 'space-between',
        }}>
          <span>ESC TO CLOSE</span>
          <span>PRINTS &amp; COMMISSIONS ON REQUEST</span>
        </div>
      </div>
    </>
  );
}

// ─── Diary overlay — dated journal entries, read-only on the public site.
// Entries are written/edited from the admin page. Each entry is
// { id, date:'YYYY-MM-DD', title, body }. ──
function fmtDiaryDate(d) {
  if (!d) return '';
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(d);
  return m ? `${m[1]}.${m[2]}.${m[3]}` : d;
}

function DiaryOverlay({ open, onClose, theme, entries, photoTitle }) {
  const t = THEMES[theme];

  useEffect(() => {
    function onKey(e) { if (e.key === 'Escape') onClose(); }
    if (open) document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  const sorted = (entries || []).slice().sort((a, b) =>
    String(b.date || '').localeCompare(String(a.date || '')));

  return (
    <>
      <div
        onClick={onClose}
        style={{
          position: 'fixed', inset: 0,
          background: theme === 'dark' ? 'rgba(0,0,0,0.55)' : 'rgba(10,10,10,0.22)',
          opacity: open ? 1 : 0,
          pointerEvents: open ? 'auto' : 'none',
          transition: 'opacity 240ms ease',
          zIndex: 60,
        }}
      />
      <div
        role="dialog"
        aria-modal="true"
        aria-label="Diary"
        aria-hidden={!open}
        style={{
          position: 'fixed', top: '50%', left: '50%',
          transform: open ? 'translate(-50%, -50%)' : 'translate(-50%, -46%)',
          width: 600, maxWidth: '92vw', maxHeight: '82vh',
          background: t.bg, color: t.fg,
          border: `1px solid ${t.rule}`,
          boxShadow: theme === 'dark' ? '0 40px 100px rgba(0,0,0,0.6)' : '0 40px 100px rgba(0,0,0,0.18)',
          opacity: open ? 1 : 0,
          pointerEvents: open ? 'auto' : 'none',
          transition: 'opacity 240ms ease, transform 320ms cubic-bezier(.2,.8,.2,1)',
          zIndex: 61,
          fontFamily: '"JetBrains Mono", monospace',
          display: 'flex', flexDirection: 'column',
        }}
      >
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
          padding: '20px 28px', borderBottom: `1px solid ${t.rule}`, flex: 'none',
        }}>
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 9, letterSpacing: '0.24em', color: t.veryMuted }}>{photoTitle ? 'FIELD NOTE' : 'DIARY'}</div>
            <div style={{ fontSize: 14, letterSpacing: '0.06em', marginTop: 5, textWrap: 'pretty' }}>
              {photoTitle ? photoTitle : `FIELD NOTES · ${String(sorted.length).padStart(2, '0')}`}
            </div>
          </div>
          <button
            onClick={onClose}
            aria-label="Close"
            style={{
              background: 'transparent', border: `1px solid ${t.rule}`, color: t.fg,
              width: 28, height: 28, cursor: 'pointer', fontSize: 15, lineHeight: 1,
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              transition: 'background 160ms, color 160ms',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.background = t.fg; e.currentTarget.style.color = t.bg; }}
            onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = t.fg; }}
          >×</button>
        </div>

        <div style={{ overflowY: 'auto', flex: 1 }}>
          {sorted.length === 0 && (
            <div style={{
              padding: '48px 28px', textAlign: 'center',
              fontSize: 11, letterSpacing: '0.18em', color: t.veryMuted,
            }}>NOTHING WRITTEN YET.</div>
          )}
          {sorted.map((e) => (
            <article key={e.id} style={{ padding: '24px 28px', borderBottom: `1px solid ${t.rule}` }}>
              <div style={{ fontSize: 10, letterSpacing: '0.18em', color: t.veryMuted }}>{fmtDiaryDate(e.date)}</div>
              {e.title && e.title.trim() && (
                <div style={{ fontSize: 15, letterSpacing: '0.03em', margin: '8px 0 2px' }}>{e.title}</div>
              )}
              {e.body && e.body.trim() && (
                <div style={{
                  fontSize: 13, lineHeight: 1.75, letterSpacing: '0.01em',
                  color: t.muted, marginTop: 10, whiteSpace: 'pre-wrap', textWrap: 'pretty',
                }}>{e.body}</div>
              )}
            </article>
          ))}
        </div>

        <div style={{
          padding: '14px 28px', borderTop: `1px solid ${t.rule}`, flex: 'none',
          fontSize: 9, letterSpacing: '0.18em', color: t.veryMuted,
          display: 'flex', justifyContent: 'space-between',
        }}>
          <span>ESC TO CLOSE</span>
          <span>{photoTitle ? 'ATTACHED TO THIS WORK' : 'FROM THE ARCHIVE'}</span>
        </div>
      </div>
    </>
  );
}

function MuseumLanding({
  photographerName, refreshKey, onShuffle,
  collection,
  siteName = 'MUSEUM OF FORGOTTEN MOMENTS',
  theme: themeProp, onThemeChange,
  indexOpen: indexOpenProp, onIndexOpenChange,
  organiseMode = false, hiddenIds, onToggleHide, onDelete,
}) {
  const [themeState, setThemeState] = useState(() => {
    // Respect a manual choice if the visitor made one; otherwise pick by time of day.
    return localStorage.getItem('mu_theme') || themeFromTime();
  });
  const theme = themeProp || themeState;
  // Persist theme ONLY when the visitor changes it manually, so the
  // time-of-day default keeps applying on return visits until they override it.
  const setThemeManual = (v) => {
    localStorage.setItem('mu_theme', v);
    if (onThemeChange) onThemeChange(v); else setThemeState(v);
  };

  const [music, setMusic] = useState(() => {
    return localStorage.getItem('mu_music') === '1';
  });
  const [indexOpenState, setIndexOpenState] = useState(false);
  const indexOpen = indexOpenProp != null ? indexOpenProp : indexOpenState;
  const setIndexOpen = onIndexOpenChange || setIndexOpenState;
  const mobile = useIsMobile();
  const [toolsOpen, setToolsOpen] = useState(false);
  const [zineOpen, setZineOpen] = useState(false);
  const [zinePlacement, setZinePlacement] = useState(() => [[], [], [], [], [], []]);
  const [zineImgOpts, setZineImgOpts] = useState({});
  const [zineCornerInfo, setZineCornerInfo] = useState(true);
  const placedIds = useMemo(() => new Set(zinePlacement.flat()), [zinePlacement]);
  // "My own" mode — build a zine from browser-only uploaded images. These live
  // in memory only (never persisted to the store, Supabase, or localStorage).
  const [myOwn, setMyOwn] = useState(false);
  const [myOwnImages, setMyOwnImages] = useState([]);
  const addOwnImages = async (files) => {
    const arr = Array.from(files || []).filter(f => /^image\//.test(f.type));
    for (const f of arr) {
      try {
        const { dataURL, width, height } = await window.fileToScaledDataURL(f, 1600, 0.82);
        const id = 'own-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
        const title = (f.name || 'image').replace(/\.[^.]+$/, '').replace(/[_\-]+/g, ' ').trim() || 'Untitled';
        const img = { id, src: dataURL, title, series: 'MY ZINE', year: new Date().getFullYear(), aspect: window.snapAspect(width, height), location: 'UPLOAD', medium: 'Digital', edition: '' };
        setMyOwnImages(prev => [...prev, img]);
      } catch (e) { /* ignore */ }
    }
  };
  const toggleMyOwn = () => {
    const nv = !myOwn;
    setMyOwn(nv);
    // 8 plain pages for your own zines; 6 interior pages for the archive flow.
    setZinePlacement(nv ? [[], [], [], [], [], [], [], []] : [[], [], [], [], [], []]);
    setZineImgOpts({});
  };
  const clearOwnImages = () => {
    setMyOwnImages([]);
    setZinePlacement([[], [], [], [], [], [], [], []]);
    setZineImgOpts({});
  };
  const [selectedIdx, setSelectedIdx] = useState(null); // null = random per refreshKey
  const [aboutOpen, setAboutOpen] = useState(false);
  const [diaryOpen, setDiaryOpen] = useState(false);
  const [about, setAbout] = useState(() => (window.MU && (window.MU.about || window.MU.DEFAULT_ABOUT)) || null);
  const [diary, setDiary] = useState(() => (window.MU && window.MU.diary) || []);
  // Load About/contact content from the data layer, and refresh when the
  // admin saves a change (same-page event from MU.saveAbout).
  useEffect(() => {
    let alive = true;
    if (window.MU && window.MU.getAbout) {
      window.MU.getAbout().then(a => { if (alive) setAbout(a); });
    }
    if (window.MU && window.MU.getDiary) {
      window.MU.getDiary().then(d => { if (alive) setDiary(d); });
    }
    const onAbout = () => { if (window.MU) setAbout(window.MU.about ? { ...window.MU.about } : null); };
    const onDiary = () => { if (window.MU) setDiary(window.MU.diary ? [...window.MU.diary] : []); };
    window.addEventListener('mu-about-changed', onAbout);
    window.addEventListener('mu-diary-changed', onDiary);
    return () => {
      alive = false;
      window.removeEventListener('mu-about-changed', onAbout);
      window.removeEventListener('mu-diary-changed', onDiary);
    };
  }, []);
  // Series-bar → index filter request. Bump `n` so repeat clicks re-apply.
  const [reqSeries, setReqSeries] = useState({ value: 'all', n: 0 });
  // Touch tap-to-place: tapping an index work bumps `n`; the zine places it on
  // the currently-selected page (drag-and-drop is the desktop path).
  const [zineTap, setZineTap] = useState({ id: null, n: 0 });

  useEffect(() => { localStorage.setItem('mu_music', music ? '1' : '0'); }, [music]);

  // Collection is sourced from the live store unless one is supplied
  // (the admin page passes a collection that includes hidden works).
  const fallbackColl = useMemo(() => getCollection(), [refreshKey]);
  const coll = collection || fallbackColl;
  // Index + zine work off the user's uploads while "my own" mode is on.
  const indexCollection = (zineOpen && myOwn) ? myOwnImages : coll;
  const zineCollection = myOwn ? myOwnImages : coll;

  const t = THEMES[theme];
  const photo = useMemo(() => {
    if (!coll.length) return null;
    if (selectedIdx != null) {
      const idx = Math.min(selectedIdx, coll.length - 1);
      return { ...coll[idx], _idx: idx };
    }
    const idx = pickIndex(coll.length);
    return { ...coll[idx], _idx: idx };
  }, [refreshKey, selectedIdx, coll]);

  const spotifyHostRef = useSpotifyPlayer(music, photo?.track?.uri);

  // Field notes attached to the current work. Diary entries carry an optional
  // workId; the photo's note tab + overlay appear only when this is non-empty.
  const photoDiary = useMemo(() => {
    if (!photo) return [];
    return (diary || []).filter(e =>
      e && e.workId === photo.id &&
      ((e.title && e.title.trim()) || (e.body && e.body.trim()))
    );
  }, [diary, photo]);

  // Close the note overlay whenever the shown work changes.
  useEffect(() => { setDiaryOpen(false); }, [photo && photo.id]);

  // Strip stats: a tally of works per series, from the live collection.
  const stats = useMemo(() => {
    const pad = (n) => String(n).padStart(2, '0');
    const counts = {};
    const order = [];
    coll.forEach(p => {
      // Group by the primary category only (text before the first " · "),
      // so "City · Motion" and "City · Dusk" both tally under "City".
      const key = p.series.split('·')[0].trim();
      if (!(key in counts)) { counts[key] = 0; order.push(key); }
      counts[key] += 1;
    });
    return order
      .sort((a, b) => counts[b] - counts[a])
      .map(s => ({ key: s, label: s.toUpperCase(), value: pad(counts[s]) }));
  }, [coll]);

  return (
    <div style={{
      width: '100%',
      minHeight: '100vh',
      background: t.bg,
      color: t.fg,
      fontFamily: '"JetBrains Mono", "IBM Plex Mono", monospace',
      display: 'flex',
      flexDirection: 'column',
      transition: 'background 280ms ease, color 280ms ease',
    }}>
      <Header
        theme={theme}
        setTheme={setThemeManual}
        music={music}
        setMusic={setMusic}
        photographerName={photographerName}
        siteName={siteName}
        onNext={() => { setSelectedIdx(null); onShuffle(); }}
        onOpenAbout={() => setAboutOpen(true)}
      />

      <AboutOverlay
        open={aboutOpen}
        onClose={() => setAboutOpen(false)}
        theme={theme}
        photographerName={photographerName}
        about={about}
      />

      <DiaryOverlay
        open={diaryOpen}
        onClose={() => setDiaryOpen(false)}
        theme={theme}
        entries={photoDiary}
        photoTitle={photo ? photo.title : ''}
      />

      <IndexTab
        theme={theme}
        open={indexOpen}
        onClick={() => { setIndexOpen(!indexOpen); if (!indexOpen) setToolsOpen(false); }}
      />

      {window.ToolsLayer && (
        <window.ToolsLayer
          theme={theme}
          open={toolsOpen}
          onToggle={() => { const v = !toolsOpen; setToolsOpen(v); if (v) setIndexOpen(false); }}
          onClose={() => setToolsOpen(false)}
          onOpenZine={() => { setZineOpen(true); setToolsOpen(false); if (!mobile) setIndexOpen(true); }}
        />
      )}

      <IndexDrawer
        open={indexOpen}
        onClose={() => setIndexOpen(false)}
        theme={theme}
        collection={indexCollection}
        currentIdx={photo ? photo._idx : -1}
        onSelect={(i) => { setSelectedIdx(i); }}
        organiseMode={organiseMode}
        hiddenIds={hiddenIds}
        onToggleHide={onToggleHide}
        onDelete={onDelete}
        zineMode={zineOpen}
        placedIds={placedIds}
        requestSeries={reqSeries}
        myOwn={myOwn}
        myOwnImages={myOwnImages}
        onToggleMyOwn={toggleMyOwn}
        onUploadOwn={addOwnImages}
        onClearOwn={clearOwnImages}
        onTapPlace={(id) => { setZineTap(s => ({ id, n: s.n + 1 })); if (mobile) setIndexOpen(false); }}
      />

      <div style={{
        display: 'flex', justifyContent: 'center', flexWrap: 'wrap', gap: '8px 56px',
        padding: '8px 32px', borderBottom: `1px solid ${t.rule}`,
        fontSize: 10, color: t.muted, letterSpacing: '0.05em',
      }}>
        {stats.map(s => (
          <button
            key={s.label}
            onClick={() => {
              setReqSeries(r => ({ value: s.key, n: r.n + 1 }));
              setToolsOpen(false);
              setIndexOpen(true);
            }}
            title={`Filter index — ${s.label}`}
            style={{
              background: 'transparent', border: 'none', padding: 0, margin: 0,
              font: 'inherit', color: 'inherit', letterSpacing: 'inherit',
              cursor: 'pointer', transition: 'color 160ms', whiteSpace: 'nowrap',
            }}
            onMouseEnter={(e) => { e.currentTarget.style.color = t.fg; }}
            onMouseLeave={(e) => { e.currentTarget.style.color = t.muted; }}
          >{s.label}/{s.value}</button>
        ))}
      </div>

      {zineOpen && window.ZineOverlay ? (
        <window.ZineOverlay
          inline
          theme={theme}
          collection={zineCollection}
          photographerName={photographerName}
          siteName={siteName}
          about={about}
          myOwn={myOwn}
          indexOpen={indexOpen}
          onRequestIndex={() => setIndexOpen(true)}
          placement={zinePlacement}
          setPlacement={setZinePlacement}
          tapPlace={zineTap}
          imgOpts={zineImgOpts}
          setImgOpts={setZineImgOpts}
          cornerInfo={zineCornerInfo}
          setCornerInfo={setZineCornerInfo}
          onClose={() => { setZineOpen(false); setIndexOpen(false); setMyOwn(false); }}
        />
      ) : photo ? (
        <PhotoStage photo={photo} total={coll.length} theme={theme} refreshKey={refreshKey} music={music} onToggleMusic={() => setMusic(m => !m)} diaryEntries={photoDiary} onOpenDiary={() => setDiaryOpen(true)} />
      ) : (
        <div style={{
          flex: '1 0 auto', minHeight: 720, display: 'flex',
          alignItems: 'center', justifyContent: 'center',
          color: t.veryMuted, fontSize: 12, letterSpacing: '0.24em',
        }}>NO WORKS IN COLLECTION</div>
      )}

      <Footer theme={theme} />

      {/* Spotify IFrame player — kept off-screen; drives audio only.
          Our own track label under the photo is the visible “now playing”. */}
      <div style={{
        position: 'fixed', left: 0, bottom: 0,
        width: 1, height: 1, overflow: 'hidden',
        opacity: 0.01, pointerEvents: 'none', zIndex: -1,
      }} aria-hidden="true">
        <div ref={spotifyHostRef}></div>
      </div>
    </div>
  );
}

window.MuseumLanding = MuseumLanding;
window.BASE_COLLECTION = BASE_COLLECTION;
window.COLLECTION = BASE_COLLECTION;
window.THEMES = THEMES;
window.ASPECT_CAPS = ASPECT_CAPS;
window.TRACK_LIBRARY = TRACK_LIBRARY;
window.loadStore = loadStore;
window.saveStore = saveStore;
window.getCollection = getCollection;
window.nextWorkId = nextWorkId;
window.snapAspect = snapAspect;
window.fileToScaledDataURL = fileToScaledDataURL;
