/* App shell: hash router, nav + search, ticker, footer, and the
   Landing / Books / Book / Chapter / Merch views. */
const { useState, useEffect, useRef } = React;
const D = window.DCC;

/* ---------- global spoiler clearance — one switch for the whole site ----------
   Persisted; every later-book redaction (entry pages) reads this single state
   so a reader sets their safe zone once instead of per entry. */
const Spoil = (function(){
  // Reader level: 0 = everything later than an entry's debut book is sealed
  // (default), 1..N = the reader has read through Book N, 99 = reveal all.
  let level=0;
  try{
    const v=localStorage.getItem('dcc-spoil-level');
    if(v!=null) level=parseInt(v,10)||0;
    else if(localStorage.getItem('dcc-spoilers')==='1') level=99;   // migrate the old on/off toggle
  }catch(e){}
  window.__dccReaderLevel = level;
  const subs=new Set();
  function setLevel(v){
    level=Math.max(0, v|0); window.__dccReaderLevel=level;
    try{localStorage.setItem('dcc-spoil-level', String(level));}catch(e){}
    subs.forEach(f=>f());
  }
  return {
    level:()=>level,
    setLevel,
    get:()=>level>=99,                                              // legacy boolean: "reveal all"
    set:v=>setLevel((typeof v==='function'? v(level>=99) : v) ? 99 : 0),
    sub:f=>{ subs.add(f); return ()=>subs.delete(f); }
  };
})();
window.DCCSpoil = Spoil;
/* Per-book reader level — the primary spoiler control. [level, setLevel]. */
function useReaderLevel(){
  const [v,setV]=useState(Spoil.level());
  useEffect(()=>Spoil.sub(()=>setV(Spoil.level())),[]);
  return [v, Spoil.setLevel];
}
/* Legacy all-or-nothing hook, kept for entry.jsx's StatRow etc. */
function useSpoilers(){
  const [v,setV]=useState(Spoil.get());
  useEffect(()=>Spoil.sub(()=>setV(Spoil.get())),[]);
  const set=nv=>Spoil.set(typeof nv==='function'?nv(Spoil.get()):nv);
  return [v,set];
}
window.useReaderLevel = useReaderLevel;
window.useSpoilers = useSpoilers;

/* Pick a random entry id (for the "Surprise me" button). */
function randomEntryId(){ const o=window.DCC.ORDER; return o[Math.floor(Math.random()*o.length)]; }

