/* global React, ReactDOM, TABLES, MAP_STRUCTURE, EVENT, FloorMap, SavedDrawer */
const { useState, useMemo, useEffect, useCallback } = React;
const { cx, FilterBar, SearchInput, TagFilter, SidePanel, DetailPanel, searchTables, ClaimsProvider, useClaims } = window.AS;
const { useSaved, useVisited, useNotes, useTheme } = window.AS_STORE;

const PRODUCT_PRESETS = [
  'prints', 'buttons', 'plushies', 'stickers', 'charms/keychains',
  'enamel pins', 'postcards', 'tote bags', 'jewelry', 'apparel',
];

const PLACEHOLDER_FANDOMS = new Set([
  'indie vtubers', 'hololive', 'chainsaw man', 'jujutsu kaisen', 'original',
  'frieren', 'studio ghibli', 'genshin', 'honkai: star rail', 'oshi no ko',
  'mob psycho', 'apothecary diaries', 'sanrio', 'pokemon', 'zelda',
  'fire emblem', 'splatoon', 'nijisanji', 'persona', 'ff14', 'dandadan',
  'phase connect', 'dungeon meshi',
]);

const DATA_EXPORT_COLUMNS = [
  ['tableId', 'tableId'],
  ['displayName', 'displayName'],
  ['x', 'x'],
  ['ig', 'ig'],
  ['bsky', 'bsky'],
  ['fandoms', 'fandoms'],
  ['products', 'products'],
  ['catalogUrl', 'catalogUrl'],
  ['contact', 'contact'],
  ['verifiedVia', 'verifiedVia'],
  ['verifiedHandle', 'verifiedHandle'],
];

function parseCSV(text, limit = 8) {
  return String(text || '')
    .split(/[\n,]/)
    .map(s => s.trim())
    .filter(Boolean)
    .slice(0, limit);
}

function parseSocialUrl(input) {
  const raw = String(input || '').trim();
  if (!raw) return null;
  const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw.replace(/^@/, 'x.com/')}`;
  try {
    const u = new URL(withProtocol);
    const host = u.hostname.replace(/^www\./, '').toLowerCase();
    const bits = u.pathname.split('/').filter(Boolean);
    if ((host === 'x.com' || host === 'twitter.com') && bits[0]) return { provider: 'x', handle: bits[0].replace(/^@/, '') };
    if (host === 'instagram.com' && bits[0]) return { provider: 'ig', handle: bits[0].replace(/^@/, '') };
    if ((host === 'bsky.app' && bits[0] === 'profile' && bits[1]) || host.endsWith('.bsky.social')) {
      return { provider: 'bsky', handle: bits[1] || host };
    }
  } catch {}
  if (raw.startsWith('@')) return { provider: 'x', handle: raw.slice(1) };
  return null;
}

function socialProfileHref(provider, handle) {
  const clean = String(handle || '').trim().replace(/^@/, '');
  if (!clean) return '';
  if (provider === 'x') return `https://x.com/${clean}`;
  if (provider === 'ig') return `https://instagram.com/${clean}/`;
  if (provider === 'bsky') return `https://bsky.app/profile/${clean}`;
  return '';
}

function socialProviderLabel(provider) {
  if (provider === 'x') return 'X';
  if (provider === 'ig') return 'Instagram';
  if (provider === 'bsky') return 'Bluesky';
  return provider;
}

function contactHref(value) {
  const raw = String(value || '').trim();
  if (!raw) return '';
  if (/^https?:\/\//i.test(raw)) return raw;
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) return `mailto:${raw}`;
  return '';
}

function splitMultiValue(text, limit = 8) {
  const raw = String(text || '').trim();
  if (!raw) return [];
  const parts = raw.includes('|')
    ? raw.split('|')
    : raw.split(/[\n,]/);
  return parts
    .map(s => s.trim())
    .filter(Boolean)
    .slice(0, limit);
}

function csvEscape(value) {
  const text = String(value ?? '');
  if (!/[",\n]/.test(text)) return text;
  return `"${text.replace(/"/g, '""')}"`;
}

function downloadTextFile(filename, text, mime = 'text/plain;charset=utf-8') {
  const blob = new Blob([text], { type: mime });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 500);
}

function parseDelimitedRows(text) {
  const source = String(text || '').trim();
  if (!source) return [];
  if (source.includes('\t')) {
    return source
      .split(/\r?\n/)
      .filter(Boolean)
      .map(line => line.split('\t').map(cell => cell.trim()));
  }
  const rows = [];
  let row = [];
  let cell = '';
  let inQuotes = false;
  for (let i = 0; i < source.length; i++) {
    const ch = source[i];
    if (inQuotes) {
      if (ch === '"') {
        if (source[i + 1] === '"') {
          cell += '"';
          i++;
        } else {
          inQuotes = false;
        }
      } else {
        cell += ch;
      }
      continue;
    }
    if (ch === '"') {
      inQuotes = true;
      continue;
    }
    if (ch === ',') {
      row.push(cell.trim());
      cell = '';
      continue;
    }
    if (ch === '\n') {
      row.push(cell.trim());
      rows.push(row);
      row = [];
      cell = '';
      continue;
    }
    if (ch !== '\r') cell += ch;
  }
  row.push(cell.trim());
  rows.push(row);
  return rows.filter(r => r.some(cellValue => cellValue !== ''));
}

function normalizeHeader(text) {
  return String(text || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '');
}

// Match behaviour to viewport: phones get the mobile shell; narrow desktop
// keeps desktop chrome but uses modal details when the side panel is cramped.
function useMediaMax(breakpoint) {
  const [matches, setMatches] = useState(() =>
    typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches
  );
  useEffect(() => {
    const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
    const onChange = (e) => setMatches(e.matches);
    mq.addEventListener ? mq.addEventListener('change', onChange) : mq.addListener(onChange);
    return () => {
      mq.removeEventListener ? mq.removeEventListener('change', onChange) : mq.removeListener(onChange);
    };
  }, [breakpoint]);
  return matches;
}

function TopBar({ onOpenSaved, onOpenClaim, theme, onToggleTheme, onOpenFaq }) {
  const nextLabel = theme === 'dark' ? 'switch to light' : 'switch to dark';
  return (
    <header className="top-bar">
      <div className="guide-spine" aria-hidden="true" />
      <div className="brand">
        <span className="brand-name">AlleySearcher</span>
        <span className="brand-sub">
          made to optimize your con time because i hate walking around aimlessly
        </span>
      </div>
      <div className="top-actions">
        <button type="button" className="btn btn-ghost export-open" onClick={onOpenSaved} title="export saved list">
          Export list
        </button>
        <button type="button" className="btn btn-ghost claim-open" onClick={onOpenClaim} title="claim your page">
          Claim your page
        </button>
        <button
          type="button"
          className="icon-round"
          onClick={onOpenFaq}
          title="how this works (FAQ)"
          aria-label="open FAQ"
        >
          <span aria-hidden="true">?</span>
        </button>
        <button
          type="button"
          className="icon-round theme-toggle"
          onClick={onToggleTheme}
          title={nextLabel}
          aria-label={nextLabel}
        >
          <span className="theme-toggle-glyph" aria-hidden="true">
            {theme === 'dark' ? '☀' : '☾'}
          </span>
        </button>
      </div>
    </header>
  );
}

function FilterPill({ label, children, onClear, extra }) {
  return (
    <span className="filter-pill">
      <span>{label}</span>
      {children}
      {extra && <span className="filter-extra">{extra}</span>}
      {onClear && (
        <button type="button" onClick={(e) => onClear(e)} aria-label={`remove ${label}`}>×</button>
      )}
    </span>
  );
}

function SelectedTagSummary({ label, values, onClear, onOpen, mobile }) {
  const emptyLabel = label === 'Products' ? 'Any product' : 'Any fandom';
  const shown = [...values].slice(0, 2);
  const rest = Math.max(0, values.size - shown.length);
  const count = values.size;
  const onKeyDown = (e) => {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    e.preventDefault();
    onOpen();
  };
  return (
    <div
      className={cx('summary-card', mobile && 'summary-card-mobile')}
      role="button"
      tabIndex="0"
      onClick={onOpen}
      onKeyDown={onKeyDown}
    >
      <span className="summary-label">{mobile ? `${label} (${count})` : label.toLowerCase()}</span>
      <span className="summary-values">
        {values.size === 0 ? (
          <span className="summary-empty">{emptyLabel}</span>
        ) : shown.map(v => (
          <FilterPill key={v} label={v} onClear={mobile ? null : (e) => { e.stopPropagation(); onClear(v); }} />
        ))}
        {rest > 0 && <span className="filter-pill filter-pill-count">+{rest}</span>}
      </span>
      <span className="summary-caret" aria-hidden="true">⌄</span>
    </div>
  );
}

function DesktopFilterStrip({ tables, query, onQuery, filters, setFilters, savedCount, visitedCount, notVisitedCount, notedCount, anyFilter, onClearFilters, onPickTable, onOpenFilter }) {
  const setF = (patch) => setFilters({ ...filters, ...patch });
  const removeTag = (key, value) => {
    const next = new Set(filters[key]);
    next.delete(value);
    setF({ [key]: next });
  };
  const catalogCount = tables.filter(t => t.hasCatalog).length;
  return (
    <section className="desktop-filter-strip" aria-label="filters">
      <div className="desktop-filter-search">
        <SearchInput
          value={query}
          onChange={onQuery}
          tables={tables}
          onPickTable={onPickTable}
          placeholder="Search artists, tables, products, or keywords..."
        />
      </div>
      <div className="desktop-status-row">
        <span className="status-label">Status:</span>
        <button type="button" className={cx('status-chip', !filters.savedOnly && !filters.visitedOnly && !filters.notVisitedOnly && !filters.notedOnly && !filters.hasCatalog && 'is-active')}
          onClick={() => setF({ savedOnly: false, visitedOnly: false, notVisitedOnly: false, notedOnly: false, hasCatalog: false })}>
          All
        </button>
        <button type="button" className={cx('status-chip', filters.savedOnly && 'is-active', 'tone-saved')}
          onClick={() => setF({ savedOnly: !filters.savedOnly })}>♥ Saved <span>{savedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.visitedOnly && 'is-active')}
          onClick={() => setF({ visitedOnly: !filters.visitedOnly, notVisitedOnly: false })}>✓ Visited <span>{visitedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.notVisitedOnly && 'is-active')}
          onClick={() => setF({ notVisitedOnly: !filters.notVisitedOnly, visitedOnly: false })}>○ Not visited <span>{notVisitedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.notedOnly && 'is-active')}
          onClick={() => setF({ notedOnly: !filters.notedOnly })}>♧ w/ Notes <span>{notedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.hasCatalog && 'is-active')}
          onClick={() => setF({ hasCatalog: !filters.hasCatalog })}>▭ w/ Catalog <span>{catalogCount}</span></button>
      </div>
      <div className="desktop-filter-groups">
        <div className="filter-group-line">
          <SelectedTagSummary label="Fandoms" values={filters.fandoms} onClear={(v) => removeTag('fandoms', v)} onOpen={() => onOpenFilter('fandom')} />
        </div>
        <div className="filter-group-line">
          <SelectedTagSummary label="Products" values={filters.niches} onClear={(v) => removeTag('niches', v)} onOpen={() => onOpenFilter('niche')} />
        </div>
        {anyFilter && (
          <div className="desktop-filter-clear">
            <button type="button" className="link clear-all clear-all-inline" onClick={onClearFilters}>
              clear all
            </button>
          </div>
        )}
      </div>
    </section>
  );
}

