<h1>Review</h1>
<div class="pagebar">
  <span id="sectionlinks"></span>
  <span class="keyhelp">
    <kbd>j</kbd>/<kbd>k</kbd> move · <kbd>1</kbd>–<kbd>9</kbd> pick ·
    <kbd>r</kbd> reject · <kbd>l</kbd> keep local · <kbd>m</kbd> manual id · <kbd>u</kbd> unknown ·
    <kbd>v</kbd> veto merge
  </span>
</div>
<div id="cards"></div>
<script>
"use strict";
let STATE = null;
let ACTIVE = 0;

async function refresh() {
  STATE = await fetchJSON("/api/state");
  render();
}

async function post(url, body) {
  const res = await apiPost(url, body);
  if (!res) return;
  try {
    STATE = await res.json();
    render();
  } catch (err) {
    // the decision saved server-side; only the re-render failed
    errorBanner(`saved, but the page failed to refresh (${err.message || err}) — reload the page`);
  }
}

function cueChips(cues) {
  const parts = [];
  if (cues.publisher) parts.push(`<span class="cue"><b>publisher</b> ${esc(cues.publisher)}</span>`);
  if (cues.edition) parts.push(`<span class="cue"><b>edition</b> ${esc(cues.edition)}</span>`);
  if (cues.year) parts.push(`<span class="cue"><b>year</b> ${esc(cues.year)}</span>`);
  if (cues.language) parts.push(`<span class="cue"><b>language</b> ${esc(cues.language)}</span>`);
  if (cues.art_notes) parts.push(`<span class="cue"><b>art</b> ${esc(cues.art_notes)}</span>`);
  return parts.length ? `<div class="cues">${parts.join("")}</div>` : "";
}

function shots(photos) {
  if (!photos.length) return `<div class="shots"><div class="noshot">photo not on disk</div></div>`;
  return `<div class="shots">` + photos.map(p =>
    `<a href="/photos/${encodeURIComponent(p)}" target="_blank" tabindex="-1">
       <img src="/photos/${encodeURIComponent(p)}" alt="source photo ${esc(p)}"></a>`
  ).join("") + `</div>`;
}

function thumbHtml(c) {
  if (c.thumbnail) return `<img class="thumb" src="${esc(c.thumbnail)}" alt="">`;
  const initial = (c.name || "?").trim().charAt(0).toUpperCase();
  return `<div class="thumb ph" aria-hidden="true">${esc(initial)}</div>`;
}