/* ---------- route <-> hash ---------- */
function encode(r){
  switch(r.view){
    case 'index': return '#/index';
    case 'type': return `#/type/${r.type}`;
    case 'books': return '#/books';
    case 'charmap': return '#/characters';
    case 'book': return `#/book/${r.book}`;
    case 'chapter': return `#/book/${r.book}/ch/${r.ch}`;
    case 'entry': return `#/entry/${r.id}`;
    case 'games': return r.game ? `#/games/${r.game}` : '#/games';
    case 'floor1': return '#/floor1';
    case 'merch': return '#/merch';
    case 'floors': return '#/floors';
    case 'system': return '#/system';
    case 'glossary': return '#/glossary';
    case 'catchup': return '#/catchup';
    case 'timeline': return '#/timeline';
    case 'community': return '#/community';
    case 'gallery': return '#/gallery';
    case 'submit': return '#/submit';
    case 'review': return '#/review';
    default: return '#/';
  }
}
function decode(){
  let h = location.hash.replace(/^#\/?/,'');
  // Prerendered pages live at real paths (/entry/<id>/, /book/2/ch/5/, …)
  // that share the hash grammar. On first load with no hash at all, adopt
  // the pathname as the route; an explicit '#/' still means home.
  if(!h && !location.hash) h = decodeURIComponent(location.pathname).replace(/^\/+|\/+$/g,'');
  const p = h.split('/').filter(Boolean);
  if(p[0]==='index') return {view:'index'};
  if(p[0]==='type' && p[1] && D.TYPE_META[p[1]]) return {view:'type', type:p[1]};
  if(p[0]==='books') return {view:'books'};
  if(p[0]==='characters') return {view:'charmap'};
  if(p[0]==='games') return {view:'games', game:(p[1]==='crawl'||p[1]==='class'||p[1]==='crawldle')?p[1]:null};
  if(p[0]==='floor1') return {view:'floor1'};
  if(p[0]==='merch') return {view:'merch'};
  if(p[0]==='floors') return {view:'floors'};
  if(p[0]==='system') return {view:'system'};
  if(p[0]==='glossary') return {view:'glossary'};
  if(p[0]==='catchup') return {view:'catchup'};
  if(p[0]==='timeline') return {view:'timeline'};
  if(p[0]==='community') return {view:'community'};
  if(p[0]==='gallery') return {view:'gallery'};
  if(p[0]==='submit') return {view:'submit', id:(p[1]&&D.ENTRIES[p[1]])?p[1]:null};
  if(p[0]==='review') return {view:'review'};
  if(p[0]==='entry' && p[1] && D.ENTRIES[p[1]]) return {view:'entry', id:p[1]};
  if(p[0]==='book' && p[1]){
    const book = parseInt(p[1],10)||1;
    if(p[2]==='ch' && p[3]) return {view:'chapter', book, ch:parseInt(p[3],10)};
    return {view:'book', book};
  }
  return {view:'home'};
}

/* ---------- ticker ---------- */
function Ticker(){
  const D = window.DCC;
  const live = D.BOOKS.filter(b=>b.status==='live').map(b=>b.n);
  const liveMax = Math.max(...live);
  const partial = D.BOOKS.find(b=>b.status==='live' && b.mappedChapters < b.chapters && b.n===liveMax);
  const fullN = partial ? live.filter(n=>n!==partial.n) : live;
  const fullLabel = fullN.length>1 ? `Books ${fullN[0]}–${fullN[fullN.length-1]} indexed` : `Book ${fullN[0]} indexed`;
  const idxMsg = partial
    ? `${fullLabel}. Book ${partial.n} goes up as fast as the Crawlers clear it (ch. ${partial.mappedChapters} of ${partial.chapters}).`
    : `${fullLabel}. The deeper floors surface as the feed catches up.`;
  const items = [
    ['⚠ BROADCAST:','You are live on the dungeon feed. Smile.'],
    ['INDEX:', idxMsg],
    ['WARNING:','Future-book intel is sealed. Decrypt at your own risk.'],
    ['SPONSOR SLOT:','Unsold. The System is sulking about it.'],
  ];
  const run = items.concat(items);
  return (
    <div className="sysbar"><div className="track">
      {run.map((it,i)=>(<span key={i}><b>{it[0]}</b> {it[1]}</span>))}
    </div></div>
  );
}

/* ---------- reading-level selector (the spoiler control) ---------- */
function ReadingLevel(){
  const [level, setLevel] = useReaderLevel();
  const D = window.DCC;
  return (
    <label className={'rl'+(level>0?' on':'')} title="Set how far you've read. Later-book spoilers stay sealed across the whole index.">
      <span className="rl-ic">{level>0?'◈':'▓'}</span>
      <select className="rl-sel" value={level} onChange={e=>setLevel(+e.target.value)}>
        <option value={0}>Spoilers: sealed</option>
        {D.BOOKS.map(b=>(<option key={b.n} value={b.n}>Read thru Book {b.n}</option>))}
        <option value={99}>Reveal all</option>
      </select>
    </label>
  );
}

/* ---------- nav + search ---------- */
function Nav({route, go}){
  const [q, setQ] = useState('');
  const [open, setOpen] = useState(false);
  const [menu, setMenu] = useState(false);
  const [level, setLevel] = useReaderLevel();
  const boxRef = useRef(null);
  /* Search obeys the reading-level seal: story-beat entries whose very NAME is a
     reveal (deaths, betrayals, true-faces — the `event`/`lore` types) stay
     hidden when they belong to a book past the reader's level, so the dropdown
     can't leak what the entry pages carefully redact. Everything else stays
     searchable. */
  const SEARCH_SEAL = {event:1, lore:1};
  const sealHit = e => SEARCH_SEAL[e.type] && level<99 && (e.book||1) > level;
  const qn = q.trim().toLowerCase();
  const matches = qn.length ?
    D.allEntries().filter(e=> (e.name+' '+e.sub).toLowerCase().includes(qn)) : [];
  const sealedHits = matches.filter(sealHit).length;
  const results = matches.filter(e=>!sealHit(e)).slice(0,8);
  useEffect(()=>{
    function onDoc(e){ if(boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); }
    document.addEventListener('mousedown', onDoc);
    return ()=>document.removeEventListener('mousedown', onDoc);
  },[]);
  function pick(id){ setQ(''); setOpen(false); setMenu(false); go({view:'entry', id}); }
  const is = v => route.view===v ? 'active' : '';
  const navTo = r => { setMenu(false); go(r); };
  return (
    <nav className="nav"><div className="nav-in">
      <div className="brand" onClick={()=>navTo({view:'home'})}>
        <span className="sigil">▲</span>
        <span className="bt">System AI<small>UNOFFICIAL DCC INDEX</small></span>
      </div>
      <div className="navlinks">
        <a className={is('index')+' hide-sm'} onClick={()=>navTo({view:'index'})}>Index</a>
        <a className={is('books')+' hide-sm'} onClick={()=>navTo({view:'books'})}>Books</a>
        <a className={is('charmap')+' hide-sm'} onClick={()=>navTo({view:'charmap'})}>Characters</a>
        <div className="navdrop hide-sm">
          <a className="navdrop-btn">Guides ▾</a>
          <div className="navdrop-menu">
            <a className={is('floors')} onClick={()=>navTo({view:'floors'})}>Floors</a>
            <a className={is('system')} onClick={()=>navTo({view:'system'})}>The System</a>
            <a className={is('glossary')} onClick={()=>navTo({view:'glossary'})}>Glossary</a>
            <a className={is('timeline')} onClick={()=>navTo({view:'timeline'})}>Timeline</a>
            <a className={is('catchup')} onClick={()=>navTo({view:'catchup'})}>Where was I?</a>
            <a className={is('gallery')} onClick={()=>navTo({view:'gallery'})}>Gallery</a>
            <a className={is('games')} onClick={()=>navTo({view:'games', game:null})}>Arcade</a>
            <a className={is('community')} onClick={()=>navTo({view:'community'})}>Community</a>
            <a onClick={()=>navTo({view:'entry', id:randomEntryId()})}>⚄ Surprise me</a>
          </div>
        </div>
        <div className="navsearch" ref={boxRef}>
          <span className="mag-i">⌕</span>
          <input value={q} placeholder="Find any mention…"
            onChange={e=>{setQ(e.target.value); setOpen(true);}}
            onFocus={()=>setOpen(true)}
            onKeyDown={e=>{
              if(e.key==='Enter' && results.length){ pick(results[0].id); e.target.blur(); }
              if(e.key==='Escape'){ setQ(''); setOpen(false); e.target.blur(); }
            }}/>
          {open && q.trim().length>0 && (
            <div className="search-pop">
              {results.map(e=>(
                <div className="sr" key={e.id} onClick={()=>pick(e.id)}>
                  <window.TypeTag type={e.type} outline/>
                  <span className="nm">{e.name}</span>
                  <span className="lo">B{e.book}·C{e.chapter}</span>
                </div>
              ))}
              {!results.length && !sealedHits && <div className="empty">No mention found for “{q}”.</div>}
              {sealedHits>0 && (
                <div className="sr-sealed" onClick={()=>setLevel(99)}>
                  ▓ {sealedHits} later-book result{sealedHits===1?'':'s'} sealed · reveal spoilers
                </div>
              )}
            </div>
          )}
        </div>
        <ReadingLevel/>
        <a className="merch hide-sm" onClick={()=>navTo({view:'merch'})}>Merch</a>
        <button className="navtoggle" onClick={()=>setMenu(m=>!m)} aria-label="Menu">{menu?'✕':'☰'}</button>
      </div>
    </div>
    {menu && (
      <div className="navdrawer">
        <a className={is('index')} onClick={()=>navTo({view:'index'})}>Index</a>
        <a className={is('books')} onClick={()=>navTo({view:'books'})}>Books</a>
        <a className={is('charmap')} onClick={()=>navTo({view:'charmap'})}>Characters</a>
        <a className={is('floors')} onClick={()=>navTo({view:'floors'})}>Floors</a>
        <a className={is('system')} onClick={()=>navTo({view:'system'})}>The System</a>
        <a className={is('glossary')} onClick={()=>navTo({view:'glossary'})}>Glossary</a>
        <a className={is('timeline')} onClick={()=>navTo({view:'timeline'})}>Timeline</a>
        <a className={is('catchup')} onClick={()=>navTo({view:'catchup'})}>Where was I?</a>
        <a className={is('gallery')} onClick={()=>navTo({view:'gallery'})}>Gallery</a>
        <a className={is('games')} onClick={()=>navTo({view:'games', game:null})}>Arcade</a>
        <a className={is('community')} onClick={()=>navTo({view:'community'})}>Community</a>
        <a onClick={()=>navTo({view:'entry', id:randomEntryId()})}>⚄ Surprise me</a>
        <a className="merch" onClick={()=>navTo({view:'merch'})}>Merch</a>
      </div>
    )}
    </nav>
  );
}

/* ---------- landing ---------- */
/* Main cast eligible for the ON AIR spotlight. NOTE the real entry ids:
   Princess Donut is 'donut' (not 'princess-donut') and Katia is 'katia-grim'
   (not 'katia') — using the display-name slug would silently drop them. */
const SPOTLIGHT_CAST = ['carl', 'donut', 'mordecai', 'katia-grim'];
function pickSpotlight(){
  const pool = SPOTLIGHT_CAST.filter(id => D.entry(id));   // guard against id drift
  const id = pool[Math.floor(Math.random() * pool.length)] || 'donut';
  return D.entry(id);
}
function Landing({go}){
  // Chosen once per mount (i.e. per page load) so it stays stable across
  // re-renders but rotates at random whenever the landing is freshly loaded.
  const [feat] = useState(pickSpotlight);
  const featArt = window.useArtImage(feat.id);
  const liveBooks = D.BOOKS.filter(b=>b.status==='live').length;
  const partialBook = D.BOOKS.find(b=>b.status==='live' && b.mappedChapters<b.chapters);
  const total = D.allEntries().length;
  return (
    <div className="view">
      <header className="hero"><div className="wrap">
        <div className="hero-grid">
          <div>
            <span className="live"><span className="dot"></span>Live · now broadcasting from the dungeon</span>
            <h1 className="h-title">Welcome,<br/><span className="em">Crawler.</span></h1>
            <div className="h-onair">You're on air.</div>
            <p className="h-lede">The biggest <b>Dungeon Crawler Carl</b> index in the galaxy. Lost track of where that
              cursed item, that doomed contestant, or that busted spell first showed up? The (unofficial) System AI
              has the whole feed logged, by Book and Chapter.
              <span className="feet"> Shoes off, feet pics in, let's roll the tape.</span></p>
            <div className="h-cta">
              <a className="btn" onClick={()=>go({view:'index'})}>Open the Index ▸</a>
              <a className="btn ghost" onClick={()=>go({view:'charmap'})}>Character Timeline</a>
              <a className="btn ghost" onClick={()=>go({view:'games', game:null})}>The Arcade</a>
            </div>
            <div className="chips">
              <span className="chip"><b>{liveBooks}</b> books indexed</span>
              {partialBook && <span className="chip">Book {partialBook.n} <b>in progress</b></span>}
              <span className="chip"><b>{total}</b> entries</span>
              <span className="chip"><b>{D.INDEX_ORDER.length}</b> index categories</span>
            </div>
            <div className="seal-pill"><span className="sp-ic">▓</span> Future-book spoilers <b>sealed</b> by default. Set how far you've read from the nav, or <a onClick={()=>go({view:'catchup'})} style={{cursor:'pointer',textDecoration:'underline'}}>catch up first</a>.</div>
          </div>
          <div className="monitor">
            <span className="onair">● ON AIR</span>
            <div className={"screen"+(featArt.url?' has-img':'')} onClick={()=>go({view:'entry', id:feat.id})}>
              {featArt.url && <image-slot id={featArt.slot} display="1" src={featArt.url} fit="contain" shape="rect" className="screen-slot"
                style={{position:'absolute', inset:0, width:'100%', height:'100%', display:'block'}}></image-slot>}
              <span className="scan"></span>
              <span className="pl"><b>{feat.name}</b>featured this floor<br/>click to open the entry ▸</span>
            </div>
            <div className="cap"><span>FEED · DUNGEON FLOOR 1</span><span className="ft">REC ●</span></div>
          </div>
        </div>
      </div></header>

      <section className="section"><div className="wrap">
        <div className="feat">
          <div className="fcell"><div className="fn">01 · Mapped</div><h3>Every mention, by Book &amp; Chapter</h3>
            <p>Browse the way you read: descend a book, open a chapter, meet everything that first appears in it.</p></div>
          <div className="fcell"><div className="fn">02 · Concept Art</div><h3>System AI concept art</h3>
            <p>Badly drawn concept art provided by our own System AI. Don't like it? <a onClick={()=>go({view:'submit'})} style={{cursor:'pointer', textDecoration:'underline'}}>Upload your own art</a> and we'll be glad to host it!</p></div>
          <div className="fcell"><div className="fn">03 · Sealed</div><h3>Spoilers stay redacted</h3>
            <p>Future-book references are blacked out until you choose to decrypt them. Your timeline, your risk.</p></div>
        </div>
      </div></section>
    </div>
  );
}

/* ---------- index hub (the many ways in) ---------- */
function IndexHub({go}){
  const liveBooks = D.BOOKS.filter(b=>b.status==='live').length;
  const total = D.allEntries().length;
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:'home'})}>Home</a><span className="sep">/</span><span style={{color:'var(--ink)'}}>Index</span></div>
      <div className="sec-top">
        <h2>The <span className="am">Index</span></h2>
        <p className="note">{total} entries across {liveBooks} indexed book{liveBooks===1?'':'s'}. Pick a way in.</p>
      </div>
      <div className="hub-grid">
        <div className="hubcard book-route" onClick={()=>go({view:'books'})}>
          <div className="hubglyph">❑</div>
          <div className="hubname">By Book &amp; Chapter</div>
          <div className="hubblurb">Read-order index: descend a book, open a chapter, meet everything that first appears.</div>
          <div className="hubcount">{liveBooks} books · {D.BOOKS.reduce((n,b)=>n+D.chaptersFor(b.n).length,0)} chapters</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--t-place)'}} onClick={()=>go({view:'floors'})}>
          <div className="hubglyph" style={{color:'var(--t-place)'}}>⌖</div>
          <div className="hubname">The Floors</div>
          <div className="hubblurb">A floor-by-floor guide to all 18 levels of the World Dungeon: theme, mobs and the bosses that block the stairs.</div>
          <div className="hubcount" style={{color:'var(--t-place)'}}>18 floors</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--mag)'}} onClick={()=>go({view:'system'})}>
          <div className="hubglyph" style={{color:'var(--mag)'}}>⚜</div>
          <div className="hubname">How the System Works</div>
          <div className="hubblurb">New here? Stats, leveling, races and classes, boss tiers and loot: the rules of the crawl, spoiler-light.</div>
          <div className="hubcount">Stats · races · classes</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--t-event)'}} onClick={()=>go({view:'timeline'})}>
          <div className="hubglyph" style={{color:'var(--t-event)'}}>✷</div>
          <div className="hubname">The Timeline</div>
          <div className="hubblurb">Every major beat in order, from the collapse onward, sealed past your reading level.</div>
          <div className="hubcount" style={{color:'var(--t-event)'}}>{window.DCCX.MAJOR_TIMELINE.length} beats</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--amber)'}} onClick={()=>go({view:'catchup'})}>
          <div className="hubglyph" style={{color:'var(--amber)'}}>⟲</div>
          <div className="hubname">Where Was I?</div>
          <div className="hubblurb">Pick how far you’ve read and get a stitched-together recap, then set your spoiler level to match.</div>
          <div className="hubcount" style={{color:'var(--amber)'}}>Catch-up tool</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--t-lore)'}} onClick={()=>go({view:'glossary'})}>
          <div className="hubglyph" style={{color:'var(--t-lore)'}}>◆</div>
          <div className="hubname">The Glossary</div>
          <div className="hubblurb">Crawler, showrunner, saferoom, benefactor box: the DCC lexicon, defined.</div>
          <div className="hubcount" style={{color:'var(--t-lore)'}}>{window.DCCX.GLOSSARY.length} terms</div>
        </div>
        <div className="hubcard" style={{['--tc']:'var(--t-lootbox)'}} onClick={()=>go({view:'gallery'})}>
          <div className="hubglyph" style={{color:'var(--t-lootbox)'}}>▦</div>
          <div className="hubname">The Gallery</div>
          <div className="hubblurb">Browse the System AI concept art for the whole cast, and the community pieces readers have sent in.</div>
          <div className="hubcount" style={{color:'var(--t-lootbox)'}}>Art for the cast</div>
        </div>
        {D.INDEX_ORDER.map(type=>{
          const m = D.TYPE_META[type];
          return (
            <div key={type} className="hubcard" style={{['--tc']:`var(--t-${type})`}} onClick={()=>go({view:'type', type})}>
              <div className="hubglyph" style={{color:`var(--t-${type})`}}>{m.glyph}</div>
              <div className="hubname">{m.name}</div>
              <div className="hubblurb">{m.blurb}</div>
              <div className="hubcount">{D.typeCount(type)} entries</div>
            </div>
          );
        })}
      </div>
    </div></div>
  );
}