function FilterSelectSheet({ kind, tables, filters, setFilters, onClose, mobile,
                             savedCount, visitedCount, notVisitedCount, notedCount }) {
  const isStatus = kind === 'status';
  const isNiche = kind === 'niche';
  const key = isNiche ? 'niches' : 'fandoms';
  const title = isNiche ? 'Select Products' : 'Select Fandoms';
  const catalogCount = tables.filter(t => t.hasCatalog).length;
  const [draft, setDraft] = useState(() => new Set(filters[key]));
  const [sortBy, setSortBy] = useState('count');

  useEffect(() => {
    if (isStatus) return;
    setDraft(new Set(filters[key]));
  }, [kind, filters, isStatus]); // eslint-disable-line

  const apply = () => {
    setFilters({ ...filters, [key]: draft });
    onClose();
  };

  const setStatus = (patch) => setFilters({ ...filters, ...patch });

  const statusBody = (
    <div className="filter-dialog-card">
      <div className="filter-dialog-head">
        <h3>Status filters</h3>
        <button type="button" className="icon-btn close-btn" onClick={onClose} aria-label="close">×</button>
      </div>
      <div className="status-sheet-grid">
        <button type="button" className={cx('status-chip', !filters.savedOnly && !filters.visitedOnly && !filters.notVisitedOnly && !filters.notedOnly && !filters.hasCatalog && 'is-active')}
          onClick={() => setStatus({ savedOnly: false, visitedOnly: false, notVisitedOnly: false, notedOnly: false, hasCatalog: false })}>
          All
        </button>
        <button type="button" className={cx('status-chip', filters.savedOnly && 'is-active', 'tone-saved')}
          onClick={() => setStatus({ savedOnly: !filters.savedOnly })}>♥ Saved <span>{savedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.visitedOnly && 'is-active')}
          onClick={() => setStatus({ visitedOnly: !filters.visitedOnly, notVisitedOnly: false })}>✓ Visited <span>{visitedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.notVisitedOnly && 'is-active')}
          onClick={() => setStatus({ notVisitedOnly: !filters.notVisitedOnly, visitedOnly: false })}>○ Not visited <span>{notVisitedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.notedOnly && 'is-active')}
          onClick={() => setStatus({ notedOnly: !filters.notedOnly })}>♧ w/ Notes <span>{notedCount}</span></button>
        <button type="button" className={cx('status-chip', filters.hasCatalog && 'is-active')}
          onClick={() => setStatus({ hasCatalog: !filters.hasCatalog })}>▭ w/ Catalog <span>{catalogCount}</span></button>
      </div>
    </div>
  );

  const body = (
    <div className="filter-dialog-card">
      <div className="filter-dialog-head">
        <h3>{title}</h3>
        <button type="button" className="icon-btn close-btn" onClick={onClose} aria-label="close">×</button>
      </div>
      <div className="filter-dialog-sort">
        <span>Sort by:</span>
        <button type="button" className={cx('sort-chip', sortBy === 'alpha' && 'is-active')} onClick={() => setSortBy('alpha')}>A → Z</button>
        <button type="button" className={cx('sort-chip', sortBy === 'count' && 'is-active')} onClick={() => setSortBy('count')}># of tables</button>
      </div>
      <TagFilter
        tables={tables}
        value={draft}
        onChange={setDraft}
        kind={kind}
        sortBy={sortBy}
      />
      <div className="filter-dialog-foot">
        <span>{draft.size} selected</span>
        <div className="filter-dialog-actions">
          <button
            type="button"
            className="link clear-selection"
            onClick={() => setDraft(new Set())}
            disabled={draft.size === 0}
          >
            clear all
          </button>
          <button type="button" className="btn btn-primary" onClick={apply}>Apply</button>
        </div>
      </div>
    </div>
  );

  if (mobile) {
    return (
      <div className="mobile-filter-sheet-root" role="dialog" aria-modal="true" aria-label={isStatus ? 'Status filters' : title}>
        <div className="mobile-filter-scrim" onClick={onClose} />
        <div className="mobile-filter-sheet">
          {isStatus ? statusBody : body}
        </div>
      </div>
    );
  }

  return (
    <div className="filter-dialog-root" role="dialog" aria-modal="true" aria-label={title}>
      <div className="filter-dialog-scrim" onClick={onClose} />
      {body}
    </div>
  );
}

function MobileChrome({ savedCount, listCount, activeView, hasFilters, onShowMap, onShowList, theme, onToggleTheme, onOpenSaved, onOpenStatus, onOpenFandoms, onOpenProducts, onClearFilters }) {
  return (
    <>
      <header className="mobile-top">
        <button type="button" className="mobile-icon" aria-label="menu">☰</button>
        <span className="brand-name">AlleySearcher</span>
        <div className="mobile-top-actions">
          <button type="button" className="mobile-saved" onClick={onOpenSaved}>♥ {savedCount}</button>
          <button type="button" className="mobile-icon" onClick={onToggleTheme} aria-label={theme === 'dark' ? 'switch to light' : 'switch to dark'}>
            {theme === 'dark' ? '☀' : '☾'}
          </button>
        </div>
      </header>
      <nav className="mobile-shortcuts" aria-label="filter shortcuts">
        <button type="button" onClick={onOpenStatus}>♧<span>Status</span></button>
        <button type="button" onClick={onOpenFandoms}>♡<span>Fandoms</span></button>
        <button type="button" onClick={onOpenProducts}>▭<span>Products</span></button>
        <button type="button" onClick={onClearFilters} disabled={!hasFilters}>×<span>Clear</span></button>
      </nav>
      <div className="mobile-tabs" role="tablist" aria-label="views">
        <button
          type="button"
          role="tab"
          aria-selected={activeView === 'map'}
          className={cx(activeView === 'map' && 'is-active')}
          onClick={onShowMap}
        >
          Map
        </button>
        <button
          type="button"
          role="tab"
          aria-selected={activeView === 'list'}
          className={cx(activeView === 'list' && 'is-active')}
          onClick={onShowList}
        >
          List <span>{listCount}</span>
        </button>
      </div>
    </>
  );
}

function MobileBottomNav({ activeView, onShowMap, onShowList, onOpenSaved, onOpenSettings }) {
  return (
    <nav className="mobile-bottom-nav" aria-label="primary">
      <button type="button" className={cx(activeView === 'map' && 'is-active')} onClick={onShowMap}>⌂<span>Map</span></button>
      <button type="button" className={cx(activeView === 'list' && 'is-active')} onClick={onShowList}>☷<span>List</span></button>
      <button type="button" onClick={onOpenSaved}>♡<span>Saved</span></button>
      <button type="button" onClick={onOpenSettings}>♧<span>Settings</span></button>
    </nav>
  );
}

function MobileSettingsSheet({ open, onClose, onOpenFaq, onOpenClaim, onExportNotes }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  if (!open) return null;

  const run = (fn) => {
    onClose();
    fn();
  };

  return (
    <div className="mobile-settings-root" role="dialog" aria-modal="true" aria-label="settings">
      <div className="mobile-settings-scrim" onClick={onClose} />
      <div className="mobile-settings-sheet">
        <div className="mobile-settings-head">
          <h2>settings</h2>
          <button type="button" className="icon-round" onClick={onClose} aria-label="close settings">×</button>
        </div>
        <div className="mobile-settings-actions">
          <button type="button" onClick={() => run(onOpenFaq)}>
            <span>about / faq</span>
            <small>how AlleySearcher works</small>
          </button>
          <button type="button" onClick={() => run(onOpenClaim)}>
            <span>claim an artist page</span>
            <small>connect your booth to socials</small>
          </button>
          <button type="button" onClick={() => run(onExportNotes)}>
            <span>export notes</span>
            <small>copy your local notes</small>
          </button>
        </div>
      </div>
    </div>
  );
}

function ExportNotesDrawer({ open, onClose, tables, savedIds, notes }) {
  const [copied, setCopied] = useState(false);

  const rows = useMemo(() => {
    const ids = new Set(savedIds || []);
    for (const [id, text] of Object.entries(notes || {})) {
      if (text && text.trim()) ids.add(id);
    }
    return tables
      .filter(t => ids.has(t.id))
      .sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }))
      .map(t => {
        const artist = t.artists[0];
        const fandoms = [...new Set(t.artists.flatMap(a => a.fandoms))].slice(0, 3).join(', ') || 'none';
        return {
          id: t.id,
          name: t.artists.map(a => a.name).join(' / '),
          fandoms,
          note: (notes[t.id] || '').trim(),
          saved: savedIds.has(t.id),
          artist,
        };
      });
  }, [tables, savedIds, notes]);

  const exportText = useMemo(() => rows.map(r => (
    `${r.name} - ${r.id} - ${r.fandoms} - ${r.note || ''}`.trim()
  )).join('\n'), [rows]);

  const copy = async () => {
    if (!exportText) return;
    try {
      await navigator.clipboard.writeText(exportText);
      setCopied(true);
      setTimeout(() => setCopied(false), 1400);
    } catch {
      window.prompt('copy your notes', exportText);
    }
  };

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

  if (!open) return null;

  return (
    <div className="export-notes-root" role="dialog" aria-modal="true" aria-label="export notes">
      <div className="export-notes-scrim" onClick={onClose} />
      <aside className="export-notes-sheet">
        <div className="export-notes-head">
          <h2>notes</h2>
          <button type="button" className="icon-btn close-btn" onClick={onClose} aria-label="close">×</button>
        </div>
        <div className="export-notes-list">
          {rows.length === 0 ? (
            <div className="empty">
              <div className="empty-big">no notes yet</div>
              <div>save a table or write a note and it will show here</div>
            </div>
          ) : rows.map(r => (
            <div className="export-note-row" key={r.id}>
              <span className="export-note-table">{r.id}</span>
              <strong>{r.name}</strong>
              <span>{r.fandoms}</span>
              {r.saved && <span className="export-note-heart">♥</span>}
            </div>
          ))}
        </div>
        <div className="export-notes-foot">
          <div className="export-label-row">
            <span className="export-label">your list, ready to copy:</span>
            <span className="export-hint">artist - table - fandoms - note</span>
          </div>
          <textarea
            className="export-textarea"
            readOnly
            value={exportText || 'your saved tables and notes will show here'}
            onClick={(e) => e.currentTarget.select()}
          />
          <button type="button" className="btn btn-primary" onClick={copy} disabled={!exportText}>
            {copied ? 'copied' : 'copy to clipboard'}
          </button>
        </div>
      </aside>
    </div>
  );
}