function matchCard(row, idx) {
  const cands = row.candidates.map((c, i) => `
    <li data-pick="${c.bgg_id}" title="press ${i + 1}" tabindex="0" role="button"
        aria-label="pick ${esc(c.name)} (${esc(c.year ?? "year unknown")})">
      <kbd>${i + 1}</kbd> ${thumbHtml(c)}
      <span><span class="cname">${esc(c.name)}</span>
        <span class="cmeta">${esc(c.year ?? "—")} · ${esc(c.type || "?")}
          · rank ${esc(c.rank ?? "—")} · owned ${esc(c.owned ?? "—")}
          · <a href="${bggUrl(c.bgg_id, c.type)}" target="_blank" rel="noopener"
               title="check this candidate before picking">view ↗</a></span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="match" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    ${shots(row.photos)}
    <div class="body">
      <p class="title">${esc(row.title_raw)}</p>
      <p class="status">${esc(row.match_status)} · ${idx + 1} of ${STATE.pending.length + STATE.versions.length} to review</p>
      ${cueChips(row.cues)}
      <ol class="cands">${cands || "<li class='cmeta'>no candidates — enter a BGG id or reject</li>"}</ol>
      <div class="rowactions">
        <span><kbd>m</kbd> <input type="text" inputmode="numeric" placeholder="BGG id, then ⏎"
              aria-label="manual BGG id"></span>
        <span class="research">
          <input type="text" class="rq" value="${esc(row.title_raw)}"
                 aria-label="search text for ${esc(row.title_raw)}">
          <button class="dosearch" data-types="">search BGG</button>
          <button class="dosearch" data-types="rpgitem"
                  title="BGG's board-game entries hide same-named RPGs from the automatic search">
            search RPGGeek</button>
        </span>
        <button class="reject" title="press r"><kbd>r</kbd> reject — not a game / bad read</button>
        <button class="golocal" title="press l"><kbd>l</kbd> not on BGG — keep locally</button>
      </div>
    </div>
  </section>`;
}

function versionCard(row, idx) {
  const cands = row.candidates.map((v, i) => `
    <li data-pickver="${v.version_id}" title="press ${i + 1}" tabindex="0" role="button"
        aria-label="pick ${esc(v.name)} (${esc(v.year ?? "year unknown")})">
      <kbd>${i + 1}</kbd>
      <span><span class="cname">${esc(v.name)}</span>
        <span class="cmeta">${esc(v.year ?? "—")} · ${esc((v.publishers || []).join(", "))}
          · ${esc((v.languages || []).join(", "))} · score ${esc(v.score ?? "—")}</span></span>
    </li>`).join("");
  return `
  <section class="card actionable" data-kind="version" data-rowix="${row.row_ix}"
           data-title="${esc(row.title_raw)}" data-photos="${esc(row.source_photos)}">
    ${shots(row.photos || [])}
    <div class="body">
      <p class="title">${esc(row.bgg_name || row.title_raw)}
        ${row.bgg_id ? `<a class="cmeta" href="${bggUrl(row.bgg_id, row.type)}/versions"
            target="_blank" rel="noopener"
            title="BGG's own list of every printing">versions on BGG ↗</a>` : ""}</p>
      <p class="status">which edition${row.photos && row.photos.length
        ? ` is the copy in ${esc(row.photos.join(", "))}`
        : ""}? (skippable — <kbd>u</kbd> records "unknown", or just move on)</p>
      <ol class="cands">${cands}</ol>
      <div class="rowactions">
        <button class="unknown" title="press u"><kbd>u</kbd> can't tell — leave version unset</button>
        <button class="allversions"
                title="the cues shortlisted these — fetch every published printing instead">
          list every printing</button>
        <button class="wronggame"
                title="none of these printings is your box: BGG may file it as a SEPARATE game — re-match it">
          wrong game — re-match</button>
      </div>
    </div>
  </section>`;
}

function render() {
  const m = document.getElementById("cards");
  const s = STATE;
  showBanner((s.warnings || []).length
    ? `<div class="banner warn">${s.warnings.map(esc).join("<br>")}</div>` : "");

  const link = (n, label, anchor) => n ? `<a href="#${anchor}"><b>${n}</b> ${label}</a>` : "";
  document.getElementById("sectionlinks").innerHTML = [
    link(s.pending.length, "matches", "matches"),
    link(s.versions.length, "editions", "editions"),
    link(s.merges.length, "merged", "merges"),
    s.summary.unresolved ? `<span><b>${s.summary.unresolved}</b> awaiting resolve</span>` : "",
    `<span>${s.decisions} decided this sitting</span>`,
  ].filter(Boolean).join(" ");

  let html = "";
  if (!s.pending.length && !s.versions.length) {
    const waiting = s.summary.unresolved;
    html += `
    <div class="done">
      <h2>${waiting
        ? "Resolved set fully reviewed"
        : "All reviewed — every title is diff-ready"}</h2>
      <div class="nums">
        <div>${s.summary.extracted}<span>extracted</span></div>
        <div>${s.summary.recognized}<span>recognized</span></div>
        <div>${s.summary.version_updates}<span>with versions</span></div>
        <div>${s.summary.rejected}<span>rejected</span></div>
      </div>
      ${waiting
        ? s.summary.token_present
          ? `<p class="waiting">${waiting} title(s) are extracted but not yet matched
               to BGG.</p>
             <p class="next">Run <b>resolve</b> from the <a href="/">pipeline</a> to match them.</p>`
          : `<p class="waiting">${waiting} title(s) are extracted but not yet matched
               to BGG — they're waiting on your API token
               (<a href="https://boardgamegeek.com/applications" target="_blank"
                   rel="noopener">boardgamegeek.com/applications</a>).</p>
             <p class="next">When it arrives, run <b>resolve</b> from the <a href="/">pipeline</a>.</p>`
        : `<p class="next">Next: run <b>diff</b> from the <a href="/">pipeline</a>, then check the <a href="/queue">queue</a>.</p>`}
    </div>`;
  }
  // csv order is extraction order — alphabetize so a long session has a
  // findable, predictable shape (stable within a payload, so the keyboard
  // cursor stays put between polls)
  const byTitle = (a, b) =>
    a.title_raw.localeCompare(b.title_raw, undefined, { sensitivity: "base" });
  if (s.pending.length) {
    html += `<h2 id="matches">Matches <span class="count">— pick the game each photo shows</span></h2>`;
    html += [...s.pending].sort(byTitle).map(matchCard).join("");
  }
  if (s.versions.length) {
    html += `<h2 id="editions">Editions <span class="count">— optional pass, never blocks uploads</span></h2>`;
    html += [...s.versions].sort(byTitle).map(versionCard).join("");
  }
  if (s.merges.length) {
    html += `<h2 id="merges">Merges <span class="count">— duplicate reads folded into one game; veto if wrong</span></h2>`;
    html += [...s.merges].sort(byTitle).map(mg => `
      <section class="card merge actionable" data-kind="merge"
               data-rowix="${mg.row_ix}"
               data-title="${esc(mg.title_raw)}" data-photos="${esc(mg.source_photos)}">
        <div class="body">
          <span class="cname">${esc(mg.title_raw)}</span>
          <span class="arrow">merged into</span>
          <span class="cname">${esc(mg.merged_into)}</span>
          <span class="cmeta">${esc(mg.bgg_name)} · ${esc(mg.bgg_id)}</span>
          <button class="veto" title="press v"><kbd>v</kbd> veto — these are different games</button>
        </div>
      </section>`).join("");
  }
  m.innerHTML = html;

  const cards = actionables();
  if (ACTIVE >= cards.length) ACTIVE = Math.max(0, cards.length - 1);
  highlight();

  // an outbound "view ↗" inside a pick row must not also cast the vote
  m.querySelectorAll("[data-pick] a, [data-pickver] a").forEach(a =>
    a.onclick = e => e.stopPropagation());
  m.querySelectorAll("[data-pick]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    decide(card, "pick", Number(li.dataset.pick));
  });
  m.querySelectorAll("[data-pickver]").forEach(li => li.onclick = () => {
    const card = li.closest(".card");
    version(card, "pick", Number(li.dataset.pickver));
  });
  m.querySelectorAll(".reject").forEach(b => b.onclick = () =>
    decide(b.closest(".card"), "reject"));
  m.querySelectorAll(".golocal").forEach(b => b.onclick = () =>
    decide(b.closest(".card"), "local"));
  m.querySelectorAll(".dosearch").forEach(b => b.onclick = () => {
    const card = b.closest(".card");
    post("/api/research", {
      title_raw: card.dataset.title, source_photos: card.dataset.photos,
      row_ix: rowIx(card), query: card.querySelector(".rq").value,
      types: b.dataset.types || null,
    });
  });
  m.querySelectorAll(".wronggame").forEach(b => b.onclick = () =>
    post("/api/reopen-match", {
      title_raw: b.closest(".card").dataset.title,
      source_photos: b.closest(".card").dataset.photos,
      row_ix: rowIx(b.closest(".card")),
    }));
  m.querySelectorAll(".allversions").forEach(b => b.onclick = () =>
    post("/api/open-versions", {
      title_raw: b.closest(".card").dataset.title,
      source_photos: b.closest(".card").dataset.photos,
      row_ix: rowIx(b.closest(".card")),
    }));
  m.querySelectorAll(".unknown").forEach(b => b.onclick = () =>
    version(b.closest(".card"), "unknown"));
  m.querySelectorAll(".veto").forEach(b => b.onclick = () =>
    vetoMerge(b.closest(".card")));
  m.querySelectorAll(".rowactions input").forEach(inp => {
    inp.onkeydown = e => {
      if (e.key === "Enter" && inp.value.trim().match(/^\d+$/)) {
        decide(inp.closest(".card"), "manual", Number(inp.value.trim()));
      }
      if (e.key === "Escape") inp.blur();
      e.stopPropagation();
    };
  });
}