/* ---------- type browse (one category, all books) ---------- */
function TypeBrowse({type, go}){
  const m = D.TYPE_META[type] || {name:type};
  const ents = D.entriesByType(type);
  const byBook = {};
  ents.forEach(e=>{ (byBook[e.book] = byBook[e.book] || []).push(e); });
  const snip = (type==='lootbox' || type==='notification' || type==='spell' || type==='event' || type==='lore');
  // Book numbers this category actually appears in, low→high (the selector bar).
  const books = Object.keys(byBook).map(Number).sort((a,b)=>a-b);
  const [book, setBook] = useState('all');                 // 'all' or a book number
  useEffect(()=>{ setBook('all'); }, [type]);              // reset when switching category
  const sel = (book==='all' || !byBook[book]) ? books : [book];
  const shownCount = book==='all' ? ents.length : (byBook[book]||[]).length;
  const titleOf = n => (D.BOOKS.find(b=>b.n===n)||{}).title || '';
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs">
        <a onClick={()=>go({view:'index'})}>Index</a><span className="sep">/</span>
        <span style={{color:'var(--ink)'}}>{m.name}</span>
      </div>
      <div className="sec-top">
        <h2><span style={{color:`var(--t-${type})`}}>{m.glyph}</span> {m.name}</h2>
        <p className="note">{shownCount} {shownCount===1?'entry':'entries'}{book==='all'?' · alphabetical, grouped by first-appearance book.':` · first appearing in Book ${book}.`}</p>
      </div>
      {books.length>1 && (
        <div className="chap-filter" style={{['--tc']:`var(--t-${type})`}}>
          <button className={'cfilt'+(book==='all'?' on':'')} onClick={()=>setBook('all')}>All Books <i>{ents.length}</i></button>
          {books.map(n=>(
            <button key={n} className={'cfilt'+(book===n?' on':'')} title={titleOf(n)} onClick={()=>setBook(book===n?'all':n)}>
              Book {n} <i>{byBook[n].length}</i>
            </button>
          ))}
        </div>
      )}
      {sel.map(bk=>(
        <div key={bk} style={{marginBottom:28}}>
          <div className="block-h">Book {bk}</div>
          <div className={snip ? 'type-list' : 'entry-grid'}>
            {byBook[bk].map(e=> snip ? (
              <div key={e.id} className="ecard snip-card" style={{['--tc']:`var(--t-${type})`}} onClick={()=>go({view:'entry', id:e.id})}>
                <window.SnipThumb entry={e}/>
                <div className="ebody">
                  <div className="snip-top">
                    <window.TypeTag type={e.type}/>
                    <span className="snip-occ">B{e.book}·C{e.chapter}</span>
                  </div>
                  <h3 className="enm">{e.name}</h3>
                  <p className="ed">{e.sub || e.desc}</p>
                </div>
              </div>
            ) : (
              <div key={e.id} className="ecard" style={{['--tc']:`var(--t-${type})`}} onClick={()=>go({view:'entry', id:e.id})}>
                <window.ArtThumb entry={e}/>
                <div className="ebody">
                  <h3 className="enm">{e.name}</h3>
                  <p className="ed">{e.sub || e.desc}</p>
                </div>
              </div>
            ))}
          </div>
        </div>
      ))}
    </div></div>
  );
}