function CompactViewTabs({ activeView, listCount, onShowMap, onShowList }) {
  return (
    <div className="desktop-view-tabs" role="tablist" aria-label="workspace view">
      <button
        type="button"
        role="tab"
        aria-selected={activeView === 'map'}
        className={cx(activeView === 'map' && 'is-active')}
        onClick={onShowMap}
      >
        map
      </button>
      <button
        type="button"
        role="tab"
        aria-selected={activeView === 'list'}
        className={cx(activeView === 'list' && 'is-active')}
        onClick={onShowList}
      >
        list <span>{listCount}</span>
      </button>
    </div>
  );
}

// Simple FAQ modal — static Q&A. Pattern mirrors ClaimTableModal so the two
// popups feel like siblings.
function FaqModal({ open, onClose, onOpenClaim }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);
  if (!open) return null;

  const items = [
    {
      q: 'what is AlleySearcher?',
      a: (
        <>i made this to optimize your precious con time because i hate walking around aimlessly. search tables, save booths, mark what you've visited, and leave yourself notes. everything stays on your device. no account needed.</>
      ),
    },
    {
      q: 'where does my data live?',
      a: (
        <>locally, in your browser's storage. saved tables, visited marks, and notes don't leave this device. if you clear your browser data, they're gone, so export your saved list first (top-right <strong>♥ export list</strong>).</>
      ),
    },
    {
      q: 'how do I filter by fandom or product?',
      a: (
        <>use the left sidebar. the <em>quick filters</em> narrow by state (saved / visited / has a note / has a catalog). below that, search or scroll to check boxes under <em>by keyword(s)</em> or <em>by product(s)</em>. you can also tap a <code>+</code> on any tag in an artist card to add that filter.</>
      ),
    },
    {
      q: 'how do I save a table?',
      a: (
        <>tap any booth on the map (or row in the list) to open that artist, then hit <strong>save</strong>. the booth will tint terracotta on the map. tap <strong>visit</strong> once you've been there. saves survive a refresh, so you can use it as your con plan.</>
      ),
    },
    {
      q: 'can I export my saved list?',
      a: (
        <>yes. click <strong>♥ export list</strong> at the top right, then <strong>copy to clipboard</strong>. you'll get a plain-text list in <code>artist - table - fandoms - note</code> format. paste it into notes, a DM, wherever.</>
      ),
    },
    {
      q: `are the artists' tags real?`,
      a: (
        <>i can't guarantee every tag is perfect. fandoms and product types are based on my own social media findings, plus any artist-submitted claims that come in.</>
      ),
    },
    {
      q: `I'm an exhibitor, how do I update my booth?`,
      a: (
        <>open <button type="button" className="link" onClick={() => { onClose(); onOpenClaim(); }}>claim your table</button> and submit the manual request form. i'm reviewing requests by hand for OshiUpLink 2026, then i'll bring back a faster automatic claim flow after the event.</>
      ),
    },
    {
      q: 'can I support this?',
      a: (
        <>this is free. if people end up finding it useful, i may expand it to other conventions later. if you want to support it, you can buy me a coffee here: <a href="https://ko-fi.com/maikocafe" target="_blank" rel="noopener noreferrer">ko-fi.com/maikocafe</a>.</>
      ),
    },
    {
      q: 'keyboard shortcuts?',
      a: (
        <>
          <kbd>/</kbd> focus search, <kbd>Esc</kbd> close a panel,
          <kbd>←</kbd> <kbd>→</kbd> previous / next table while a card is open,
          <kbd>+</kbd> <kbd>−</kbd> <kbd>0</kbd> zoom controls on the map.
        </>
      ),
    },
    {
      q: 'dark mode?',
      a: (
        <>☾ in the top right. respects your OS theme until you pick one explicitly, then it sticks.</>
      ),
    },
  ];

  return (
    <div className="faq-root" role="dialog" aria-modal="true" aria-label="frequently asked questions">
      <div className="faq-scrim" onClick={onClose} />
      <div className="faq-sheet">
        <button type="button" className="icon-btn close-btn faq-close" onClick={onClose} aria-label="close">×</button>
        <h2 className="faq-title">how this works</h2>
        <p className="faq-lede">quick answers. everything else is in the footer links.</p>
        <dl className="faq-list">
          {items.map((it, i) => (
            <div className="faq-item" key={i}>
              <dt className="faq-q">{it.q}</dt>
              <dd className="faq-a">{it.a}</dd>
            </div>
          ))}
        </dl>
      </div>
    </div>
  );
}

// Small footer — matches the maiko.cafe blog footer rhythm: centered row,
// muted type, bullet-separated links.
function SiteFooter({ onClaim, onFaq }) {
  return (
    <footer className="site-footer" role="contentinfo">
      <span className="foot-copy">© 2026 maiko.cafe</span>
      <span className="foot-sep" aria-hidden="true">·</span>
      <a href="https://maiko.cafe/" target="_blank" rel="noopener noreferrer">Blog</a>
      <span className="foot-sep" aria-hidden="true">·</span>
      <a href="https://x.com/maikocafe" target="_blank" rel="noopener noreferrer">Twitter</a>
      <span className="foot-sep" aria-hidden="true">·</span>
      <a href="https://ko-fi.com/maikocafe" target="_blank" rel="noopener noreferrer">Ko-fi</a>
      <span className="foot-sep" aria-hidden="true">·</span>
      <button type="button" className="foot-link" onClick={onClaim}>
        Claim your page
      </button>
      <span className="foot-sep" aria-hidden="true">·</span>
      <a href="mailto:hello@maiko.cafe">Contact</a>
    </footer>
  );
}