const actionables = () => [...document.querySelectorAll(".actionable")];

function highlight(scroll = false) {
  actionables().forEach((el, i) => el.classList.toggle("active", i === ACTIVE));
  // scroll only for KEYBOARD moves: a mouse user's cursor idles on card
  // one, and scrolling to it on every re-render yanks them to the top
  const el = actionables()[ACTIVE];
  if (el && scroll) el.scrollIntoView({block: "nearest", behavior: "auto"});
}

const rowIx = card => Number(card.dataset.rowix);  // every card template sets it
const decide = (card, action, bgg_id = null) => post("/api/decision", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, bgg_id,
});
const version = (card, action, version_id = null) => post("/api/version", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card), action, version_id,
});
const vetoMerge = card => post("/api/veto-merge", {
  title_raw: card.dataset.title, source_photos: card.dataset.photos,
  row_ix: rowIx(card),
});

document.addEventListener("keydown", e => {
  if ((e.key === "Enter" || e.key === " ") && e.target.matches("[data-pick],[data-pickver]")) {
    e.preventDefault();
    e.target.click();
    return;
  }
  if (e.target.tagName === "INPUT") return;
  const cards = actionables();
  if (!cards.length) return;
  const card = cards[ACTIVE];
  const kind = card?.dataset.kind;
  if (e.key === "j" || e.key === "ArrowDown") { ACTIVE = Math.min(ACTIVE + 1, cards.length - 1); highlight(true); }
  else if (e.key === "k" || e.key === "ArrowUp") { ACTIVE = Math.max(ACTIVE - 1, 0); highlight(true); }
  else if (/^[1-9]$/.test(e.key) && kind === "match") {
    const li = card.querySelectorAll("[data-pick]")[Number(e.key) - 1];
    if (li) decide(card, "pick", Number(li.dataset.pick));
  }
  else if (/^[1-9]$/.test(e.key) && kind === "version") {
    const li = card.querySelectorAll("[data-pickver]")[Number(e.key) - 1];
    if (li) version(card, "pick", Number(li.dataset.pickver));
  }
  else if (e.key === "r" && kind === "match") decide(card, "reject");
  else if (e.key === "l" && kind === "match") decide(card, "local");
  else if (e.key === "u" && kind === "version") version(card, "unknown");
  else if (e.key === "v" && kind === "merge") vetoMerge(card);
  else if (e.key === "m" && kind === "match") { card.querySelector("input")?.focus(); e.preventDefault(); }
});

refresh().catch(err => errorBanner(err.message || err));

// Live-follow the data files; only re-render on an actual change (keeps
// the keyboard cursor stable) and never mid-typing. Stale poll responses
// (answered before a decision landed) are discarded by revision.
pollLoop(async () => {
  const fresh = await fetchJSON("/api/state");
  // discard stale responses — but only within one server lifetime: a
  // restart resets revision to 0 (boot changes), and refusing forever
  // would freeze the page
  if (STATE && fresh.boot === STATE.boot && fresh.revision < STATE.revision) return;
  if (JSON.stringify(fresh) !== JSON.stringify(STATE)) {
    STATE = fresh;
    render();
  }
}, 3000, () => render());
</script>