/* ---------- books index ---------- */
function Books({go}){
  const live = D.BOOKS.filter(b=>b.status==='live');
  const liveLabel = live.length===1 ? `Book ${live[0].n} is` : `Books ${live.map(b=>b.n).join(', ').replace(/, (\d+)$/,' and $1')} are`;
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:'home'})}>Home</a><span className="sep">/</span><span style={{color:'var(--ink)'}}>Books</span></div>
      <div className="sec-top"><h2>Browse by Book &amp; <span className="am">Chapter</span></h2>
        <p className="note">{liveLabel} live and fully logged. The rest unlock as the feed reaches them. Check back, Crawler.</p></div>
      <div className="book-grid">
        {D.BOOKS.map(b=>(
          <div key={b.id} className={"book"+(b.status==='soon'?' locked':'')}
            onClick={()=> b.status==='live' ? go({view:'book', book:b.n}) : null}
            style={{cursor:b.status==='live'?'pointer':'default'}}>
            <span className="spine"></span>
            {b.status==='live' ? <span className="badge" style={{color:'var(--mag)'}}>Live</span> : <span className="badge soon">▓ Sealed</span>}
            <div className="bn">BOOK {b.n}</div>
            <div className="bttl">{b.title}</div>
            <div className="bch">{b.status==='live' ? `${b.mappedChapters<b.chapters ? `${b.mappedChapters} of ${b.chapters}` : b.chapters} chapters · ${b.entries} entries` : b.sub}</div>
            {b.status!=='live' && <div className="blocknote">Not on the feed yet · unlocks when it airs</div>}
          </div>
        ))}
      </div>
    </div></div>
  );
}