// Admin panel modal. Operator-only: guarded by bearer-token check at the
// worker. The token is the `SESSION_SECRET` in wrangler. Entered here once
// per session, kept in memory only (never persisted).
function AdminPanel({ open, onClose }) {
  const claims = useClaims();
  const [token, setToken] = useState('');
  const [authed, setAuthed] = useState(false);
  const [claimsMap, setClaimsMap] = useState({});
  const [manualRequests, setManualRequests] = useState([]);
  const [activeManualRequestId, setActiveManualRequestId] = useState('');
  const [directory, setDirectory] = useState({});
  const [dirDraft, setDirDraft] = useState('');
  const [editTableId, setEditTableId] = useState('A11');
  const [editDraft, setEditDraft] = useState(null);
  const [bulkKind, setBulkKind] = useState('products');
  const [bulkTag, setBulkTag] = useState('');
  const [bulkArtists, setBulkArtists] = useState('');
  const [importDraft, setImportDraft] = useState('');
  const [status, setStatus] = useState('');
  const [busy, setBusy] = useState(false);

  const api = window.AS_CLAIMS || {};
  const adminToken = token.trim();

  const tableOptions = useMemo(() => [...(window.TABLES || [])].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true })), []);
  const makeDraft = useCallback((tableId, map = claimsMap, dir = directory) => {
    const table = tableOptions.find(t => t.id === tableId);
    const artist = table?.artists?.[0] || {};
    const claim = map[tableId] || {};
    const entry = dir[tableId] || {};
    return {
      displayName: claim.displayName || artist.name || '',
      fandoms: (claim.fandoms || artist.fandoms || []).join(', '),
      products: (claim.products || artist.niches || []).join(', '),
      catalogUrl: 'catalogUrl' in claim ? claim.catalogUrl : (table?.catalogPostUrl || ''),
      socials: {
        x: claim.socials?.x || entry.x || artist.socials?.x || '',
        ig: claim.socials?.ig || entry.ig || artist.socials?.ig || '',
        bsky: claim.socials?.bsky || entry.bsky || artist.socials?.bsky || '',
      },
      contact: claim.contact || artist.contact || '',
    };
  }, [claimsMap, directory, tableOptions]);

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

  const load = async (t) => {
    setBusy(true); setStatus('');
    try {
      const [claims, dir, requests] = await Promise.all([
        api.adminListClaims(t),
        api.adminGetDirectory(t),
        api.adminListManualClaims ? api.adminListManualClaims(t) : Promise.resolve([]),
      ]);
      setClaimsMap(claims || {});
      setManualRequests(requests || []);
      setDirectory(dir || {});
      setDirDraft(JSON.stringify(dir || {}, null, 2));
      setEditDraft(makeDraft(editTableId, claims || {}, dir || {}));
      setAuthed(true);
    } catch (err) {
      setAuthed(false);
      setStatus(err.message || 'sign-in failed');
    } finally {
      setBusy(false);
    }
  };

  const saveDirectory = async () => {
    setBusy(true); setStatus('');
    try {
      const parsed = JSON.parse(dirDraft);
      await api.adminPutDirectory(adminToken, parsed);
      setDirectory(parsed);
      setEditDraft(makeDraft(editTableId, claimsMap, parsed));
      setStatus(`directory saved (${Object.keys(parsed).length} entries).`);
    } catch (err) {
      setStatus(err.message || 'save failed');
    } finally {
      setBusy(false);
    }
  };

  const deleteClaim = async (tableId) => {
    if (!confirm(`delete claim for ${tableId}? the artist can re-claim by signing in again.`)) return;
    setBusy(true); setStatus('');
    try {
      await api.adminDeleteClaim(adminToken, tableId);
      const { [tableId]: _, ...rest } = claimsMap;
      setClaimsMap(rest);
      setStatus(`deleted claim for ${tableId}.`);
    } catch (err) {
      setStatus(err.message || 'delete failed');
    } finally {
      setBusy(false);
    }
  };

  const onSignIn = (e) => {
    e.preventDefault();
    load(token.trim());
  };

  useEffect(() => {
    if (!authed) return;
    setEditDraft(makeDraft(editTableId));
  }, [editTableId, authed, makeDraft]);

  const updateEditDraft = (patch) => setEditDraft(prev => ({ ...(prev || makeDraft(editTableId)), ...patch }));
  const updateSocialDraft = (provider, value) => updateEditDraft({ socials: { ...(editDraft?.socials || {}), [provider]: value } });

  const exportRows = useMemo(() => {
    return tableOptions.map(table => {
      const baseArtist = table.artists?.[0] || {};
      const claim = claimsMap[table.id] || {};
      const entry = directory[table.id] || {};
      const fandoms = claim.fandoms || baseArtist.fandoms || [];
      const products = claim.products || baseArtist.niches || [];
      const socials = {
        x: claim.socials?.x || entry.x || baseArtist.socials?.x || '',
        ig: claim.socials?.ig || entry.ig || baseArtist.socials?.ig || '',
        bsky: claim.socials?.bsky || entry.bsky || baseArtist.socials?.bsky || '',
      };
      return {
        tableId: table.id,
        displayName: claim.displayName || baseArtist.name || '',
        x: socials.x,
        ig: socials.ig,
        bsky: socials.bsky,
        fandoms: fandoms.join(' | '),
        products: products.join(' | '),
        catalogUrl: 'catalogUrl' in claim ? (claim.catalogUrl || '') : (table.catalogPostUrl || ''),
        contact: claim.contact || baseArtist.contact || '',
        verifiedVia: claim.verifiedVia || '',
        verifiedHandle: claim.verifiedHandle || '',
      };
    });
  }, [tableOptions, claimsMap, directory]);

  const exportTsv = useMemo(() => {
    const header = DATA_EXPORT_COLUMNS.map(([, label]) => label).join('\t');
    const lines = exportRows.map(row => (
      DATA_EXPORT_COLUMNS.map(([key]) => String(row[key] ?? '')).join('\t')
    ));
    return [header, ...lines].join('\n');
  }, [exportRows]);

  const downloadCsv = useCallback(() => {
    const header = DATA_EXPORT_COLUMNS.map(([, label]) => csvEscape(label)).join(',');
    const lines = exportRows.map(row => (
      DATA_EXPORT_COLUMNS.map(([key]) => csvEscape(row[key] ?? '')).join(',')
    ));
    downloadTextFile('alleysearch-data.csv', [header, ...lines].join('\n'), 'text/csv;charset=utf-8');
  }, [exportRows]);

  const copyTsv = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(exportTsv);
      setStatus('copied current data as tab-separated rows.');
    } catch {
      window.prompt('copy the exported data', exportTsv);
    }
  }, [exportTsv]);

  const loadImportFile = useCallback(async (event) => {
    const file = event.target.files?.[0];
    if (!file) return;
    try {
      const text = await file.text();
      setImportDraft(text);
      setStatus(`loaded ${file.name} into the import box.`);
    } catch (error) {
      setStatus(error?.message || 'could not read that file.');
    } finally {
      event.target.value = '';
    }
  }, []);

  const saveArtistClaim = async () => {
    if (!editDraft) return;
    setBusy(true); setStatus('');
    try {
      const body = {
        displayName: editDraft.displayName,
        fandoms: parseCSV(editDraft.fandoms, 8),
        products: parseCSV(editDraft.products, 6),
        catalogUrl: editDraft.catalogUrl,
        socials: editDraft.socials,
        contact: editDraft.contact,
        verifiedHandle: 'admin',
        verifiedVia: 'admin',
      };
      const claim = await api.adminPutClaim(adminToken, editTableId, body);
      const nextClaims = { ...claimsMap, [editTableId]: claim };
      setClaimsMap(nextClaims);
      const nextDir = { ...directory, [editTableId]: { ...(directory[editTableId] || {}), ...editDraft.socials } };
      setDirectory(nextDir);
      setDirDraft(JSON.stringify(nextDir, null, 2));
      if (activeManualRequestId && api.adminDeleteManualClaim) {
        try {
          await api.adminDeleteManualClaim(adminToken, activeManualRequestId);
          setManualRequests(prev => prev.filter(r => r.id !== activeManualRequestId));
          setActiveManualRequestId('');
          setStatus(`saved ${editTableId} and cleared request.`);
        } catch {
          setStatus(`saved ${editTableId}. request still needs review.`);
        }
      } else {
        setStatus(`saved ${editTableId}.`);
      }
      claims?.refresh?.();
    } catch (err) {
      setStatus(err.message || 'save failed');
    } finally {
      setBusy(false);
    }
  };

  const loadManualRequest = (request) => {
    setActiveManualRequestId(request.id);
    updateEditDraft({
      displayName: request.displayName || '',
      socials: {
        x: request.socials?.x || '',
        ig: request.socials?.ig || '',
        bsky: request.socials?.bsky || '',
      },
      fandoms: (request.fandoms || []).join(', '),
      products: (request.products || []).join(', '),
      catalogUrl: request.catalogUrl || '',
      contact: request.contact || '',
    });
    setStatus(`loaded ${request.displayName}. choose the table, then save artist card.`);
  };

  const dismissManualRequest = async (id) => {
    if (!confirm('dismiss this manual claim request?')) return;
    setBusy(true); setStatus('');
    try {
      await api.adminDeleteManualClaim(adminToken, id);
      setManualRequests(prev => prev.filter(r => r.id !== id));
      if (activeManualRequestId === id) setActiveManualRequestId('');
      setStatus('request dismissed.');
    } catch (err) {
      setStatus(err.message || 'dismiss failed');
    } finally {
      setBusy(false);
    }
  };

  const applyBulkImport = async () => {
    const tag = bulkTag.trim();
    const names = parseCSV(bulkArtists, 200).map(s => s.toLowerCase());
    if (!tag || names.length === 0) return;
    setBusy(true); setStatus('');
    try {
      let count = 0;
      const nextClaims = { ...claimsMap };
      for (const table of tableOptions) {
        const artist = table.artists[0];
        if (!names.includes(String(artist.name || '').toLowerCase()) && !names.includes(table.id.toLowerCase())) continue;
        const existing = nextClaims[table.id] || {};
        const fandoms = new Set(existing.fandoms || artist.fandoms || []);
        const products = new Set(existing.products || artist.niches || []);
        if (bulkKind === 'fandoms') fandoms.add(tag);
        else products.add(tag);
        const claim = await api.adminPutClaim(adminToken, table.id, {
          displayName: existing.displayName || artist.name,
          fandoms: [...fandoms].slice(0, 8),
          products: [...products].slice(0, 6),
          catalogUrl: 'catalogUrl' in existing ? existing.catalogUrl : (table.catalogPostUrl || ''),
          socials: existing.socials || artist.socials || {},
          verifiedHandle: existing.verifiedHandle || 'admin',
          verifiedVia: existing.verifiedVia || 'admin',
        });
        nextClaims[table.id] = claim;
        count++;
      }
      setClaimsMap(nextClaims);
      setStatus(`imported ${tag} onto ${count} artist${count === 1 ? '' : 's'}.`);
      claims?.refresh?.();
    } catch (err) {
      setStatus(err.message || 'import failed');
    } finally {
      setBusy(false);
    }
  };

  const clearPlaceholderFandoms = async () => {
    if (!confirm('clear fandoms that still look like placeholder tags across the current data?')) return;
    setBusy(true);
    setStatus('');
    try {
      let count = 0;
      const nextClaims = { ...claimsMap };
      for (const table of tableOptions) {
        const baseArtist = table.artists?.[0] || {};
        const existing = nextClaims[table.id] || {};
        const currentFandoms = existing.fandoms || baseArtist.fandoms || [];
        if (!currentFandoms.length) continue;
        const onlyPlaceholders = currentFandoms.every(tag => PLACEHOLDER_FANDOMS.has(String(tag || '').trim().toLowerCase()));
        if (!onlyPlaceholders) continue;
        const claim = await api.adminPutClaim(adminToken, table.id, {
          displayName: existing.displayName || baseArtist.name || '',
          fandoms: [],
          products: existing.products || baseArtist.niches || [],
          catalogUrl: 'catalogUrl' in existing ? existing.catalogUrl : (table.catalogPostUrl || ''),
          socials: existing.socials || baseArtist.socials || {},
          contact: existing.contact || baseArtist.contact || '',
          verifiedHandle: existing.verifiedHandle || 'admin',
          verifiedVia: existing.verifiedVia || 'admin',
        });
        nextClaims[table.id] = claim;
        count++;
      }
      setClaimsMap(nextClaims);
      setEditDraft(makeDraft(editTableId, nextClaims, directory));
      setStatus(count === 0 ? 'no placeholder fandom sets found.' : `cleared placeholder fandoms on ${count} table${count === 1 ? '' : 's'}.`);
      claims?.refresh?.();
    } catch (err) {
      setStatus(err.message || 'placeholder cleanup failed');
    } finally {
      setBusy(false);
    }
  };

  const applyDataImport = async () => {
    const rows = parseDelimitedRows(importDraft);
    if (rows.length < 2) {
      setStatus('paste exported rows from sheets or csv first.');
      return;
    }
    const headers = rows[0].map(normalizeHeader);
    const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim()));
    const columnValue = (row, names) => {
      for (const name of names) {
        const idx = headers.indexOf(normalizeHeader(name));
        if (idx >= 0) return String(row[idx] || '').trim();
      }
      return '';
    };
    setBusy(true);
    setStatus('');
    try {
      const nextClaims = { ...claimsMap };
      const nextDirectory = { ...directory };
      let count = 0;
      for (const row of dataRows) {
        const tableId = columnValue(row, ['tableId', 'table', 'table_id', 'id']).toUpperCase();
        const table = tableOptions.find(t => t.id.toUpperCase() === tableId);
        if (!table) continue;
        const baseArtist = table.artists?.[0] || {};
        const existing = nextClaims[table.id] || {};
        const existingDirectory = nextDirectory[table.id] || {};
        const claimBody = {
          displayName: columnValue(row, ['displayName', 'display_name', 'artist', 'name']) || existing.displayName || baseArtist.name || '',
          fandoms: splitMultiValue(columnValue(row, ['fandoms', 'series', 'tags']), 8),
          products: splitMultiValue(columnValue(row, ['products', 'productTypes', 'product_types']), 6),
          catalogUrl: columnValue(row, ['catalogUrl', 'catalog', 'catalog_link']),
          socials: {
            x: columnValue(row, ['x', 'twitter']),
            ig: columnValue(row, ['ig', 'instagram']),
            bsky: columnValue(row, ['bsky', 'bluesky']),
          },
          contact: columnValue(row, ['contact', 'site', 'siteoremail']),
          verifiedHandle: columnValue(row, ['verifiedHandle', 'verified_handle']) || existing.verifiedHandle || 'admin',
          verifiedVia: columnValue(row, ['verifiedVia', 'verified_via']) || existing.verifiedVia || 'admin',
        };
        const claim = await api.adminPutClaim(adminToken, table.id, {
          displayName: claimBody.displayName,
          fandoms: claimBody.fandoms.length ? claimBody.fandoms : (existing.fandoms || baseArtist.fandoms || []),
          products: claimBody.products.length ? claimBody.products : (existing.products || baseArtist.niches || []),
          catalogUrl: claimBody.catalogUrl !== '' ? claimBody.catalogUrl : ('catalogUrl' in existing ? existing.catalogUrl : (table.catalogPostUrl || '')),
          socials: {
            x: claimBody.socials.x || existing.socials?.x || existingDirectory.x || baseArtist.socials?.x || '',
            ig: claimBody.socials.ig || existing.socials?.ig || existingDirectory.ig || baseArtist.socials?.ig || '',
            bsky: claimBody.socials.bsky || existing.socials?.bsky || existingDirectory.bsky || baseArtist.socials?.bsky || '',
          },
          contact: claimBody.contact || existing.contact || baseArtist.contact || '',
          verifiedHandle: claimBody.verifiedHandle,
          verifiedVia: claimBody.verifiedVia,
        });
        nextClaims[table.id] = claim;
        nextDirectory[table.id] = {
          ...(nextDirectory[table.id] || {}),
          ...(claim.socials?.x ? { x: claim.socials.x } : {}),
          ...(claim.socials?.ig ? { ig: claim.socials.ig } : {}),
          ...(claim.socials?.bsky ? { bsky: claim.socials.bsky } : {}),
        };
        count++;
      }
      await api.adminPutDirectory(adminToken, nextDirectory);
      setClaimsMap(nextClaims);
      setDirectory(nextDirectory);
      setDirDraft(JSON.stringify(nextDirectory, null, 2));
      setEditDraft(makeDraft(editTableId, nextClaims, nextDirectory));
      setStatus(count === 0 ? 'no valid table rows found in that import.' : `imported ${count} row${count === 1 ? '' : 's'} from pasted data.`);
      claims?.refresh?.();
    } catch (err) {
      setStatus(err.message || 'data import failed');
    } finally {
      setBusy(false);
    }
  };

  if (!open) return null;

  return (
    <div className="admin-root" role="dialog" aria-modal="true" aria-label="admin panel">
      <div className="admin-scrim" onClick={onClose} />
      <div className="admin-sheet">
        <button type="button" className="icon-btn close-btn admin-close" onClick={onClose} aria-label="close">×</button>
        <h2 className="admin-title">admin panel</h2>
        {!authed ? (
          <form className="admin-signin" onSubmit={onSignIn}>
            <p className="admin-lede">open the site with <strong>?admin=1</strong>, then unlock this panel with the admin token. keeping this token-only for the event week avoids OAuth drift right before the show.</p>
            <label className="claim-field">
              <span className="claim-field-label">admin token <span className="claim-opt">(optional)</span></span>
              <input
                className="claim-field-input"
                type="password"
                value={token}
                onChange={(e) => setToken(e.target.value)}
                placeholder="bearer token"
                autoFocus
              />
            </label>
            {status && <div className="claim-error">{status}</div>}
            <div className="claim-channels">
              <button type="submit" className="btn btn-primary" disabled={busy}>
                {busy ? 'signing in...' : 'unlock admin'}
              </button>
            </div>
          </form>
        ) : (
          <>
            <p className="admin-lede">
              signed in.
              <button type="button" className="link admin-signout" onClick={() => { setAuthed(false); setToken(''); setClaimsMap({}); setDirectory({}); }}>sign out</button>
            </p>

            <section className="admin-section">
              <h3 className="admin-section-title">manual requests <span className="admin-section-sub">review submitted forms</span></h3>
              {manualRequests.length === 0 ? (
                <div className="admin-empty">no manual requests.</div>
              ) : (
                <div className="admin-request-list">
                  {manualRequests.map(request => (
                    <article className={cx('admin-request-card', activeManualRequestId === request.id && 'is-active')} key={request.id}>
                      <div className="admin-request-head">
                        <div>
                          <strong>{request.displayName}</strong>
                          <span>{request.submittedAt ? new Date(request.submittedAt).toLocaleString() : 'pending'}</span>
                        </div>
                        <div className="admin-request-actions">
                          <button type="button" className="btn btn-ghost" onClick={() => loadManualRequest(request)}>load into editor</button>
                          <button type="button" className="link danger" onClick={() => dismissManualRequest(request.id)}>dismiss</button>
                        </div>
                      </div>
                      <div className="admin-request-tags">
                        {Object.entries(request.socials || {}).map(([provider, handle]) => (
                          <a
                            className="mini-tag admin-social-link"
                            key={provider}
                            href={socialProfileHref(provider, handle)}
                            target="_blank"
                            rel="noopener noreferrer"
                            title={`open ${socialProviderLabel(provider)} profile in a new tab`}
                          >
                            {socialProviderLabel(provider)}: @{handle}
                          </a>
                        ))}
                        {(request.fandoms || []).map(tag => <span className="mini-tag" key={`f-${tag}`}>{tag}</span>)}
                        {(request.products || []).map(tag => <span className="mini-tag niche" key={`p-${tag}`}>{tag}</span>)}
                      </div>
                      {(request.catalogUrl || request.contact) && (
                        <div className="admin-request-lines">
                          {request.catalogUrl && <span>catalog: <a href={request.catalogUrl} target="_blank" rel="noopener noreferrer">{request.catalogUrl}</a></span>}
                          {request.contact && (() => {
                            const href = contactHref(request.contact);
                            const external = href && !href.startsWith('mailto:');
                            return (
                              <span>
                                contact:{' '}
                                {href
                                  ? <a href={href} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined}>{request.contact}</a>
                                  : request.contact}
                              </span>
                            );
                          })()}
                        </div>
                      )}
                    </article>
                  ))}
                </div>
              )}
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">artist editor <span className="admin-section-sub">update the public card</span></h3>
              <div className="admin-editor-grid">
                <label className="claim-field">
                  <span className="claim-field-label">table</span>
                  <select className="claim-field-input" value={editTableId} onChange={(e) => setEditTableId(e.target.value)}>
                    {tableOptions.map(t => <option key={t.id} value={t.id}>{t.id} - {t.artists.map(a => a.name).join(' / ')}</option>)}
                  </select>
                </label>
                <label className="claim-field">
                  <span className="claim-field-label">artist name</span>
                  <input className="claim-field-input" value={editDraft?.displayName || ''} onChange={(e) => updateEditDraft({ displayName: e.target.value })} />
                </label>
                <label className="claim-field">
                  <span className="claim-field-label">X handle</span>
                  <input className="claim-field-input" value={editDraft?.socials?.x || ''} onChange={(e) => updateSocialDraft('x', e.target.value)} />
                </label>
                <label className="claim-field">
                  <span className="claim-field-label">Instagram handle</span>
                  <input className="claim-field-input" value={editDraft?.socials?.ig || ''} onChange={(e) => updateSocialDraft('ig', e.target.value)} />
                </label>
                <label className="claim-field">
                  <span className="claim-field-label">Bluesky handle</span>
                  <input className="claim-field-input" value={editDraft?.socials?.bsky || ''} onChange={(e) => updateSocialDraft('bsky', e.target.value)} />
                </label>
                <label className="claim-field admin-wide">
                  <span className="claim-field-label">fandoms, series, oshis</span>
                  <input className="claim-field-input" value={editDraft?.fandoms || ''} onChange={(e) => updateEditDraft({ fandoms: e.target.value })} />
                </label>
                <label className="claim-field admin-wide">
                  <span className="claim-field-label">product types</span>
                  <input className="claim-field-input" value={editDraft?.products || ''} onChange={(e) => updateEditDraft({ products: e.target.value })} />
                </label>
                <label className="claim-field admin-wide">
                  <span className="claim-field-label">catalog link</span>
                  <input className="claim-field-input" value={editDraft?.catalogUrl || ''} onChange={(e) => updateEditDraft({ catalogUrl: e.target.value })} placeholder="leave blank to remove catalog" />
                </label>
                <label className="claim-field admin-wide">
                  <span className="claim-field-label">site or email</span>
                  <input className="claim-field-input" value={editDraft?.contact || ''} onChange={(e) => updateEditDraft({ contact: e.target.value })} />
                </label>
              </div>
              <div className="admin-row">
                <button type="button" className="btn btn-primary" onClick={saveArtistClaim} disabled={busy || !editDraft?.displayName?.trim()}>
                  {busy ? 'saving...' : 'save artist card'}
                </button>
                <button type="button" className="btn btn-ghost" onClick={() => setEditDraft(makeDraft(editTableId))}>reset</button>
              </div>
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">bulk tag import <span className="admin-section-sub">paste artist names or table ids</span></h3>
              <div className="admin-bulk-grid">
                <label className="claim-field">
                  <span className="claim-field-label">tag type</span>
                  <select className="claim-field-input" value={bulkKind} onChange={(e) => setBulkKind(e.target.value)}>
                    <option value="fandoms">fandom or keyword</option>
                    <option value="products">product type</option>
                  </select>
                </label>
                <label className="claim-field">
                  <span className="claim-field-label">tag to add</span>
                  <input className="claim-field-input" value={bulkTag} onChange={(e) => setBulkTag(e.target.value)} placeholder={bulkKind === 'products' ? 'prints' : 'Original'} />
                </label>
                <label className="claim-field admin-wide">
                  <span className="claim-field-label">artists or tables <span className="claim-opt">(line by line or comma separated)</span></span>
                  <textarea className="admin-textarea" rows={4} value={bulkArtists} onChange={(e) => setBulkArtists(e.target.value)} />
                </label>
              </div>
              <div className="admin-row">
                <button type="button" className="btn btn-primary" onClick={applyBulkImport} disabled={busy || !bulkTag.trim() || !bulkArtists.trim()}>
                  apply import
                </button>
              </div>
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">data tools <span className="admin-section-sub">export current data, import from sheets, and clean placeholders</span></h3>
              <p className="admin-hint">copy the tab-separated export into Google Sheets, paste rows back here later, or load a CSV or TSV file straight into the import box.</p>
              <label className="claim-field">
                <span className="claim-field-label">current data export</span>
                <textarea
                  className="admin-textarea"
                  rows={8}
                  value={exportTsv}
                  readOnly
                  onClick={(e) => e.currentTarget.select()}
                  spellCheck="false"
                />
              </label>
              <div className="admin-row">
                <button type="button" className="btn btn-ghost" onClick={copyTsv}>copy export</button>
                <button type="button" className="btn btn-ghost" onClick={downloadCsv}>download csv</button>
                <button type="button" className="link danger" onClick={clearPlaceholderFandoms} disabled={busy}>
                  clear placeholder fandoms
                </button>
              </div>
              <label className="claim-field">
                <span className="claim-field-label">import rows <span className="claim-opt">(paste TSV from Google Sheets or a CSV with matching headers)</span></span>
                <textarea
                  className="admin-textarea"
                  rows={8}
                  value={importDraft}
                  onChange={(e) => setImportDraft(e.target.value)}
                  spellCheck="false"
                  placeholder={DATA_EXPORT_COLUMNS.map(([, label]) => label).join('\t')}
                />
              </label>
              <div className="admin-row">
                <label className="btn btn-ghost file-import-btn">
                  load csv / tsv
                  <input
                    type="file"
                    accept=".csv,.tsv,text/csv,text/tab-separated-values"
                    onChange={loadImportFile}
                  />
                </label>
                <button type="button" className="btn btn-primary" onClick={applyDataImport} disabled={busy || !importDraft.trim()}>
                  {busy ? 'importing...' : 'import pasted data'}
                </button>
              </div>
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">directory <span className="admin-section-sub">who is listed at which table</span></h3>
              <p className="admin-hint">JSON shape: <code>{'{ "A14": { "x": "_magui3", "ig": "magui_3" } }'}</code></p>
              <textarea
                className="admin-textarea"
                rows={10}
                value={dirDraft}
                onChange={(e) => setDirDraft(e.target.value)}
                spellCheck="false"
              />
              <div className="admin-row">
                <button type="button" className="btn btn-primary" onClick={saveDirectory} disabled={busy}>
                  {busy ? 'saving...' : 'save directory'}
                </button>
                <button type="button" className="btn btn-ghost" onClick={() => setDirDraft(JSON.stringify(directory, null, 2))}>
                  reset draft
                </button>
                <span className="admin-count">{Object.keys(directory).length} entries</span>
              </div>
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">claims <span className="admin-section-sub">what artists have edited</span></h3>
              {Object.keys(claimsMap).length === 0 ? (
                <div className="admin-empty">no claims yet.</div>
              ) : (
                <table className="admin-claims">
                  <thead>
                    <tr>
                      <th>table</th>
                      <th>display name</th>
                      <th>via</th>
                      <th>handle</th>
                      <th>updated</th>
                      <th></th>
                    </tr>
                  </thead>
                  <tbody>
                    {Object.entries(claimsMap)
                      .sort((a, b) => a[0].localeCompare(b[0], 'en', { numeric: true }))
                      .map(([id, c]) => (
                        <tr key={id}>
                          <td className="admin-mono">{id}</td>
                          <td>{c.displayName || <em className="admin-dim">none</em>}</td>
                          <td className="admin-mono">{c.verifiedVia || ''}</td>
                          <td className="admin-mono">@{c.verifiedHandle || ''}</td>
                          <td className="admin-dim">{c.updatedAt ? new Date(c.updatedAt).toLocaleString() : ''}</td>
                          <td>
                            <button type="button" className="link danger" onClick={() => deleteClaim(id)}>delete</button>
                          </td>
                        </tr>
                      ))}
                  </tbody>
                </table>
              )}
            </section>

            <section className="admin-section">
              <h3 className="admin-section-title">handle candidates <span className="admin-section-sub">research tool, approve to seed or reject to hide</span></h3>
              <p className="admin-hint">candidates are generated from each artist's listed name using common variation heuristics. bluesky shows only handles that actually resolve; X/IG need a click-through check.</p>
              {window.AS?.SeedCandidates
                ? <window.AS.SeedCandidates
                    token={adminToken}
                    directory={directory}
                    onDirectoryChange={(next) => {
                      setDirectory(next);
                      setDirDraft(JSON.stringify(next, null, 2));
                    }}
                  />
                : <div className="admin-empty">seed candidate module not loaded.</div>
              }
            </section>

            {status && <div className={cx(status.includes('fail') || status.includes('error') ? 'claim-error' : 'claim-ok')}>{status}</div>}
          </>
        )}
      </div>
    </div>
  );
}