/* ---------- single book (chapter list) ---------- */
function Book({book, go}){
  const b = D.BOOKS.find(x=>x.n===book) || D.BOOKS[0];
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:'books'})}>Books</a><span className="sep">/</span><span style={{color:'var(--ink)'}}>Book {b.n}</span></div>
      <div className="sec-top"><h2>{b.title}</h2>
        <p className="note">{b.mappedChapters<b.chapters
          ? `Book ${b.n} · ${b.mappedChapters} of ${b.chapters} chapters live so far. Pick a chapter to meet everything that first appears in it.`
          : `Book ${b.n} · all ${b.mappedChapters} chapters live (Epilogue included). Pick a chapter to meet everything that first appears in it.`}</p></div>
      <div className="chap-list">
        {D.chaptersFor(book).map(c=>{
          const ents = D.entriesInChapter(book, c.num);
          const recur = D.allEntries().filter(e=> e.book===book && e.chapter!==c.num && (
            (e.mentions||[]).some(m=>m.ch===c.num) || (e.awards||[]).some(a=>a.b===book && a.c===c.num)
          ));
          return (
            <div key={c.num} className="chap" onClick={()=>go({view:'chapter', book:b.n, ch:c.num})}>
              <div className="cnum">{String(c.num).padStart(2,'0')}</div>
              <div><div className="ct">{c.title}</div><div className="cs">{c.summary}</div></div>
              <div className="meta">
                <span className="count">{ents.length>0 ? `${ents.length} new ${ents.length===1?'entry':'entries'}` : (recur.length ? `${recur.length} referenced` : '—')}</span>
                <span className="dots">{[...new Set((ents.length?ents:recur).map(e=>e.type))].map((t,i)=>(<i key={i} style={{background:`var(--t-${t})`}} title={t}></i>))}</span>
              </div>
            </div>
          );
        })}
      </div>
    </div></div>
  );
}

/* ---------- chapter (entry grid) ---------- */
function Chapter({book, ch, go}){
  const [filter, setFilter] = useState('all');
  useEffect(()=>{ setFilter('all'); }, [book, ch]);
  const c = D.chapter(book, ch);
  const ents = D.entriesInChapter(book, ch);
  const firstIds = new Set(ents.map(e=>e.id));
  const recurring = D.allEntries().filter(e=> e.book===book && !firstIds.has(e.id) && (
    (e.mentions||[]).some(m=>m.ch===ch) || (e.awards||[]).some(a=>a.b===book && a.c===ch)
  ));
  const types = D.INDEX_ORDER.filter(t=> ents.some(e=>e.type===t));
  const shown = filter==='all' ? ents : ents.filter(e=>e.type===filter);
  if(!c) return <div className="view"><div className="wrap" style={{padding:'40px 0'}}>Chapter not found.</div></div>;
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs">
        <a onClick={()=>go({view:'books'})}>Books</a><span className="sep">/</span>
        <a onClick={()=>go({view:'book', book})}>Book {book}</a><span className="sep">/</span>
        <span style={{color:'var(--ink)'}}>Chapter {ch}</span>
      </div>
      <div className="sec-top">
        <h2><span style={{color:'var(--mag)'}}>{String(ch).padStart(2,'0')}</span> · {c.title}</h2>
        <p className="note">{ents.length} {ents.length===1?'entry first appears':'entries first appear'} here{recurring.length?` · ${recurring.length} also referenced`:''}.</p>
      </div>
      <p style={{maxWidth:680, color:'var(--ink-dim)', marginTop:6, marginBottom:26}}>{c.summary}</p>
      {ents.length>0 && (
        <React.Fragment>
          <div className="block-h">First appearances{filter!=='all'?` · ${D.typeName[filter]}`:''}</div>
          {types.length>1 && (
            <div className="chap-filter">
              <button className={'cfilt'+(filter==='all'?' on':'')} onClick={()=>setFilter('all')}>All <i>{ents.length}</i></button>
              {types.map(t=>(
                <button key={t} className={'cfilt'+(filter===t?' on':'')} style={{['--tc']:`var(--t-${t})`}} onClick={()=>setFilter(filter===t?'all':t)}>
                  {D.typeName[t]} <i>{ents.filter(e=>e.type===t).length}</i>
                </button>
              ))}
            </div>
          )}
        </React.Fragment>
      )}
      <div className="entry-grid">
        {shown.map(e=>{
          const isSnip = (e.type==='lootbox' || e.type==='notification' || e.type==='spell' || e.type==='event' || e.type==='lore');
          if(isSnip){
            const occ = (e.awards&&e.awards.length) ? e.awards.reduce((n,a)=>n+(a.qty||1),0) : (e.mentions&&e.mentions.length ? e.mentions.length : 1);
            const tier = (D.kindName && e.kind && D.kindName[e.kind]) || (e.stats.find(s=>s[0]==='Tier'||s[0]==='Type'||s[0]==='School')||[])[1];
            const verb = e.type==='lootbox'?'awarded':(e.type==='spell'?'appears':(e.type==='event'?'occurs':(e.type==='lore'?'noted':'logged')));
            return (
              <div key={e.id} className="ecard snip-card" style={{['--tc']:`var(--t-${e.type})`}} onClick={()=>go({view:'entry', id:e.id})}>
                <window.SnipThumb entry={e}/>
                <div className="ebody">
                  <div className="snip-top">
                    <window.TypeTag type={e.type}/>
                    {tier && <span className="snip-tier">{tier}</span>}
                    <span className="snip-occ">{verb} {occ}×</span>
                  </div>
                  <h3 className="enm">{e.name}</h3>
                  <p className="ed">{e.desc}</p>
                </div>
              </div>
            );
          }
          return (
            <div key={e.id} className="ecard" style={{['--tc']:`var(--t-${e.type})`}} onClick={()=>go({view:'entry', id:e.id})}>
              <window.ArtThumb entry={e}/>
              <div className="ebody">
                <h3 className="enm">{e.name}</h3>
                <p className="ed">{e.desc}</p>
              </div>
            </div>
          );
        })}
      </div>
      {recurring.length>0 && (
        <React.Fragment>
          <div className="block-h" style={{marginTop:34}}>Also appears in this chapter</div>
          <div style={{display:'flex',flexWrap:'wrap',gap:'10px'}}>
            {recurring.map(e=>(
              <div key={e.id} className="mention" style={{cursor:'pointer',flex:'0 0 auto'}} onClick={()=>go({view:'entry', id:e.id})}>
                <window.TypeTag type={e.type} outline/><span style={{fontFamily:'var(--f-gothic)',fontWeight:700,fontSize:'17px'}}>{e.name}</span>
              </div>
            ))}
          </div>
        </React.Fragment>
      )}
      {ents.length===0 && recurring.length===0 && (
        <p style={{color:'var(--ink-faint)',fontFamily:'var(--f-mono)',fontSize:'13px'}}>No entries logged for this chapter yet.</p>
      )}
    </div></div>
  );
}

/* ---------- merch (minimal) ---------- */
function Merch({go}){
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:'home'})}>Home</a><span className="sep">/</span><span style={{color:'var(--ink)'}}>Merch</span></div>
      <div className="merch-wrap">
        <div className="badge">Official Store</div>
        <h2>Gear up, Crawler.</h2>
        <p>Official <b>Dungeon Crawler Carl</b> gear, straight from author <b>Matt Dinniman</b>'s own store. Every order supports the series this index is built on. Grab the books while you're there too.</p>
        <a className="btn amber" href="https://dungeoncrawlercarlmerch.com/" target="_blank" rel="noopener noreferrer">Visit the official store ▸</a>
      </div>
    </div></div>
  );
}

/* ---------- footer ---------- */
function Footer(){
  const D = window.DCC;
  const live = D.BOOKS.filter(b=>b.status==='live');
  const liveMax = live.length ? Math.max(...live.map(b=>b.n)) : 1;
  const partial = D.BOOKS.find(b=>b.status==='live' && b.mappedChapters<b.chapters && b.n===liveMax);
  const fullN = partial ? live.filter(b=>b.n!==partial.n).map(b=>b.n) : live.map(b=>b.n);
  const fullLabel = fullN.length>1 ? `Books ${fullN[0]}–${fullN[fullN.length-1]} complete` : `Book ${fullN[0]} complete`;
  const status = partial ? `${fullLabel}, Book ${partial.n} in progress` : fullLabel;
  return (
    <footer className="foot"><div className="wrap">
      <div className="foot-links">
        <a href="https://www.reddit.com/r/DungeonCrawlerCarl/" target="_blank" rel="noopener noreferrer">Reddit ↗</a>
        <a href="https://discord.com/invite/dungeoncrawlercarl" target="_blank" rel="noopener noreferrer">Discord ↗</a>
        <a href="https://hellocrawlers.com/" target="_blank" rel="noopener noreferrer">Podcast ↗</a>
        <a href="https://soundbooththeater.com/series/dungeon-crawler-carl/" target="_blank" rel="noopener noreferrer">Audiobooks ↗</a>
        <a href="https://mattdinniman.com/" target="_blank" rel="noopener noreferrer">The author ↗</a>
        <a className="foot-route" href="#/community">Community page →</a>
      </div>
      <div className="foot-in">
      <div className="brand"><span className="sigil">▲</span><span className="bt">System AI<small>UNOFFICIAL DCC INDEX</small></span></div>
      <p className="disc">
        <b>Unofficial, fan-made index.</b> Not affiliated with, endorsed by, or sponsored by the author or publisher.
        <em> Dungeon Crawler Carl</em> and all related names belong to their respective owners.<br/>
        {status} · {D.allEntries().length} entries · descriptions fan-written.
        Future-book references stay sealed until you decrypt them.
      </p>
    </div></div></footer>
  );
}

/* ---------- back to top ---------- */
function BackTop(){
  const [show, setShow] = useState(false);
  useEffect(()=>{
    function onScroll(){ setShow(window.scrollY > 720); }
    window.addEventListener('scroll', onScroll, {passive:true});
    onScroll();
    return ()=>window.removeEventListener('scroll', onScroll);
  },[]);
  return (
    <button className={'backtop'+(show?' on':'')} aria-label="Back to top"
      onClick={()=>window.scrollTo({top:0, behavior:'smooth'})}>▲<span>TOP</span></button>
  );
}

/* ---------- submit-art: universal "upload your own art" form ----------
   Posts to the /api/submit-art Pages Function, which stores the image in R2
   (a review queue) and rate-limits to one upload per IP per day, 5 MB max. */
function SubmitArt({go, preselect}){
  const all = D.allEntries().slice().sort((a,b)=>a.name.localeCompare(b.name));
  const [entryText, setEntryText] = useState(preselect && D.ENTRIES[preselect] ? D.ENTRIES[preselect].name : "");
  const [who, setWho] = useState("");
  const [note, setNote] = useState("");
  const [file, setFile] = useState(null);
  const [preview, setPreview] = useState("");
  const [st, setSt] = useState({k:"idle", msg:""});
  const MAX = 5*1024*1024;

  const OKTYPE = {"image/png":1, "image/jpeg":1, "image/jpg":1, "image/gif":1, "image/webp":1, "image/avif":1};
  function pickFile(f){
    if(!f) return;
    if(!OKTYPE[(f.type||"").toLowerCase()]){ setSt({k:"err", msg:"Please choose a PNG, JPEG, GIF, WebP or AVIF image (no SVG or other file types)."}); return; }
    if(f.size > MAX){ setSt({k:"err", msg:"That image is over 5 MB. Please resize and try again."}); return; }
    setFile(f); setSt({k:"idle", msg:""});
    const r = new FileReader(); r.onload = ()=>setPreview(r.result); r.readAsDataURL(f);
  }
  async function submit(e){
    e.preventDefault();
    if(!file){ setSt({k:"err", msg:"Pick an image first."}); return; }
    const match = all.find(x=>x.name.toLowerCase()===entryText.trim().toLowerCase());
    const entryVal = match ? match.id : entryText.trim();
    if(!entryVal){ setSt({k:"err", msg:"Tell us which entry this art is for."}); return; }
    setSt({k:"sending", msg:"Uploading…"});
    try{
      const fd = new FormData();
      fd.append("art", file);
      fd.append("entry", entryVal);
      fd.append("who", who);
      fd.append("note", note);
      const res = await fetch("/api/submit-art", {method:"POST", body:fd});
      let data = {}; try{ data = await res.json(); }catch(_){}
      if(res.ok){ setSt({k:"ok", msg: data.message || "Thanks! Your art is in the review queue."}); setFile(null); setPreview(""); }
      else if(res.status===429){ setSt({k:"err", msg: data.message || "One submission per day. Please try again tomorrow."}); }
      else { setSt({k:"err", msg: data.message || "Upload failed. Please try again later."}); }
    }catch(_){ setSt({k:"err", msg:"Network error. Please try again."}); }
  }

  const box = {background:"var(--panel,#15131c)", border:"1px solid var(--line,#2a2632)", borderRadius:"8px", color:"var(--ink,#e8e3ef)", padding:"10px 12px", font:"inherit", width:"100%", boxSizing:"border-box"};
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:"home"})}>Home</a><span className="sep">/</span><span style={{color:"var(--ink)"}}>Submit Art</span></div>
      <h1 style={{fontSize:"2rem", margin:"10px 0 6px"}}>Submit your concept art</h1>
      <p className="h-lede" style={{maxWidth:"640px"}}>Our System AI does its best, but it really can't draw. Got something better? Drop it here.
        We review every submission and host the good ones. One upload per person per day, images up to 5 MB.</p>
      <form onSubmit={submit} style={{maxWidth:"640px", display:"grid", gap:"16px", marginTop:"20px"}}>
        <label style={{display:"grid", gap:"6px"}}><b>Which entry is this art for?</b>
          <input list="dcc-entry-names" value={entryText} placeholder="Start typing a name…"
            onChange={e=>setEntryText(e.target.value)} style={box}/>
          <datalist id="dcc-entry-names">{all.map(x=>(<option key={x.id} value={x.name}/>))}</datalist>
        </label>
        <label style={{display:"grid", gap:"6px"}}><b>Your art <span style={{opacity:.6}}>(image, max 5 MB)</span></b>
          <input type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/avif" onChange={e=>pickFile(e.target.files && e.target.files[0])} style={box}/>
        </label>
        {preview && <img src={preview} alt="preview" style={{maxWidth:"260px", borderRadius:"8px", border:"1px solid var(--line,#2a2632)"}}/>}
        <label style={{display:"grid", gap:"6px"}}><b>Your name or handle <span style={{opacity:.6}}>(optional, for credit)</span></b>
          <input value={who} maxLength={80} onChange={e=>setWho(e.target.value)} style={box}/>
        </label>
        <label style={{display:"grid", gap:"6px"}}><b>Note to the curator <span style={{opacity:.6}}>(optional)</span></b>
          <textarea value={note} maxLength={500} rows={3} onChange={e=>setNote(e.target.value)} style={box}/>
        </label>
        <div style={{display:"flex", gap:"12px", alignItems:"center", flexWrap:"wrap"}}>
          <button className="btn" type="submit" disabled={st.k==="sending"}>{st.k==="sending" ? "Uploading…" : "Submit art ▸"}</button>
          {st.msg && <span style={{color: st.k==="ok" ? "var(--good,#7bd88f)" : (st.k==="err" ? "var(--bad,#ff8a8a)" : "var(--ink)")}}>{st.msg}</span>}
        </div>
      </form>
    </div></div>
  );
}