// Temporary launch-week claim modal. For OshiUpLink 2026 we are intentionally
// keeping this in manual review mode so exhibitors can submit updates without
// us depending on OAuth setup this close to the event.

function ClaimTableModal({ open, onClose }) {
  const claims = useClaims();

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

  if (!open) return null;

  const workerReady = !!claims?.workerReady;

  const bodyByStep = () => {
    if (!workerReady) return <ClaimOfflineNotice />;
    return <ClaimManualReview />;
  };

  return (
    <div className="claim-root" role="dialog" aria-modal="true" aria-label="claim your table">
      <div className="claim-scrim" onClick={onClose} />
      <div className="claim-sheet">
        <button type="button" className="icon-btn close-btn claim-close" onClick={onClose} aria-label="close">×</button>
        <h2 className="claim-title">claim your table</h2>
        {bodyByStep()}
      </div>
    </div>
  );
}

function ClaimOfflineNotice() {
  return (
    <div className="claim-offline">
      <p className="claim-lede">
        i'm keeping claim requests in manual review mode right now. if this page
        is offline for any reason, send me a DM at
        <a href="https://x.com/catsgomao" target="_blank" rel="noopener noreferrer"> @catsgomao</a> or
        <a href="https://bsky.app/profile/maiko.cafe" target="_blank" rel="noopener noreferrer"> @maiko.cafe</a> and
        include the same details you'd put in the form.
      </p>
    </div>
  );
}