/* ---------- review queue (owner-only, gated by the REVIEW_KEY secret) ----------
   Unlinked page at #/review. Lists pending submissions from /api/review and lets
   the owner approve (publish to the entry) or reject (delete). */
function ReviewCard({it, busy, onAct}){
  const [entry, setEntry] = useState(it.entry||"");
  const known = D.ENTRIES[entry];
  const box = {background:"var(--panel,#15131c)", border:"1px solid var(--line,#2a2632)", borderRadius:"8px", color:"var(--ink,#e8e3ef)", padding:"8px 10px", font:"inherit", width:"100%", boxSizing:"border-box"};
  return (
    <div style={{display:"flex", gap:"16px", border:"1px solid var(--line,#2a2632)", borderRadius:"8px", padding:"12px", marginBottom:"12px", alignItems:"flex-start", flexWrap:"wrap"}}>
      <img src={it.img} alt="submission" style={{width:"150px", height:"150px", objectFit:"contain", background:"#0c0a12", borderRadius:"6px", flexShrink:0}}/>
      <div style={{flex:"1 1 280px", display:"grid", gap:"6px"}}>
        <label style={{display:"grid", gap:"4px"}}><b>Entry id to publish to</b>
          <input value={entry} onChange={e=>setEntry(e.target.value)} style={box}/>
        </label>
        <div style={{fontSize:".85em", opacity:.8}}>{known ? ("✓ "+known.name) : "⚠ not a known entry id"}</div>
        {it.who && <div style={{fontSize:".9em"}}><b>By:</b> {it.who}</div>}
        {it.note && <div style={{fontSize:".9em"}}><b>Note:</b> {it.note}</div>}
        <div style={{fontSize:".8em", opacity:.6}}>{it.day} · {Math.round((it.bytes||0)/1024)} KB · {it.type}</div>
        <div style={{display:"flex", gap:"10px", marginTop:"4px"}}>
          <button className="btn" disabled={busy||!entry} onClick={()=>onAct(it, "approve", entry)}>Approve &amp; publish ▸</button>
          <button className="btn ghost" disabled={busy} onClick={()=>onAct(it, "reject")}>Reject</button>
        </div>
      </div>
    </div>
  );
}
function ReviewArt({go}){
  const [key, setKey] = useState(()=>{ try{ return localStorage.getItem("dcc-rk")||""; }catch(_){ return ""; } });
  const [items, setItems] = useState(null);
  const [msg, setMsg] = useState("");
  const [busy, setBusy] = useState(false);

  async function load(k){
    const useKey = (k!==undefined && typeof k==="string") ? k : key;
    if(!useKey){ setMsg("Enter the review key."); return; }
    setBusy(true); setMsg("Loading…");
    try{
      const res = await fetch("/api/review?key="+encodeURIComponent(useKey));
      if(res.status===401){ setMsg("Wrong key."); setItems(null); setBusy(false); return; }
      const d = await res.json();
      setItems(d.pending||[]); setMsg("");
      try{ localStorage.setItem("dcc-rk", useKey); }catch(_){}
    }catch(_){ setMsg("Could not load the queue."); }
    setBusy(false);
  }
  React.useEffect(()=>{ if(key) load(key); }, []);

  async function act(it, action, entry){
    setBusy(true); setMsg("");
    try{
      const res = await fetch("/api/review?key="+encodeURIComponent(key), {
        method:"POST", headers:{"content-type":"application/json"},
        body: JSON.stringify({id: it.id, action, entry})
      });
      const d = await res.json().catch(()=>({}));
      if(res.ok){ setItems(prev=>(prev||[]).filter(x=>x.id!==it.id)); setMsg(action==="approve" ? ("Published to "+d.entry+".") : "Removed."); }
      else { setMsg(d.error||"Action failed."); }
    }catch(_){ setMsg("Action failed."); }
    setBusy(false);
  }

  const box = {background:"var(--panel,#15131c)", border:"1px solid var(--line,#2a2632)", borderRadius:"8px", color:"var(--ink,#e8e3ef)", padding:"10px 12px", font:"inherit", maxWidth:"360px"};
  return (
    <div className="view"><div className="wrap">
      <div className="crumbs"><a onClick={()=>go({view:"home"})}>Home</a><span className="sep">/</span><span style={{color:"var(--ink)"}}>Review queue</span></div>
      <h1 style={{fontSize:"2rem", margin:"10px 0 6px"}}>Review queue{items!==null && <span style={{opacity:.6, fontWeight:400}}> · {items.length} pending</span>}</h1>
      <div style={{display:"flex", gap:"10px", alignItems:"center", flexWrap:"wrap", margin:"10px 0 20px"}}>
        <input type="password" value={key} placeholder="Review key" onChange={e=>setKey(e.target.value)} style={box}/>
        <button className="btn" disabled={busy} onClick={()=>load()}>{items===null ? "Load" : "Refresh"}</button>
        {msg && <span style={{opacity:.85}}>{msg}</span>}
      </div>
      {items!==null && items.length===0 && <p className="h-lede">No pending submissions right now.</p>}
      {items!==null && items.map(it=>(<ReviewCard key={it.id} it={it} busy={busy} onAct={act}/>))}
    </div></div>
  );
}

/* ---------- arcade lazy loader ----------
   The arcade bundle (trivia/loot data + the five game scripts, ~550 KB) is
   only needed on the #/games and #/floor1 routes, so index.html leaves it
   out of the initial page load and it is fetched once, here, on the first
   visit. Plain .js data files load as normal scripts; .jsx files are
   fetched and compiled with the already-loaded Babel, wrapped in an IIFE
   to keep the same per-script scoping they had as text/babel tags. */
const ARCADE_DATA = [
  '/prototype/assets/games-data.js',
  '/prototype/assets/games-class-art.js',
  '/prototype/assets/games-floor1-art.js',
  '/prototype/assets/games-floor1-data.js',
];
const ARCADE_JSX = [
  '/prototype/assets/games.jsx',
  '/prototype/assets/games-crawl.jsx',
  '/prototype/assets/games-class.jsx',
  '/prototype/assets/games-crawldle.jsx',
  '/prototype/assets/games-floor1.jsx',
];
let arcadeLoad = null;
function loadArcade(){
  if(arcadeLoad) return arcadeLoad;
  const script = src => new Promise((ok, fail)=>{
    const s=document.createElement('script');
    s.src=src; s.onload=ok; s.onerror=()=>fail(new Error('failed: '+src));
    document.head.appendChild(s);
  });
  const jsx = src => fetch(src).then(r=>{
    if(!r.ok) throw new Error('failed: '+src);
    return r.text();
  }).then(code=>{
    const out = Babel.transform(code, {presets:['react'], filename:src}).code;
    const s=document.createElement('script');
    s.text = '(function(){\n'+out+'\n})();\n//# sourceURL='+src;
    document.head.appendChild(s);
  });
  arcadeLoad = (async()=>{
    for(const s of ARCADE_DATA) await script(s);
    for(const s of ARCADE_JSX) await jsx(s);
  })().catch(e=>{ arcadeLoad=null; throw e; });
  return arcadeLoad;
}
/* Renders its child once the arcade bundle is in; System-styled loading card. */
function ArcadeGate({render}){
  const [state,setState]=useState(window.GamesPage ? 'ready' : 'loading');
  useEffect(()=>{
    if(state!=='loading' || window.GamesPage) return;
    let on=true;
    loadArcade().then(()=>{ if(on) setState('ready'); }, ()=>{ if(on) setState('error'); });
    return ()=>{ on=false; };
  },[state]);
  if(state==='ready') return render();
  return (
    <div className="view"><div className="wrap" style={{padding:'90px 0', textAlign:'center', fontFamily:'var(--f-mono)', fontSize:14, color:'var(--ink-dim)'}}>
      {state==='error'
        ? <span>ARCADE FAILED TO LOAD — <a onClick={()=>setState('loading')} style={{cursor:'pointer', textDecoration:'underline'}}>RETRY</a></span>
        : <span>LOADING THE ARCADE…</span>}
    </div></div>
  );
}

/* ---------- per-route document titles ---------- */
const SITE_TITLE = 'System AI · Unofficial DCC Index';
const GAME_TITLES = {crawl:'The Crawl', class:'Class Selection', crawldle:'Crawldle'};
function routeTitle(r){
  const t = s => s ? s + ' · Unofficial DCC Index' : SITE_TITLE;
  switch(r.view){
    case 'index': return t('The Index');
    case 'type': { const m=D.TYPE_META[r.type]; return t(m ? m.name : r.type); }
    case 'books': return t('Books');
    case 'book': { const b=D.BOOKS.find(b=>b.n===r.book); return t(b ? `Book ${b.n} — ${b.title}` : 'Books'); }
    case 'chapter': return t(`Book ${r.book}, Chapter ${r.ch}`);
    case 'charmap': return t('Character Timeline');
    case 'entry': { const e=D.entry(r.id); return t(e ? e.name : 'Entry'); }
    case 'games': return t(GAME_TITLES[r.game] || 'The Arcade');
    case 'floor1': return t('Floor 1 — The Game');
    case 'merch': return t('Merch');
    case 'floors': return t('Floor Guide');
    case 'system': return t('System Guide');
    case 'glossary': return t('Glossary');
    case 'catchup': return t('Catch Up');
    case 'timeline': return t('Timeline');
    case 'community': return t('Community Art');
    case 'gallery': return t('Gallery');
    case 'submit': return t('Submit Art');
    case 'review': return t('Review Queue');
    default: return SITE_TITLE;
  }
}

/* ---------- app ---------- */
function App(){
  const [route, setRoute] = useState(decode());
  useEffect(()=>{
    function onHash(){ setRoute(decode()); window.scrollTo(0,0); }
    window.addEventListener('hashchange', onHash);
    return ()=>window.removeEventListener('hashchange', onHash);
  },[]);
  function go(r){ const h = encode(r); if(location.hash===h){ setRoute(r); window.scrollTo(0,0);} else location.hash=h; }
  useEffect(()=>{ document.title = routeTitle(route); },[route]);


  let body;
  switch(route.view){
    case 'index': body = <IndexHub go={go}/>; break;
    case 'type': body = <TypeBrowse type={route.type} go={go}/>; break;
    case 'books': body = <Books go={go}/>; break;
    case 'charmap': body = <window.CharFlow go={go}/>; break;
    case 'book': body = <Book book={route.book} go={go}/>; break;
    case 'chapter': body = <Chapter book={route.book} ch={route.ch} go={go}/>; break;
    case 'games': body = <ArcadeGate render={()=> <window.GamesPage game={route.game} go={go}/>}/>; break;
    case 'floor1': body = <ArcadeGate render={()=> <window.Floor1Page go={go}/>}/>; break;
    case 'entry': body = <window.EntryDetail key={route.id} entry={D.entry(route.id)} go={go}/>; break;
    case 'merch': body = <Merch go={go}/>; break;
    case 'floors': body = <window.FloorsPage go={go}/>; break;
    case 'system': body = <window.SystemGuidePage go={go}/>; break;
    case 'glossary': body = <window.GlossaryPage go={go}/>; break;
    case 'catchup': body = <window.CatchUpPage go={go}/>; break;
    case 'timeline': body = <window.TimelinePage go={go}/>; break;
    case 'community': body = <window.CommunityPage go={go}/>; break;
    case 'gallery': body = <window.GalleryPage go={go}/>; break;
    case 'submit': body = <SubmitArt go={go} preselect={route.id}/>; break;
    case 'review': body = <ReviewArt go={go}/>; break;
    default: body = <Landing go={go}/>;
  }
  return (
    <React.Fragment>
      <Ticker/>
      <div className="hazard"></div>
      <Nav route={route} go={go}/>
      <main>{body}</main>
      <Footer/>
      <BackTop/>
    </React.Fragment>
  );
}

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