function ClaimManualReview() {
  return (
    <>
      <div className="claim-review-stack">
        <div className="claim-review-copy">
          <p className="claim-lede">
            fill this out, then send me a DM from one of the socials you listed so i
            can verify it and add it.
          </p>
          <div className="claim-how">
            <div className="claim-how-title">how to make this faster</div>
            <ul className="claim-list claim-list-tight">
              <li>submit the socials you want shown on your card</li>
              <li>send a DM from one of those socials, or from one that clearly matches your artist name</li>
              <li>message <a href="https://x.com/catsgomao" target="_blank" rel="noopener noreferrer">@catsgomao</a> on X or <a href="https://bsky.app/profile/maiko.cafe" target="_blank" rel="noopener noreferrer">@maiko.cafe</a> on Bluesky</li>
            </ul>
          </div>
        </div>

        <p className="claim-foot claim-review-foot">
          manual requests land in my admin queue. i'll bring automatic claim editing
          back after the event.
        </p>
      </div>
      <ManualClaimForm />
    </>
  );
}

function ManualClaimForm() {
  const api = window.AS_CLAIMS || {};
  const [name, setName] = useState('');
  const [socialInputs, setSocialInputs] = useState(['']);
  const [fandomsText, setFandomsText] = useState('');
  const [products, setProducts] = useState(new Set());
  const [catalogUrl, setCatalogUrl] = useState('');
  const [contact, setContact] = useState('');
  const [ack, setAck] = useState(false);
  const [status, setStatus] = useState('');
  const [submitting, setSubmitting] = useState(false);

  const socials = useMemo(() => {
    const out = {};
    for (const input of socialInputs) {
      const parsed = parseSocialUrl(input);
      if (parsed) out[parsed.provider] = parsed.handle;
    }
    return out;
  }, [socialInputs]);

  const toggleProduct = (p) => {
    setProducts(prev => {
      const next = new Set(prev);
      if (next.has(p)) next.delete(p);
      else if (next.size < 6) next.add(p);
      return next;
    });
  };

  const submit = async (e) => {
    e.preventDefault();
    if (!ack || !name.trim() || Object.keys(socials).length === 0) return;
    setSubmitting(true);
    setStatus('');
    try {
      await api.submitManualClaim({
        displayName: name,
        socials,
        fandoms: parseCSV(fandomsText, 8),
        products: [...products],
        catalogUrl,
        contact,
        acknowledged: ack,
      });
      setStatus('submitted. now send me your DM so i can verify it and approve it.');
      setName('');
      setSocialInputs(['']);
      setFandomsText('');
      setProducts(new Set());
      setCatalogUrl('');
      setContact('');
      setAck(false);
    } catch (err) {
      setStatus(err.message || 'submission failed. try again later.');
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form className="manual-claim" onSubmit={submit}>
      <div className="manual-claim-head">
        <h3>manual claim request</h3>
        <p>fill this out, then send a matching DM so i can review it.</p>
      </div>
      <label className="claim-field manual-field-name">
        <span className="claim-field-label">artist name</span>
        <input className="claim-field-input" value={name} onChange={(e) => setName(e.target.value)} maxLength={64} />
      </label>
      <div className="manual-socials">
        {socialInputs.map((value, i) => (
          <div className="manual-social-row" key={i}>
            <label className="claim-field">
              <span className="claim-field-label">social media link {i + 1}</span>
              <input
                className="claim-field-input"
                value={value}
                onChange={(e) => setSocialInputs(prev => prev.map((v, idx) => idx === i ? e.target.value : v))}
                placeholder="https://x.com/yourhandle"
              />
            </label>
            {i > 0 && (
              <button
                type="button"
                className="icon-btn manual-remove"
                onClick={() => setSocialInputs(prev => prev.filter((_, idx) => idx !== i))}
                aria-label={`remove social media link ${i + 1}`}
              >
                ×
              </button>
            )}
          </div>
        ))}
        {socialInputs.length < 3 && (
          <button type="button" className="link manual-add" onClick={() => setSocialInputs(prev => [...prev, ''])}>
            add another social
          </button>
        )}
      </div>
      <label className="claim-field manual-field-fandoms">
        <span className="claim-field-label">fandoms, series, oshis <span className="claim-opt">(up to 8)</span></span>
        <input className="claim-field-input" value={fandomsText} onChange={(e) => setFandomsText(e.target.value)} placeholder="Splatoon, Original" />
      </label>
      <div className="claim-field manual-field-products">
        <span className="claim-field-label">product types <span className="claim-opt">(up to 6)</span></span>
        <div className="preset-grid">
          {PRODUCT_PRESETS.map(p => (
            <button type="button" key={p} className={cx('preset-chip', products.has(p) && 'is-selected')} onClick={() => toggleProduct(p)}>
              {p}
            </button>
          ))}
        </div>
      </div>
      <label className="claim-field manual-field-catalog">
        <span className="claim-field-label">catalog link <span className="claim-opt">(optional)</span></span>
        <input className="claim-field-input" value={catalogUrl} onChange={(e) => setCatalogUrl(e.target.value)} placeholder="https://x.com/you/status/..." />
      </label>
      <label className="claim-field manual-field-contact">
        <span className="claim-field-label">site or email for contact</span>
        <input className="claim-field-input" value={contact} onChange={(e) => setContact(e.target.value)} placeholder="hello@example.com" />
      </label>
      <div className="claim-dm-note">
        <strong>after submitting:</strong> send a DM from one of the linked socials, or from an account that clearly matches your artist name, to <a href="https://x.com/catsgomao" target="_blank" rel="noopener noreferrer">@catsgomao</a> or <a href="https://bsky.app/profile/maiko.cafe" target="_blank" rel="noopener noreferrer">@maiko.cafe</a>.
      </div>
      <label className="manual-ack">
        <input type="checkbox" checked={ack} onChange={(e) => setAck(e.target.checked)} />
        <span>i acknowledge that the information submitted is valid. if it is misleading or untrue, maiko.cafe may remove any or all submitted information from this tool and prevent future edit attempts.</span>
      </label>
      {status && <div className={cx(status.startsWith('submitted') ? 'claim-ok' : 'claim-error')}>{status}</div>}
      <button type="submit" className="btn btn-primary" disabled={submitting || !ack || !name.trim() || Object.keys(socials).length === 0}>
        {submitting ? 'submitting...' : 'submit claim request'}
      </button>
    </form>
  );
}

function ClaimNoMatch({ session, onSignOut }) {
  return (
    <>
      <p className="claim-lede">
        signed in as <strong>@{session.handle}</strong> via <em>{session.via}</em>, but
        i don't see that handle listed on any booth yet.
      </p>
      <ul className="claim-list">
        <li>if your booth was listed with a different handle, sign in with that one instead</li>
        <li>if you haven't been added yet, email me at <a href="mailto:hello@maiko.cafe">hello@maiko.cafe</a> and i'll seed the directory</li>
      </ul>
      <div className="claim-channels">
        <button type="button" className="btn btn-ghost" onClick={onSignOut}>sign out</button>
      </div>
    </>
  );
}

function ClaimEdit({ session, tables, activeTable, setActiveTable, save, getExisting, onSignOut }) {
  const tableId = activeTable || tables[0];
  const existing = getExisting(tableId) || {};
  const liveTable = (window.TABLES || []).find(t => t.id === tableId);
  const liveArtist = liveTable?.artists?.[0];

  const [displayName, setDisplayName] = useState(existing.displayName || liveArtist?.name || '');
  const [fandomsText, setFandomsText] = useState((existing.fandoms || liveArtist?.fandoms || []).join(', '));
  const [products, setProducts] = useState(() => new Set(existing.products || liveArtist?.niches || []));
  const [catalogUrl, setCatalogUrl] = useState(existing.catalogUrl || liveTable?.catalogPostUrl || '');
  const [socialLinks, setSocialLinks] = useState({
    x: existing.socials?.x || liveArtist?.socials?.x || '',
    ig: existing.socials?.ig || liveArtist?.socials?.ig || '',
    bsky: existing.socials?.bsky || liveArtist?.socials?.bsky || '',
  });
  const [contact, setContact] = useState(existing.contact || liveArtist?.contact || '');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');
  const [saved, setSaved] = useState(false);

  useEffect(() => {
    const c = getExisting(tableId) || {};
    const t = (window.TABLES || []).find(x => x.id === tableId);
    const a = t?.artists?.[0];
    setDisplayName(c.displayName || a?.name || '');
    setFandomsText((c.fandoms || a?.fandoms || []).join(', '));
    setProducts(new Set(c.products || a?.niches || []));
    setCatalogUrl(c.catalogUrl || t?.catalogPostUrl || '');
    setSocialLinks({
      x: c.socials?.x || a?.socials?.x || '',
      ig: c.socials?.ig || a?.socials?.ig || '',
      bsky: c.socials?.bsky || a?.socials?.bsky || '',
    });
    setContact(c.contact || a?.contact || '');
    setSaved(false); setError('');
  }, [tableId, getExisting]);

  const toggleProductPreset = (p) => {
    setProducts(prev => {
      const next = new Set(prev);
      if (next.has(p)) next.delete(p);
      else if (next.size < 6) next.add(p);
      return next;
    });
  };

  const onSubmit = async (e) => {
    e.preventDefault();
    setBusy(true); setError(''); setSaved(false);
    try {
      await save(tableId, {
        displayName,
        fandoms: fandomsText.split(',').map(s => s.trim()).filter(Boolean),
        products: [...products],
        catalogUrl,
        socials: socialLinks,
        contact,
      });
      setSaved(true);
    } catch (err) {
      setError(err.message || 'save failed');
    } finally {
      setBusy(false);
    }
  };

  return (
    <form className="claim-edit" onSubmit={onSubmit}>
      <div className="claim-edit-whoami">
        signed in as <strong>@{session.handle}</strong>
        <span className="claim-edit-via"> via {session.via}</span>
        <button type="button" className="link claim-signout" onClick={onSignOut}>sign out</button>
      </div>

      {tables.length > 1 && (
        <label className="claim-field">
          <span className="claim-field-label">which table?</span>
          <select
            className="claim-field-input"
            value={tableId}
            onChange={(e) => setActiveTable(e.target.value)}
          >
            {tables.map(t => <option key={t} value={t}>{t}</option>)}
          </select>
        </label>
      )}
      {tables.length === 1 && (
        <div className="claim-edit-table">editing table <strong>{tableId}</strong></div>
      )}

      <label className="claim-field">
        <span className="claim-field-label">display name</span>
        <input
          className="claim-field-input"
          type="text"
          value={displayName}
          onChange={(e) => setDisplayName(e.target.value)}
          maxLength={64}
          placeholder="e.g. Magui"
        />
      </label>

      <label className="claim-field">
        <span className="claim-field-label">fandoms &amp; series <span className="claim-opt">(comma-separated, up to 8)</span></span>
        <input
          className="claim-field-input"
          type="text"
          value={fandomsText}
          onChange={(e) => setFandomsText(e.target.value)}
          placeholder="Splatoon, Original, Zelda"
        />
      </label>

      <div className="claim-field">
        <span className="claim-field-label">products <span className="claim-opt">(choose up to 6)</span></span>
        <div className="preset-grid">
          {PRODUCT_PRESETS.map(p => (
            <button type="button" key={p} className={cx('preset-chip', products.has(p) && 'is-selected')} onClick={() => toggleProductPreset(p)}>
              {p}
            </button>
          ))}
        </div>
      </div>

      <div className="claim-social-grid">
        {['x', 'ig', 'bsky'].map(provider => (
          <label className="claim-field" key={provider}>
            <span className="claim-field-label">{provider} handle</span>
            <input
              className="claim-field-input"
              type="text"
              value={socialLinks[provider] || ''}
              onChange={(e) => setSocialLinks(prev => ({ ...prev, [provider]: e.target.value }))}
              placeholder={provider === 'x' ? '_magui3' : ''}
            />
          </label>
        ))}
      </div>

      <label className="claim-field">
        <span className="claim-field-label">catalog post URL <span className="claim-opt">(optional)</span></span>
        <input
          className="claim-field-input"
          type="url"
          value={catalogUrl}
          onChange={(e) => setCatalogUrl(e.target.value)}
          placeholder="https://x.com/you/status/..."
        />
      </label>

      <label className="claim-field">
        <span className="claim-field-label">site or email for contact <span className="claim-opt">(optional)</span></span>
        <input
          className="claim-field-input"
          type="text"
          value={contact}
          onChange={(e) => setContact(e.target.value)}
          placeholder="hello@example.com"
        />
      </label>

      {error && <div className="claim-error">{error}</div>}
      {saved && <div className="claim-ok">saved. your booth is live on the list.</div>}

      <div className="claim-channels">
        <button type="submit" className="btn btn-primary" disabled={busy}>
          {busy ? 'saving...' : 'save changes'}
        </button>
      </div>
    </form>
  );
}

function App() {
  const isMobile = useMediaMax(820);
  const isCompact = useMediaMax(1100);
  const isCompactDesktop = !isMobile && isCompact;
  const [theme, toggleTheme] = useTheme();
  // Pulls the claims cache so downstream memos invalidate once overlays
  // from the worker have been applied to TABLES.
  const claimsCtx = useClaims();
  const claimsVersion = claimsCtx?.claims;

  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState({
    savedOnly: false,
    visitedOnly: false,
    notVisitedOnly: false,
    notedOnly: false,
    hasCatalog: false,
    fandoms: new Set(),
    niches: new Set(),
  });
  const [selectedId, setSelectedId] = useState(null);
  const [selectionMode, setSelectionMode] = useState('inline');
  const [sideCollapsed, setSideCollapsed] = useState(false);
  const [savedDrawerOpen, setSavedDrawerOpen] = useState(false);
  const [exportNotesOpen, setExportNotesOpen] = useState(false);
  const [filterSheet, setFilterSheet] = useState(null);
  const [settingsOpen, setSettingsOpen] = useState(false);
  const [mobileView, setMobileView] = useState('map');
  const [claimOpen, setClaimOpen] = useState(false);
  const [faqOpen, setFaqOpen] = useState(false);
  const [adminOpen, setAdminOpen] = useState(false);
  // Admin link is hidden by default. Shows when the URL carries ?admin=1 or
  // when the operator has already opened the panel once this session.
  const [adminVisible, setAdminVisible] = useState(() => {
    try { return new URL(window.location.href).searchParams.has('admin'); } catch { return false; }
  });
  // Auto-open the admin panel on first load when `?admin=1` is present.
  useEffect(() => {
    if (adminVisible) setAdminOpen(true);
  }, []); // eslint-disable-line

  const [savedIds, toggleSave, clearSaved]       = useSaved();
  const [visitedIds, toggleVisited]              = useVisited();
  const [notes, setNote]                         = useNotes();

  // Derive the set of tables that have non-empty notes.
  const notedIds = useMemo(() => {
    const s = new Set();
    for (const [id, text] of Object.entries(notes || {})) {
      if (text && text.trim()) s.add(id);
    }
    return s;
  }, [notes]);

  const searchFilters = useMemo(() => ({
    ...filters,
    savedIds,
    visitedIds,
    notedIds,
  }), [filters, savedIds, visitedIds, notedIds]);

  const { matchIds, anyFilter } = useMemo(
    () => searchTables(TABLES, query, searchFilters),
    [query, searchFilters, claimsVersion]
  );

  const results = useMemo(() => {
    const list = anyFilter ? TABLES.filter(t => matchIds.has(t.id)) : TABLES;
    // keep a stable natural-ish sort for nav arrows + list
    return [...list].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
  }, [matchIds, anyFilter, claimsVersion]);

  const selected = useMemo(
    () => TABLES.find(t => t.id === selectedId) || null,
    [selectedId, claimsVersion]
  );

  // Prev/Next within the currently visible (filtered) list. Falls back to all
  // tables when the current selection has been filtered out.
  const navList = useMemo(() => {
    if (selected && !results.find(t => t.id === selected.id)) {
      return [...TABLES].sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
    }
    return results;
  }, [results, selected]);

  const navIdx = selected ? navList.findIndex(t => t.id === selected.id) : -1;
  const goPrev = navIdx > 0 ? () => setSelectedId(navList[navIdx - 1].id) : null;
  const goNext = navIdx >= 0 && navIdx < navList.length - 1 ? () => setSelectedId(navList[navIdx + 1].id) : null;

  const closeSelection = useCallback(() => setSelectedId(null), []);
  const selectFromList = useCallback((id) => {
    setMobileView('list');
    setSelectedId(id);
    setSelectionMode(isMobile ? 'modal' : 'inline');
  }, [isMobile]);
  const selectFromMap = useCallback((id) => {
    setSelectedId(id);
    setSelectionMode(isMobile || isCompactDesktop ? 'modal' : 'inline');
  }, [isMobile, isCompactDesktop]);
  const selectFromSearch = useCallback((id) => {
    if ((isMobile || isCompactDesktop) && mobileView === 'map') {
      setSelectedId(id);
      setSelectionMode('modal');
      return;
    }
    selectFromList(id);
  }, [isMobile, isCompactDesktop, mobileView, selectFromList]);
  const resetFilters = useCallback(() => {
    setQuery('');
    setFilters({
      savedOnly: false,
      visitedOnly: false,
      notVisitedOnly: false,
      notedOnly: false,
      hasCatalog: false,
      fandoms: new Set(),
      niches: new Set(),
    });
  }, []);

  const confirmClearFilters = useCallback(() => {
    if (!anyFilter) return;
    if (window.confirm('clear all filters?')) resetFilters();
  }, [anyFilter, resetFilters]);

  const showMobileMap = useCallback(() => {
    setMobileView('map');
  }, []);
  const showMobileList = useCallback(() => {
    setMobileView('list');
  }, []);

  // Clicking a tag in the detail card adds it to the right filter set and
  // drops the user back to the list so they can see every matching table.
  const onFilterByTag = useCallback((kind, value) => {
    setFilters(prev => {
      const key = kind === 'niche' ? 'niches' : 'fandoms';
      const next = new Set(prev[key]);
      next.add(value);
      return { ...prev, [key]: next };
    });
    setSelectedId(null);
  }, []);

  // Keyboard shortcuts: "/" focuses search, esc closes modal/detail/drawer,
  // +/-/0 act as zoom bindings on the map (delegated via a window event).
  useEffect(() => {
    const isTextField = () => {
      const el = document.activeElement;
      return el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
    };
    const onKey = (e) => {
      if (e.key === 'Escape') { setSelectedId(null); setSavedDrawerOpen(false); }
      if (e.key === '/' && !isTextField()) {
        e.preventDefault();
        document.querySelector('.search input')?.focus();
      }
      if (selected) {
        if (e.key === 'ArrowLeft'  && goPrev) goPrev();
        if (e.key === 'ArrowRight' && goNext) goNext();
      }
      if (!isTextField()) {
        if (e.key === '+' || e.key === '=') window.dispatchEvent(new CustomEvent('as:zoom', { detail: 'in' }));
        if (e.key === '-' || e.key === '_') window.dispatchEvent(new CustomEvent('as:zoom', { detail: 'out' }));
        if (e.key === '0')                  window.dispatchEvent(new CustomEvent('as:zoom', { detail: 'reset' }));
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [selected, goPrev, goNext]);

  const mapProps = {
    tables: TABLES,
    structure: MAP_STRUCTURE,
    selectedId,
    matchIds: anyFilter ? matchIds : null,
    filtersActive: anyFilter,
    savedIds,
    visitedIds,
    notes,
    onSelect: selectFromMap,
  };

  const filterBarProps = {
    tables: TABLES,
    query, onQuery: setQuery,
    filters, setFilters,
    savedCount: savedIds.size,
    visitedCount: visitedIds.size,
    notVisitedCount: TABLES.filter(t => !visitedIds.has(t.id)).length,
    notedCount: notedIds.size,
    anyFilter,
    onClearFilters: confirmClearFilters,
    placeholderNote: EVENT.placeholder,
    onPickTable: selectFromSearch,
  };
  const showModalDetail = !!selected && (isMobile || (isCompactDesktop && selectionMode === 'modal'));
  const showDesktopInlineDetail = !isMobile && selected && !showModalDetail;
  const selectedPrimary = selected?.artists?.[0] || null;
  const selectedEmbedHandle = (selected?.hasCatalog && selectedPrimary?.socials?.x) ? selectedPrimary.socials.x : null;
  const selectedRawCatalogPostUrl = (selected?.hasCatalog && selected?.catalogPostUrl) ? selected.catalogPostUrl : null;
  const selectedHasCatalogContent = !!(selected?.hasCatalog && (selectedRawCatalogPostUrl || selectedEmbedHandle));

  return (
    <div className={cx('app-shell', isMobile ? 'is-mobile' : 'is-desktop', isCompactDesktop && 'is-compact-desktop', showDesktopInlineDetail && selectedHasCatalogContent && 'has-inline-catalog-detail')}>
      {!isMobile && (
        <TopBar
          onOpenSaved={() => setSavedDrawerOpen(true)}
          onOpenClaim={() => setClaimOpen(true)}
          theme={theme}
          onToggleTheme={toggleTheme}
          onOpenFaq={() => setFaqOpen(true)}
        />
      )}

      {isMobile ? (
        <main className={cx('mobile-main', mobileView === 'list' && 'is-list-mode')}>
          <MobileChrome
            savedCount={savedIds.size}
            listCount={results.length}
            activeView={mobileView}
            hasFilters={anyFilter}
            onShowMap={showMobileMap}
            onShowList={showMobileList}
            theme={theme}
            onToggleTheme={toggleTheme}
            onOpenSaved={() => setSavedDrawerOpen(true)}
            onOpenStatus={() => setFilterSheet('status')}
            onOpenFandoms={() => setFilterSheet('fandom')}
            onOpenProducts={() => setFilterSheet('niche')}
            onClearFilters={confirmClearFilters}
          />
          <div className="mobile-filter-cards">
            <SelectedTagSummary
              mobile
              label="Fandoms"
              values={filters.fandoms}
              onClear={() => {}}
              onOpen={() => setFilterSheet('fandom')}
            />
            <SelectedTagSummary
              mobile
              label="Products"
              values={filters.niches}
              onClear={() => {}}
              onOpen={() => setFilterSheet('niche')}
            />
          </div>
          <div className="mobile-search-row">
            <SearchInput
              value={query}
              onChange={setQuery}
              tables={TABLES}
              onPickTable={selectFromSearch}
              placeholder="Search artists, tables, or keywords..."
            />
          </div>
          {mobileView === 'map' && (
            <div className="mobile-map-pane">
              <FloorMap {...mapProps} />
            </div>
          )}
          {mobileView === 'list' && (
            <div className="mobile-list-sheet is-full">
              <SidePanel
                mode="list"
                detailVariant="mobile"
                tables={TABLES}
                selected={selected}
                results={results}
                filtersActive={anyFilter}
                savedIds={savedIds}
                visitedIds={visitedIds}
                notes={notes}
                onSelect={selectFromList}
                onClose={closeSelection}
                onPrev={goPrev}
                onNext={goNext}
                onToggleSave={toggleSave}
                onToggleVisited={toggleVisited}
                onSetNote={setNote}
                onFilterByTag={onFilterByTag}
                collapsed={false}
              />
            </div>
          )}
          <MobileBottomNav
            activeView={mobileView}
            onShowMap={showMobileMap}
            onShowList={showMobileList}
            onOpenSaved={() => setSavedDrawerOpen(true)}
            onOpenSettings={() => setSettingsOpen(true)}
          />
          {showModalDetail && (
            <MobileDetailModal
              table={selected}
              onClose={closeSelection}
              onPrev={goPrev}
              onNext={goNext}
              saved={savedIds.has(selected.id)}
              visited={visitedIds.has(selected.id)}
              note={notes[selected.id] || ''}
              onToggleSave={toggleSave}
              onToggleVisited={toggleVisited}
              onSetNote={setNote}
              onFilterByTag={onFilterByTag}
            />
          )}
          <MobileSettingsSheet
            open={settingsOpen}
            onClose={() => setSettingsOpen(false)}
            onOpenFaq={() => setFaqOpen(true)}
            onOpenClaim={() => setClaimOpen(true)}
            onExportNotes={() => setExportNotesOpen(true)}
          />
        </main>
      ) : (
        <main className={cx('desktop-main', isCompactDesktop && 'is-compact-desktop')}>
          <DesktopFilterStrip {...filterBarProps} onOpenFilter={setFilterSheet} />
          {isCompactDesktop && (
            <CompactViewTabs
              activeView={mobileView}
              listCount={results.length}
              onShowMap={showMobileMap}
              onShowList={showMobileList}
            />
          )}
          <section className={cx('desktop-workspace', isCompactDesktop && 'is-compact-workspace', isCompactDesktop && `show-${mobileView}`, showDesktopInlineDetail && selectedHasCatalogContent && 'has-catalog-detail')}>
            {(!isCompactDesktop || mobileView === 'map') && (
              <div className="map-card">
                <div className="section-card-head">
                  <div className="map-heading">
                    <span className="map-title">{EVENT.name}</span>
                    <span className="map-event-line">{EVENT.venue} · {EVENT.dates}</span>
                  </div>
                </div>
                <div className="map-frame">
                  <FloorMap {...mapProps} />
                </div>
              </div>
            )}
            {(!isCompactDesktop || mobileView === 'list') && (
              <div className="desktop-list-card">
              <SidePanel
                mode={showDesktopInlineDetail ? 'detail' : 'list'}
                tables={TABLES}
                selected={selected}
                results={results}
                filtersActive={anyFilter}
                savedIds={savedIds}
                visitedIds={visitedIds}
                notes={notes}
                onSelect={selectFromList}
                onClose={closeSelection}
                onPrev={goPrev}
                onNext={goNext}
                onToggleSave={toggleSave}
                onToggleVisited={toggleVisited}
                onSetNote={setNote}
                onFilterByTag={onFilterByTag}
                collapsed={sideCollapsed}
              />
              </div>
            )}
          </section>
          {showModalDetail && (
            <MobileDetailModal
              table={selected}
              onClose={closeSelection}
              onPrev={goPrev}
              onNext={goNext}
              saved={savedIds.has(selected.id)}
              visited={visitedIds.has(selected.id)}
              note={notes[selected.id] || ''}
              onToggleSave={toggleSave}
              onToggleVisited={toggleVisited}
              onSetNote={setNote}
              onFilterByTag={onFilterByTag}
            />
          )}
        </main>
      )}

      {filterSheet && (
        <FilterSelectSheet
          kind={filterSheet}
          tables={TABLES}
          filters={filters}
          setFilters={setFilters}
          onClose={() => setFilterSheet(null)}
          mobile={isMobile}
          savedCount={savedIds.size}
          visitedCount={visitedIds.size}
          notVisitedCount={TABLES.filter(t => !visitedIds.has(t.id)).length}
          notedCount={notedIds.size}
        />
      )}

      <SavedDrawer
        open={savedDrawerOpen}
        onClose={() => setSavedDrawerOpen(false)}
        savedIds={savedIds}
        onToggleSave={toggleSave}
        onJumpTo={selectFromList}
        onClearAll={clearSaved}
      />

      <ExportNotesDrawer
        open={exportNotesOpen}
        onClose={() => setExportNotesOpen(false)}
        tables={TABLES}
        savedIds={savedIds}
        notes={notes}
      />

      <SiteFooter
        onClaim={() => setClaimOpen(true)}
        onFaq={() => setFaqOpen(true)}
      />
      <ClaimTableModal open={claimOpen} onClose={() => setClaimOpen(false)} />
      <FaqModal
        open={faqOpen}
        onClose={() => setFaqOpen(false)}
        onOpenClaim={() => setClaimOpen(true)}
      />
      <AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} />
    </div>
  );
}

// ---- mobile popup (small modal, one-at-a-time, X to close, prev/next) ----
function MobileDetailModal({ table, onClose, onPrev, onNext, saved, visited, note, onToggleSave, onToggleVisited, onSetNote, onFilterByTag }) {
  // Lock body scroll while the modal is open so the page doesn't scroll under.
  useEffect(() => {
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { document.body.style.overflow = prev; };
  }, []);

  return (
    <div className="m-modal-root" role="dialog" aria-modal="true" aria-label={`table ${table.id}`}>
      <div className="m-modal-scrim" onClick={onClose} />
      <div className={cx('m-modal', table.hasCatalog && 'has-catalog-content')}>
        <DetailPanel
          variant="mobile"
          table={table}
          onClose={onClose}
          onPrev={onPrev}
          onNext={onNext}
          saved={saved}
          visited={visited}
          note={note}
          onToggleSave={onToggleSave}
          onToggleVisited={onToggleVisited}
          onSetNote={onSetNote}
          onFilterByTag={onFilterByTag}
        />
      </div>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <ClaimsProvider>
    <App />
  </ClaimsProvider>
);
