/* global React */

// t20 / v06p — Video Review.
//
// Two states:
//   1. LIST   — a grid of edit-version cards (sequences-style) showing
//               every uploaded review. Each card has a video-frame
//               thumbnail (first-frame poster from <video preload>),
//               the version label, upload date, and a comment count.
//   2. DETAIL — large player taking the full cream panel + a
//               Frame.io-style comments column on the right. Time-
//               coded notes appear as gold markers on the timeline.
//               Clicking a marker (or a comment row) seeks. Clicking
//               anywhere on the timeline opens a draft note at that
//               TC.
//
// Storage: /api/video-reviews returns the list, /api/video-reviews/:id
// returns one review with its comments. Comments are persisted via
// /api/video-comments.

function fmtTC(seconds) {
  if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  return `${m}:${String(s).padStart(2, "0")}`;
}
function fmtDateShort(iso) {
  if (!iso) return "";
  try {
    const d = new Date(iso);
    if (Number.isNaN(d.getTime())) return "";
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
  } catch (_) { return ""; }
}
function fmtRelative(iso) {
  if (!iso) return "";
  try {
    const d = new Date(iso);
    if (Number.isNaN(d.getTime())) return "";
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 60)    return "just now";
    if (diff < 3600)  return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
  } catch (_) { return ""; }
}
function hashHue(s) {
  let h = 0;
  for (const c of String(s || "")) h = (h * 31 + c.charCodeAt(0)) | 0;
  return Math.abs(h) % 360;
}
function reviewGradient(seed) {
  const h = hashHue(seed);
  return `linear-gradient(155deg, oklch(0.40 0.05 ${h}), oklch(0.60 0.07 ${h + 30}) 60%, oklch(0.78 0.05 ${h + 60}))`;
}

// v07zz201 — Saved presentation decks (the `presentations` table, written by the
// Presentations page's Save button → R2 + DB) surfaced here as a Review tab.
// v07zz247 — reviewable=true (has a frozen deck_json) opens the in-app deck
// review viewer (click images → comments). Legacy decks (PDF only) open the PDF.
function ReviewPresentations({ fetcher, onDeckOpenChange }) {
  // v07zz267 — cache the list module-level (like the Edits tab's __reviewListCache)
  // so re-opening the tab paints the cards INSTANTLY from cache while a background
  // refetch runs — no 2-second blank every time. Also: never show a "Loading…" or
  // "no presentations" message before the real card pops in.
  const [decks, setDecks] = React.useState(() => Array.isArray(window.__presListCache) ? window.__presListCache : []);
  const [loaded, setLoaded] = React.useState(() => Array.isArray(window.__presListCache));
  // v07zz412 — Do NOT resume the last-open deck from localStorage. Hugo: going to Review must
  // always land on the main list (a deck/edit was sticking from the previous visit, so a shared
  // presentation link dumped people into whatever was open last). A shared presentation link
  // still opens its deck via window.__pendingDeckId (the deep-link effect below).
  const [openId, setOpenId] = React.useState(null);
  // v07zz261 — filter decks by type (the deck's phase, e.g. "Asset Design"). Default
  // null = show ALL, so seeing everything is never an extra step. The chip bar only
  // renders when there's MORE THAN ONE type, so it stays out of the way until useful.
  const [typeFilter, setTypeFilter] = React.useState(null);
  const load = React.useCallback(() => {
    fetcher("/api/presentations").then(r => r.ok ? r.json() : null)
      .then(d => { const list = (d && d.presentations) || []; window.__presListCache = list; setDecks(list); setLoaded(true); })
      .catch(() => setLoaded(true));
  }, [fetcher]);
  React.useEffect(() => { load(); }, [load]);
  // v07zz286 — tell the parent whenever a deck opens/closes so it can hide the
  // Edits/Presentations tab bar — entering a deck must behave EXACTLY like
  // entering an Edit (the page header collapses to the deck's own back-button
  // header, no chunky tab bar sitting above it). Hugo flagged this repeatedly.
  React.useEffect(() => {
    if (onDeckOpenChange) onDeckOpenChange(openId != null);
    return () => { if (onDeckOpenChange) onDeckOpenChange(false); };
  }, [openId, onDeckOpenChange]);
  // v07zz255 — deep link (/?deck=<id>): open that deck straight away.
  React.useEffect(() => {
    if (window.__pendingDeckId) { const id = parseInt(window.__pendingDeckId, 10); delete window.__pendingDeckId; if (id) setOpenId(id); }
  }, []);
  // v07zz254 — reload when a deck (or deck comment) syncs in from a peer, so a deck
  // Hugo locked locally shows up live on Railway without a manual refresh.
  React.useEffect(() => {
    const h = (e) => { if (e.detail && e.detail.type === "presentations_changed") load(); };
    window.addEventListener("paradise-sse", h);
    return () => window.removeEventListener("paradise-sse", h);
  }, [load]);

  // Opened deck → render the Edits-style inline review detail (replaces the list).
  if (openId != null && window.PresentationDeckViewer) {
    return <window.PresentationDeckViewer presentationId={openId} onClose={() => setOpenId(null)} />;
  }
  // Cold load with nothing cached → render an empty placeholder (no "Loading…" text,
  // no "no presentations" flash). The real empty-state shows only once a fetch with
  // zero results has completed.
  if (!loaded && !decks.length) return <div className="review-grid" aria-hidden="true" />;
  if (loaded && !decks.length) return <div className="review-empty">No presentations have been shared for review yet.</div>;

  // Same card as the Edits tab: a fast first-slide thumbnail (the server hands us
  // the cover image url), "REVIEW DECK" eyebrow, the deck's own title, and the date
  // at the bottom — pixel-identical to .review-card.
  const deckIcon = <svg viewBox="0 0 24 24" width="42" height="42" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round" style={{ opacity: 0.5 }}><path d="M6 2.5h8L19 7v13.5a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1Z"/><path d="M14 2.5V7h5"/><path d="M8.5 13h7M8.5 16.5h5"/></svg>;
  // Distinct deck types (phase). Chips show only when there's more than one.
  const types = Array.from(new Set(decks.map(d => (d.title || "").trim()).filter(Boolean)));
  const shown = typeFilter ? decks.filter(d => (d.title || "").trim() === typeFilter) : decks;
  return (
    <div className="review-pres-wrap">
      {types.length > 1 && (
        <div className="review-typebar">
          <button type="button" className={"review-typechip" + (typeFilter === null ? " is-on" : "")} onClick={() => setTypeFilter(null)}>All</button>
          {types.map(t => (
            <button key={t} type="button" className={"review-typechip" + (typeFilter === t ? " is-on" : "")} onClick={() => setTypeFilter(t)}>{t}</button>
          ))}
        </div>
      )}
      <div className="review-grid">
      {shown.map(p => {
        const reviewable = !!p.has_deck && !!window.PresentationDeckViewer;
        const openPdf = () => { if (p.open_url) window.open(p.open_url, "_blank", "noopener"); };
        const onActivate = reviewable ? () => setOpenId(p.id) : openPdf;
        return (
          <button key={p.id} type="button" className="review-card"
            onClick={onActivate}
            title={reviewable ? "Open for review & comments" : "Open the PDF"}>
            <div className="review-card-thumb">
              {p.thumb
                ? <img className="review-card-video is-ready" src={window.thumbUrl ? window.thumbUrl(p.thumb, 400) : p.thumb} alt="" loading="lazy" />
                : <span style={{ display: "grid", placeItems: "center", width: "100%", height: "100%", color: "var(--ink-muted)" }}>{deckIcon}</span>}
              {p.version_label ? <span className="review-card-version">{p.version_label}</span> : null}
            </div>
            <div className="review-card-body">
              <div className="review-card-eyebrow">REVIEW DECK</div>
              <div className="review-card-title">{(p.filename_base||"").replace(/\s*-\s*Review Deck\s*-\s*/i," - ").trim() || p.title || "Untitled deck"}</div>
              <div className="review-card-meta">
                {p.created_at && <span className="review-card-date">{fmtDateShort(p.created_at)}</span>}
                {p.slide_count ? <span className="review-card-by">{p.slide_count} slides</span> : null}
              </div>
            </div>
          </button>
        );
      })}
      </div>
    </div>
  );
}

// v07zz557 — the "Updates" activity digest (v07zz549) is no longer rendered here. Hugo:
// "I asked those updates to be a DIFFERENT PAGE" — it now lives in src/UpdatesPage.jsx
// as its own nav page (id "updates", perm nav_updates). This page is back to Edits-first.

function ReviewPage() {
  const fetcher = window.authFetch || fetch;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const canResolve = role === "admin" || role === "producer";
  // v07zz171 — "Set cover" is an admin-only action (Hugo: only he should pick
  // the edit's cover frame). Fall back to the app-wide effectiveRole too, since
  // the UserContext role can lag behind a "see as" override.
  const isAdmin = role === "admin" || window.__effectiveRole === "admin";

  // v07ze — Hugo: cards reloaded each time the user navigated away
  // and back. Hold the last reviews payload in a module-level cache
  // so re-mounts read it immediately; a background refetch still
  // runs to pick up changes from peers / new uploads. The detail
  // payload also gets cached so re-opening a review you've already
  // visited paints instantly.
  const [reviews, setReviews] = React.useState(() => {
    if (window.__reviewListCache && Array.isArray(window.__reviewListCache.reviews)) {
      return window.__reviewListCache.reviews;
    }
    return [];
  });
  const [activeId, setActiveId] = React.useState(() => {
    // v06e — Media Uploads sets window.__pendingReviewId before navigating here so we open
    // straight into that review. v07zz412 — we NO LONGER resume the last-open edit from
    // localStorage: Hugo wants the Review page to always land on the main list (a shared
    // presentation link was dumping people into whatever edit they had open last).
    const pending = (typeof window !== "undefined" && window.__pendingReviewId) || null;
    if (pending) { try { delete window.__pendingReviewId; } catch (e) {} return pending; }
    return null;
  });
  const [active, setActive] = React.useState(null);
  // v07zz412 — Edits + Presentations now render TOGETHER as two stacked panels (no mode
  // tab, no sticky last-tab, no resumed edit) — see the render below. A shared presentation
  // link still opens its deck via window.__pendingDeckId (handled inside ReviewPresentations).
  // v07zz286 — true while a presentation deck is open inside ReviewPresentations.
  // Used to hide the tab bar so a deck takes over the page like an Edit does.
  const [presDeckOpen, setPresDeckOpen] = React.useState(false);
  const [loading, setLoading] = React.useState(() => {
    // Skip the "Loading…" flash on re-mount if we already have a
    // cached list — the user effectively sees a stale-while-revalidate
    // experience as the background refetch swaps in newer data.
    return !(window.__reviewListCache && Array.isArray(window.__reviewListCache.reviews));
  });
  const [error, setError] = React.useState(null);
  // v07zz477 — how many fresh exports are still encoding their playback proxy
  // (their cards are hidden server-side until ready).
  const [encodingCount, setEncodingCount] = React.useState(0);
  // v07zz583 — admin-curated visibility. Default = ONLY needs_review rows show
  // ("these need reviews, so they are showing, all the rest is hidden" — Hugo);
  // the Show-all switch reveals the hidden ones greyed. Not persisted: each
  // visit starts on the curated view.
  const [showHidden, setShowHidden] = React.useState(false);
  // Optimistic flag PATCH (needs_review / category), then re-sync with the server list.
  const setReviewFlags = React.useCallback((id, patch) => {
    setReviews(prev => prev.map(r => String(r.id) === String(id) ? { ...r, ...patch } : r));
    fetcher(`/api/video-reviews/${id}`, {
      method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch),
    }).then(() => loadReviews()).catch(() => loadReviews());
  }, [fetcher]);   // eslint-disable-line react-hooks/exhaustive-deps — loadReviews declared below

  const loadReviews = React.useCallback(() => {
    const hasCache = !!(window.__reviewListCache && Array.isArray(window.__reviewListCache.reviews));
    if (!hasCache) setLoading(true);
    setError(null);
    // v06p — Use the new /api/review/edits endpoint that ALSO
    // surfaces files dropped into <WATCH_PATH>/work/incoming/
    // fromEditor (auto-registering unknown ones so they get a
    // stable id for comments). Falls back to /api/video-reviews
    // if the new endpoint isn't available.
    fetcher("/api/review/edits")
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (d && Array.isArray(d.reviews)) {
          setReviews(d.reviews);
          // v07zz477 — server hides fresh exports until their playback proxy is
          // built; `encoding` says how many are baking so we can hint at it.
          setEncodingCount(Number(d.encoding) || 0);
          // v07zz369 — public origin for shareable edit links (Railway on local, own origin on Railway).
          window.__reviewShareBase = (d && d.share_base) || null;
          window.__reviewListCache = { reviews: d.reviews, t: Date.now() };
          setLoading(false);
          return null;
        }
        // Fallback to legacy endpoint.
        return fetcher("/api/video-reviews").then(r => r.ok ? r.json() : null).then(d2 => {
          const list = (d2 && d2.reviews) || [];
          setReviews(list);
          window.__reviewListCache = { reviews: list, t: Date.now() };
          setLoading(false);
        });
      })
      .catch(err => { setError(err.message); setLoading(false); });
  }, [fetcher]);
  React.useEffect(() => { loadReviews(); }, [loadReviews]);

  // v07zz477 — reload the LIST when a review changes server-side. The main use:
  // the server broadcasts review.proxy_ready when a fresh export finishes
  // encoding (its card is hidden until then), so the now-playable card pops in
  // without a manual refresh. Debounced — review_changed also fires on comment
  // syncs and /api/review/edits is the heavy scan endpoint.
  React.useEffect(() => {
    let t = null;
    const fn = (e) => {
      if (!e.detail || e.detail.type !== "review_changed") return;
      if (t) clearTimeout(t);
      t = setTimeout(() => { t = null; loadReviews(); }, 1200);
    };
    window.addEventListener("paradise-sse", fn);
    return () => { window.removeEventListener("paradise-sse", fn); if (t) clearTimeout(t); };
  }, [loadReviews]);

  // v07zz538 — Re-scan for new edits when the app regains focus. A dropped edit file
  // (Hugo exporting from Premiere into exports\wip\) fires NO event — the /api/review/edits
  // scan only discovers it on the NEXT call. So if the Review page was already open, the new
  // edit never appeared until a manual refresh ("where is my latest edit?"). Now, coming back
  // to the app after dropping the file re-fetches the list, which runs the scan + registers it.
  // Guarded to the visible→ transition + a short cooldown so it can't hammer the heavy scan.
  React.useEffect(() => {
    let last = 0;
    const refetch = () => {
      if (document.visibilityState !== "visible") return;
      const now = (window.performance && performance.now) ? performance.now() : 0;
      if (now && now - last < 4000) return;   // cooldown — focus + visibilitychange can double-fire
      last = now;
      loadReviews();
    };
    document.addEventListener("visibilitychange", refetch);
    window.addEventListener("focus", refetch);
    return () => { document.removeEventListener("visibilitychange", refetch); window.removeEventListener("focus", refetch); };
  }, [loadReviews]);

  // Load the active review whenever activeId changes.
  React.useEffect(() => {
    if (!activeId) { setActive(null); return; }
    fetcher(`/api/video-reviews/${activeId}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        // v07zz274 — a resumed activeId whose edit was deleted (non-ok → null) clears
        // back to the list instead of showing a blank edit view. Network errors
        // (the .catch) keep activeId so a transient blip doesn't kick you out.
        if (!d) { setActiveId(null); setActive(null); return; }
        setActive(d);
      })
      .catch(() => setActive(null));
  }, [activeId, fetcher]);

  // v07u — Listen for sync.applied → review_changed / note_added so
  // synced-in comments from a peer appear without a manual refresh.
  // App.jsx dispatches these on every sync round that touched
  // video_reviews / video_comments. Also fires from the manual
  // refresh button.
  React.useEffect(() => {
    if (!activeId) return;
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (!msg) return;
        // v07zz336 — only refetch the open edit on events that actually touch a review/comment:
        // review_changed (video_reviews/video_comments sync) + new_note (a posted comment). The
        // generic `note_added` fires on EVERY sync tick touching ANY note/notification table
        // (~10s when anyone's active) — refetching + setActive on that cadence re-rendered the
        // <video> and flashed its poster (the reported 10s flicker). And when we DO refetch, pin
        // the media fields (play_url/file_path/poster) from the previous active object so a
        // comment update can never reload or re-seek the video — only the comments change.
        if (msg.type === "review_changed" || msg.type === "new_note") {
          fetcher(`/api/video-reviews/${activeId}`)
            .then(r => r.ok ? r.json() : null)
            .then(d => {
              if (!d) return;
              setActive(prev => (prev && String(prev.id) === String(d.id))
                ? { ...d, play_url: prev.play_url, file_path: prev.file_path, poster: prev.poster }
                : d);
            })
            .catch(() => {});
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [activeId, fetcher]);

  const refreshActive = React.useCallback(() => {
    if (!activeId) return;
    fetcher(`/api/video-reviews/${activeId}`)
      .then(r => r.json())
      .then(d => {
        setActive(d);
        if (d && Array.isArray(d.comments)) {
          const unresolved = d.comments.filter(c => !c.resolved).length;
          window.__reviewPendingNotes = unresolved;
        }
      });
  }, [activeId, fetcher]);

  return (
    <section className="view-page review-view review-view--v2">
      {/* v07zz412 — Edits + Presentations shown TOGETHER as two stacked panels (no tabs,
          no sticky last-tab). Opening an edit (activeId) or a deck (presDeckOpen) takes over
          the whole page; ReviewPresentations stays mounted so an open deck survives. */}
      {activeId ? (
        active ? (
          <ReviewDetail
            active={active}
            onBack={() => { setActiveId(null); setActive(null); }}
            canResolve={canResolve}
            isAdmin={isAdmin}
            fetcher={fetcher}
            onRefresh={refreshActive}
            onReloadList={loadReviews}
            setError={setError}
            reviews={reviews}
            onSwitchReview={setActiveId}
          />
        ) : (
          <div className="review-empty">Loading edit…</div>
        )
      ) : (<>
        {!presDeckOpen && (() => {
          // v07zz583 — two sections (Edits | Sequences to Review) + admin-curated
          // visibility. Only needs_review rows show by default; "Show all" reveals
          // the hidden ones greyed. Sequences come from exports/sequences/ (or the
          // admin re-file toggle on a card).
          const seqs   = reviews.filter(r => String(r.category || "edit") === "sequence");
          const edits  = reviews.filter(r => String(r.category || "edit") !== "sequence");
          // v07zz591 — PER-SECTION hidden counts. One global count on the Edits header
          // overstated hidden edits (it included hidden sequences) and appeared even
          // when only sequences were hidden. Each section now shows its own toggle
          // with its own count; both flip the same showHidden state.
          const hiddenEdits = edits.filter(r => r.needs_review === 0).length;
          const hiddenSeqs  = seqs.filter(r => r.needs_review === 0).length;
          const vis = (l) => showHidden ? l : l.filter(r => r.needs_review !== 0);
          const shownEdits = vis(edits), shownSeqs = vis(seqs);
          const showAllBtn = (n) => n > 0 && (
            <button className="review-showall" onClick={() => setShowHidden(v => !v)}
              title={showHidden ? "Back to the curated view (only items flagged 'needs review')" : "Also show the items an admin hid from review"}>
              {showHidden ? "Show only needs-review" : `Show all (+${n} hidden)`}
            </button>
          );
          return (
          <div className="review-panel review-panel--edits">
            <div className="review-panel-head review-panel-head--row">
              <span>Edits</span>
              {showAllBtn(hiddenEdits)}
            </div>
            {loading && <div className="review-empty">Loading reviews…</div>}
            {error   && <div className="review-empty review-empty--err">{error}</div>}
            {/* v07zz477 — fresh exports are hidden until their playback proxy is
                built (so a card always plays instantly); this quiet line says one
                is baking. The card pops in via the review.proxy_ready broadcast. */}
            {!loading && !error && encodingCount > 0 && (
              <div className="review-encoding-note" style={{ padding: "6px 2px 10px", fontSize: "var(--fs-12)", letterSpacing: "var(--track-04)", opacity: .62, fontStyle: "italic" }}>
                ⏳ {encodingCount} new edit{encodingCount > 1 ? "s" : ""} encoding for playback — the card will appear when it's ready
              </div>
            )}
            {!loading && !error && (
              <ReviewList reviews={shownEdits} onOpen={(id) => setActiveId(id)}
                adminCtl={canResolve} onFlags={setReviewFlags}
                emptyNote={edits.length ? "All edits reviewed — nothing needs review right now." : null} />
            )}
            {!loading && !error && seqs.length > 0 && (<>
              <div className="review-panel-head review-panel-head--row review-panel-head--seq">
                <span>Sequences to Review</span>
                {showAllBtn(hiddenSeqs)}
              </div>
              <ReviewList reviews={shownSeqs} onOpen={(id) => setActiveId(id)}
                adminCtl={canResolve} onFlags={setReviewFlags}
                emptyNote="All sequences reviewed — nothing needs review right now." />
            </>)}
          </div>
          );
        })()}
        <div className={"review-panel review-panel--pres" + (presDeckOpen ? " review-panel--deck" : "")}>
          {!presDeckOpen && <div className="review-panel-head">Presentations</div>}
          <ReviewPresentations fetcher={fetcher} onDeckOpenChange={setPresDeckOpen} />
        </div>
      </>)}
    </section>
  );
}

// ─── LIST VIEW ─────────────────────────────────────────────────────────

function ReviewList({ reviews, onOpen, adminCtl, onFlags, emptyNote }) {
  if (!reviews || reviews.length === 0) {
    // v07zz583 — a section whose rows are ALL admin-hidden shows a quiet note
    // (emptyNote) instead of the "No edits uploaded yet" CTA — the CTA stays
    // for a genuinely empty library.
    if (emptyNote) return <div className="review-empty review-empty--quiet">{emptyNote}</div>;
    return (
      <div className="review-empty review-empty--cta">
        <div className="review-empty-title">No edits uploaded yet</div>
        <div className="review-empty-sub">Drop an MP4 into the review folder or upload one via the Media page.</div>
      </div>
    );
  }
  return (
    <div className="review-list">
      <div className="review-grid">
        {reviews.map(r => (
          <ReviewCard key={r.id} review={r} onOpen={() => onOpen(r.id)} adminCtl={adminCtl} onFlags={onFlags}/>
        ))}
      </div>
    </div>
  );
}

function ReviewCard({ review, onOpen, adminCtl, onFlags }) {
  // First-frame poster strategy:
  //   1. On first visit, render the <video> with preload="auto", let
  //      the browser load + seek to t=0.2s, then snapshot the frame to
  //      a data URL and store it in sessionStorage keyed by file_path.
  //   2. On subsequent visits (same browser session), use the cached
  //      data URL as the <video poster=...> attribute — paints
  //      INSTANTLY while the video metadata reloads in the background.
  // Result: first-ever view = ~150ms paint (the inevitable video seek).
  // Every visit after = 0ms paint. No more flicker.
  const videoRef = React.useRef(null);
  // v07zz171 — Prefer the server-built poster JPG: render a plain <img> so the
  // card paints INSTANTLY (on Railway too) instead of downloading the whole
  // proxy video just to grab a frame (that was the ~5s delay). Falls back to
  // the <video>-seek approach if the poster image fails to load.
  const [imgFailed, setImgFailed] = React.useState(false);
  const usePoster = !!review.poster && !imgFailed;
  // v07zz169 — chosen cover frame (seconds). Falls back to ~0.2s. The cache key
  // includes it so picking a new cover frame re-captures the thumbnail.
  const posterTime = (review.poster_time != null && Number(review.poster_time) > 0) ? Number(review.poster_time) : null;
  const cacheKey = "review-poster:" + (review.file_path || "") + ":" + (posterTime != null ? posterTime : "d");
  const [cachedPoster, setCachedPoster] = React.useState(() => {
    try { return sessionStorage.getItem(cacheKey) || null; } catch (_) { return null; }
  });
  // v07zb — When we already have a cached poster from sessionStorage,
  // the first paint shows the poster image immediately (as a <video
  // poster=> attribute). Start ready so the fade-in lines up with
  // that instant paint instead of waiting for the seek round-trip.
  const [posterReady, setPosterReady] = React.useState(() => {
    try { return !!sessionStorage.getItem(cacheKey); } catch (_) { return false; }
  });
  const onMeta = (e) => {
    try {
      const v = e.currentTarget;
      // v07zc — Cache the source aspect ratio so when the user clicks
      // into the Review detail view, the big stage paints at the
      // correct aspect from the first frame (no thin→tall flash).
      if (v.videoWidth && v.videoHeight && review.file_path) {
        try {
          sessionStorage.setItem(
            "review-aspect:" + review.file_path,
            `${v.videoWidth} / ${v.videoHeight}`,
          );
        } catch (_) {}
      }
      v.currentTime = posterTime != null
        ? Math.min(posterTime, Math.max(0, (v.duration || posterTime) - 0.05))
        : Math.min(0.2, (v.duration || 1) * 0.05);
    } catch (_) {}
  };
  const onSeeked = (e) => {
    setPosterReady(true);
    if (cachedPoster) return;                  // already cached
    try {
      const v = e.currentTarget;
      const canvas = document.createElement("canvas");
      canvas.width  = Math.min(v.videoWidth  || 640, 800);
      canvas.height = Math.min(v.videoHeight || 360, 450);
      const ctx = canvas.getContext("2d");
      ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
      const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
      try { sessionStorage.setItem(cacheKey, dataUrl); } catch (_) { /* quota */ }
      setCachedPoster(dataUrl);
    } catch (_) { /* canvas may be tainted by cross-origin video; safe to ignore */ }
  };

  const date  = review.uploaded_at || review.created_at;
  const ver   = review.version_label || "v—";
  const title = review.title || `Edit ${review.id}`;
  const by    = review.uploaded_by_name;
  const unresolved = typeof review.unresolved_count === "number" ? review.unresolved_count : null;
  const totalComments = typeof review.comment_count === "number" ? review.comment_count : null;

  // Paint the resting still at the chosen cover frame (or ~0.2s default).
  // v07zz173 — use the playable proxy (play_url) so the card thumbnail can be
  // captured even when the original is HEVC/ProRes that the browser can't decode.
  const cardBase = review.play_url || review.file_path;
  const cardSrc = cardBase
    ? (cardBase.includes("#") ? cardBase : cardBase + "#t=" + (posterTime != null ? posterTime : "0.2"))
    : null;
  // v07zz583 — admin-curated visibility + Edit/Sequence category.
  const isHiddenCard = review.needs_review === 0;   // shown greyed only under "Show all"
  const isSeqCard    = String(review.category || "edit") === "sequence";
  return (
    <button className={"review-card glass" + (isHiddenCard ? " review-card--hidden" : "")} onClick={onOpen}>
      {/* No gradient overlay anymore — the gradient was visible during
          the metadata-load → seek → paint sequence and Hugo described
          it as a "flicker". With preload="auto" the browser loads
          enough of the video to paint the #t=0.2 frame immediately,
          and the gradient never gets to flash. */}
      <div className="review-card-thumb">
        {usePoster ? (
          <img
            className="review-card-video is-ready"
            src={window.thumbUrl ? window.thumbUrl(review.poster, 400) : review.poster}
            alt=""
            loading="lazy"
            onError={() => setImgFailed(true)}
          />
        ) : (
          <video
            ref={videoRef}
            className={"review-card-video" + (posterReady ? " is-ready" : "")}
            src={cardSrc}
            poster={cachedPoster || undefined}
            preload="auto"
            muted
            playsInline
            onLoadedMetadata={onMeta}
            onSeeked={onSeeked}
            onLoadedData={() => setPosterReady(true)}
            onError={() => setPosterReady(true)}
          />
        )}
        <span className="review-card-version">{ver}</span>
        {totalComments != null && totalComments > 0 && (
          <span className="review-card-comments">
            {unresolved != null && unresolved > 0
              ? <><strong>{unresolved}</strong> open · {totalComments} total</>
              : <>{totalComments} comment{totalComments === 1 ? "" : "s"}</>}
          </span>
        )}
        {/* v07zz583 — admin/producer curation controls. spans (not <button>) because the
            whole card IS a <button>; stopPropagation keeps a toggle from opening the edit. */}
        {adminCtl && onFlags && (
          <span className="review-card-adminctl" onClick={(e) => { e.stopPropagation(); e.preventDefault(); }}>
            <span role="button" tabIndex={0}
              className={"rc-flagbtn" + (isHiddenCard ? "" : " is-on")}
              title={isHiddenCard ? "Hidden from review — click to mark NEEDS REVIEW (card shows again)" : "Needs review (visible) — click to HIDE from the Review page"}
              onClick={(e) => { e.stopPropagation(); e.preventDefault(); onFlags(review.id, { needs_review: isHiddenCard ? 1 : 0 }); }}>
              {isHiddenCard
                ? <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
                : <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>}
            </span>
            <span role="button" tabIndex={0} className="rc-flagbtn"
              title={isSeqCard ? "Re-file as an EDIT (moves to the Edits section)" : "Re-file as a SEQUENCE (moves to Sequences to Review)"}
              onClick={(e) => { e.stopPropagation(); e.preventDefault(); onFlags(review.id, { category: isSeqCard ? "edit" : "sequence" }); }}>
              <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/></svg>
            </span>
          </span>
        )}
      </div>
      <div className="review-card-body">
        <div className="review-card-eyebrow">{isSeqCard ? "SEQUENCE" : "EDIT VERSION"}{isHiddenCard ? " · HIDDEN" : ""}</div>
        <div className="review-card-title">{title}</div>
        <div className="review-card-meta">
          {date && <span className="review-card-date">{fmtDateShort(date)}</span>}
          {by && <span className="review-card-by">by {by}</span>}
        </div>
      </div>
    </button>
  );
}

// ─── DETAIL VIEW ───────────────────────────────────────────────────────

// v1024 — FOLLOW THE PLAYHEAD. Markus: "it could help if the notes would follow
// the timecode. currently it keeps jumping back to the beginning and I would need
// to scroll to find the respective note in the timeline. the timeline is too dense
// to click on individual notes."
//
// Keeps the note you are currently watching scrolled into view. The "current"
// note is the LAST one at or before the playhead — a note marks the moment a
// thing happens, so that is the one you want on screen, not the nearest.
//
// It backs off for 5s after you scroll the list yourself, so it never fights you,
// and it only scrolls when the target actually changes.
//
// v1072 — Hugo: "the note needs to stay highlighted until the next one comes". The
// returned id is the note under the playhead from its own time until the next note's
// time; callers light it (gold ring + glow). It is worked out even with Follow off, so
// the highlight never depends on the scroll switch. The scroll now runs on a short timer
// (rAF never fires in a hidden tab), and after you scroll the list yourself it re-syncs
// once the 5 s back-off has passed — but only while the video is moving, never on pause.
function useFollowTimecode(listSel, comments, currentTime, enabled) {
  const lastIdRef = React.useRef(null);
  const userScrolledAt = React.useRef(0);
  // v1072b — the glide in flight. scrollTo({behavior:"smooth"}) left the list where it was
  // (a smooth scroll there never moves, an instant one does), so the follow looked dead.
  // A short timer-driven ease always lands, and a human scroll cancels it at once.
  const glideRef = React.useRef(null);
  const stopGlide = () => { if (glideRef.current) { clearTimeout(glideRef.current); glideRef.current = null; } };
  const glide = (el, target) => {
    stopGlide();
    const from = el.scrollTop;
    const to = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, target));
    const dist = to - from;
    if (Math.abs(dist) < 2) { el.scrollTop = to; return; }
    const steps = 14;
    let i = 0;
    const step = () => {
      i++;
      el.scrollTop = from + dist * (1 - Math.pow(1 - i / steps, 3));   // ease-out, ~250 ms
      glideRef.current = i < steps ? setTimeout(step, 18) : null;
    };
    step();
  };
  React.useEffect(() => () => stopGlide(), []);

  React.useEffect(() => {
    const el = document.querySelector(listSel);
    if (!el) return;
    const onScroll = () => { userScrolledAt.current = Date.now(); lastIdRef.current = null; stopGlide(); };
    // `wheel` and `touchmove` mean a HUMAN scrolled; scrollIntoView fires plain
    // `scroll`, which would otherwise make this switch itself off.
    el.addEventListener("wheel", onScroll, { passive: true });
    el.addEventListener("touchmove", onScroll, { passive: true });
    return () => {
      el.removeEventListener("wheel", onScroll);
      el.removeEventListener("touchmove", onScroll);
    };
  }, [listSel, comments.length]);

  const currentId = React.useMemo(() => {
    if (!comments || !comments.length) return null;
    let best = null;
    for (const c of comments) {
      const t = Number(c.timecode_seconds) || 0;
      if (t <= currentTime + 0.35) best = c; else break;   // comments are sorted by time
    }
    return best ? best.id : null;
  }, [enabled, comments, currentTime]);

  React.useEffect(() => {
    if (!enabled || currentId == null) return;
    if (currentId === lastIdRef.current) return;
    if (Date.now() - userScrolledAt.current < 5000) return;   // you are reading; leave it alone
    const el = document.querySelector(listSel);
    if (!el) return;
    // Run AFTER paint. On the render that follows a seek the row for the new note
    // is not in the DOM yet, so looking straight away found nothing. A timer, not
    // requestAnimationFrame: rAF does not run while the tab is hidden.
    const timer = setTimeout(() => {
      const node = el.querySelector('[data-cid="' + currentId + '"]');
      // Mark it done ONLY once we have a row. Setting this before the lookup meant
      // a single miss poisoned the id for good and the list never moved again —
      // which is exactly what was happening.
      if (!node) return;
      lastIdRef.current = currentId;
      // Measure with rects, NOT offsetTop: the rows' offsetParent is the panel,
      // not this scroller, so offsetTop arithmetic pointed at the wrong place.
      const target = el.scrollTop + (node.getBoundingClientRect().top - el.getBoundingClientRect().top) - 12;
      glide(el, target);
    }, 30);
    return () => clearTimeout(timer);
  }, [currentId, enabled, currentTime]);

  return currentId;
}

// v1076 — the Review player heals itself after the server restarts. Hugo: "the video
// stopped playing and now i cant play it at all anymore". A restart (nodemon after a save
// of server.js, START.bat run again, the PC waking up) cuts the video stream, and Chrome
// does not recover: it waits forever, often with NO error event (tested). This hook watches
// the element. On an error, or a stall where no bytes come for 8 s while it should play, it
// reloads the same file, puts the playhead back and plays on if it was playing. A reload
// that gets no bytes for 5 s is dropped and tried again. Retries back off 0.8 s → 5 s,
// 20 tries (about 1.5 min); then it shows "lost" and play starts a new round.
//   status        "" | "reconnecting" | "lost" (the small pill on the stage)
//   isReloading() true while a reload will put the playhead back itself: skip your own
//                 metadata seek and the 0:00 the element reports in the meantime
//   retry()       call it first in a play handler; true = it took over (a dead player reloads)
function useVideoHeal(videoRef, key) {
  const [status, setStatus] = React.useState("");
  const stRef = React.useRef(null);
  const fresh = () => ({ t: 0, hasTime: false, want: false, playAt: 0, progressAt: Date.now(), plan: null, tries: 0, timer: null, dog: null, attempt: null, onMeta: null, heal: null });
  if (!stRef.current) stRef.current = fresh();

  React.useEffect(() => {
    const s = stRef.current;
    const v = videoRef.current;
    clearTimeout(s.timer); clearTimeout(s.dog); clearTimeout(s.attempt);
    Object.assign(s, fresh());
    setStatus("");
    if (!v || !key) return undefined;

    // What to restore is frozen at the FIRST failure; later retries reuse it.
    const makePlan = () => {
      if (!s.plan) s.plan = { t: s.hasTime ? s.t : null, play: s.want || !v.paused || (Date.now() - s.playAt < 3000) };
      return s.plan;
    };
    const schedule = () => {
      makePlan();
      if (s.timer) return;
      if (s.tries >= 20) { setStatus("lost"); return; }
      setStatus("reconnecting");
      s.timer = setTimeout(() => { s.timer = null; heal(); }, Math.min(5000, Math.round(800 * Math.pow(1.6, s.tries))));
    };
    // A reload can hang with no event at all (Chrome retrying a dead socket). No bytes for
    // 5 s = drop it and try a fresh request; bytes still coming = a slow link, keep waiting.
    const watchAttempt = (onMeta) => {
      clearTimeout(s.attempt);
      s.attempt = setTimeout(() => {
        s.attempt = null;
        if (s.onMeta !== onMeta) return;
        if (Date.now() - s.progressAt > 5000) schedule(); else watchAttempt(onMeta);
      }, 6000);
    };
    const heal = () => {
      const plan = makePlan();
      s.tries += 1;
      if (s.onMeta) v.removeEventListener("loadedmetadata", s.onMeta);
      const onMeta = () => {
        v.removeEventListener("loadedmetadata", onMeta);
        if (s.onMeta === onMeta) s.onMeta = null;
        if (plan.t != null) { try { v.currentTime = Math.max(0, Math.min(plan.t, (v.duration || plan.t) - 0.1)); } catch (_) {} }
        if (s.plan === plan) s.plan = null;
        if (plan.play) { const p = v.play(); if (p && p.catch) p.catch(() => {}); }
        else { try { v.pause(); } catch (_) {} }
      };
      s.onMeta = onMeta;
      v.addEventListener("loadedmetadata", onMeta);
      s.progressAt = Date.now();
      try { v.preload = "auto"; v.load(); } catch (_) {}
      watchAttempt(onMeta);
    };
    s.heal = heal;

    // A cut stream often fires NO error: Chrome just waits. No bytes for 8 s while it should
    // play = dead. The check repeats while the stall lasts; a slow but live link keeps firing
    // "progress", so it never trips.
    const armDog = () => {
      if (s.dog) return;
      s.dog = setTimeout(() => {
        s.dog = null;
        const want = s.plan ? s.plan.play : !v.paused;
        if (!want || v.readyState >= 3) return;
        if (Date.now() - s.progressAt > 7000) schedule(); else armDog();
      }, 8000);
    };
    const onTime = () => {
      s.progressAt = Date.now();
      if (s.plan) return;
      if (v.readyState >= 1) { s.t = v.currentTime || 0; s.hasTime = true; }
      if (!v.paused) s.playAt = Date.now();
    };
    const onProgress = () => { s.progressAt = Date.now(); };
    // A seek into a gap fires no timeupdate until data comes, so take the target here.
    const onSeeking = () => { if (!s.plan && v.readyState >= 1) { s.t = v.currentTime || 0; s.hasTime = true; } };
    const onPlay = () => { s.want = true; };
    const onPause = () => { s.want = false; };
    const onOk = () => { s.tries = 0; clearTimeout(s.dog); s.dog = null; if (!s.plan) setStatus(""); };
    // Code 1 = aborted: a src change or a reload, not a failure.
    const onError = () => { const e = v.error; if (e && e.code !== 1) schedule(); };
    const events = [["timeupdate", onTime], ["progress", onProgress], ["seeking", onSeeking], ["play", onPlay], ["pause", onPause],
      ["playing", onOk], ["canplay", onOk], ["error", onError], ["waiting", armDog], ["stalled", armDog]];
    events.forEach(([n, f]) => v.addEventListener(n, f));
    return () => {
      events.forEach(([n, f]) => v.removeEventListener(n, f));
      if (s.onMeta) { v.removeEventListener("loadedmetadata", s.onMeta); s.onMeta = null; }
      clearTimeout(s.timer); clearTimeout(s.dog); clearTimeout(s.attempt);
      s.timer = null; s.dog = null; s.attempt = null; s.heal = null;
    };
  }, [videoRef, key]);

  return {
    status,
    isReloading: () => { const p = stRef.current.plan; return !!(p && p.t != null); },
    retry: () => {
      const s = stRef.current, v = videoRef.current;
      if (!v || !s.heal || !(v.error || s.plan)) return false;
      clearTimeout(s.timer); s.timer = null; s.tries = 0;
      if (s.plan) s.plan.play = true;
      else s.plan = { t: s.hasTime ? s.t : null, play: true };
      setStatus("reconnecting");
      s.heal();
      return true;
    },
  };
}

function ReviewSkip({ videoRef, duration, onSeek, side }) {
  // v1014 — Markus: "could you add a scroll forward and back button… when I want
  // to go back 5 or 10 seconds to look at something again." J and L already did
  // -5s/+5s, but only as invisible keys, and only on the Review page. These are
  // the same move made visible, and on the popup player too. Fixed width and
  // height, so adding them cannot change the toolbar's size.
  const nudge = (d) => {
    const v = videoRef && videoRef.current; if (!v) return;
    const max = Number(duration) || v.duration || 0;
    const t = Math.max(0, max ? Math.min(max - 0.05, (v.currentTime || 0) + d) : (v.currentTime || 0) + d);
    try { v.currentTime = t; } catch (_) {}
    if (onSeek) onSeek(t);
  };
  // v1016 — `side` so play sits BETWEEN them: -10 -5 [play] +5 +10.
  if (side === "fwd") {
    return (
      <React.Fragment>
        <button type="button" className="review-skip is-fwd" onClick={() => nudge(5)} title="Forward 5 seconds">+5</button>
        <button type="button" className="review-skip is-fwd" onClick={() => nudge(10)} title="Forward 10 seconds">+10</button>
      </React.Fragment>
    );
  }
  return (
    <React.Fragment>
      <button type="button" className="review-skip" onClick={() => nudge(-10)} title="Back 10 seconds">&#8722;10</button>
      <button type="button" className="review-skip" onClick={() => nudge(-5)} title="Back 5 seconds">&#8722;5</button>
    </React.Fragment>
  );
}

function ReviewDetail({ active, onBack, canResolve, isAdmin, fetcher, onRefresh, onReloadList, setError, reviews, onSwitchReview }) {
  const review = active.review;
  const allComments = (active.comments) || [];
  // v07zz278 — hide the comment + reply composers for roles without
  // comment_on_shots (server gates POST /api/video-comments on that key).
  // The comment thread + timeline markers stay fully readable.
  const canComment = !window.hasPerm || window.hasPerm("comment_on_shots");

  // v07zz161 — manual rename + version override for an edit.
  const [editingMeta, setEditingMeta] = React.useState(false);
  const [editTitle, setEditTitle] = React.useState("");
  const [editVer, setEditVer] = React.useState("");
  const [savingMeta, setSavingMeta] = React.useState(false);
  // v07zz169 — "Set as cover": use the current scrub frame as the edit thumbnail.
  const [coverSaving, setCoverSaving] = React.useState(false);
  const [coverSaved, setCoverSaved] = React.useState(false);
  // v07zz476 — PIN the playback src for the lifetime of the open edit. play_url
  // carries a ?rt=<updated_at> cache-buster, so ANY metadata write (Set-cover's
  // poster_time PATCH, an SSE review.update, a title edit) used to change the
  // <video> src mid-viewing → the element reloaded → playback stopped and the
  // position was lost ("set cover just stops the edit"). Only switching to a
  // DIFFERENT edit re-derives the src; a re-export while open shows on reopen.
  // v1075 — the 1080p / 4K switch. A source taller than 1080p plays its high-quality 1080p
  // copy (play_url) so it stays smooth while ComfyUI holds the GPU; original_url is the full
  // 4K master. The choice sticks per browser. A switch keeps the playhead and play/pause.
  const [fullRes, setFullRes] = React.useState(() => { try { return localStorage.getItem("filmtracker.review.fullres") === "1"; } catch (e) { return false; } });
  const pickSrc = (r) => (fullRes && r.original_url) || r.play_url || r.file_path;
  const [playSrc, setPlaySrc] = React.useState(() => pickSrc(review));
  React.useEffect(() => { setPlaySrc(pickSrc(review)); }, [review.id]);
  const resumeAfterSwitchRef = React.useRef(null);
  const switchQuality = () => {
    const next = !fullRes;
    setFullRes(next);
    try { localStorage.setItem("filmtracker.review.fullres", next ? "1" : "0"); } catch (e) {}
    const url = next ? review.original_url : (review.play_url || review.file_path);
    if (!url || url === playSrc) return;
    const v = videoRef.current;
    if (v) { pendingSeekRef.current = v.currentTime || 0; resumeAfterSwitchRef.current = !v.paused; }
    setPlaySrc(url);
  };
  // v07zz369 — Share: copy a public link to this edit. Uses the Railway origin (window.__reviewShareBase)
  // so a link copied on localhost still opens on Railway for reviewers; on Railway it's the own origin.
  // The link opens the edit via the /?review=<id> deep-link.
  const [shared, setShared] = React.useState(false);
  const shareEdit = async () => {
    const base = String((typeof window !== "undefined" && window.__reviewShareBase) || location.origin).replace(/\/+$/, "");
    // 24 Sep 2026 - the link carries its project as the path (/trope?review=12), src/projectUrl.js
    const P = window.__projectUrl;
    const url = base + (P ? P.appPath(window.__activeProjectId) : "/") + "?review=" + encodeURIComponent(review.id);
    let ok = false;
    try { await navigator.clipboard.writeText(url); ok = true; } catch (_) {}
    if (!ok) {
      try { const ta = document.createElement("textarea"); ta.value = url; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); ok = true; } catch (_) {}
    }
    if (ok) { setShared(true); setTimeout(() => setShared(false), 2200); }
  };
  const openMetaEdit = () => { setEditTitle(review.title || ""); setEditVer(review.version_label || ""); setEditingMeta(true); };
  const saveMeta = async () => {
    setSavingMeta(true);
    try {
      const r = await fetcher(`/api/video-reviews/${review.id}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title: editTitle.trim(), version_label: editVer.trim() }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setEditingMeta(false);
      if (onRefresh) onRefresh();
      if (onReloadList) onReloadList();
    } catch (e) { if (setError) setError(e.message); }
    finally { setSavingMeta(false); }
  };
  const setAsCover = async () => {
    const t = videoRef.current ? videoRef.current.currentTime : currentTime;
    setCoverSaving(true); if (setError) setError(null);
    try {
      const r = await fetcher(`/api/video-reviews/${review.id}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ poster_time: t }),
      });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `HTTP ${r.status}`); }
      setCoverSaved(true); setTimeout(() => setCoverSaved(false), 2200);
      if (onRefresh) onRefresh();
      if (onReloadList) onReloadList();
    } catch (e) { if (setError) setError(e.message); }
    finally { setCoverSaving(false); }
  };

  // Player + timeline state.
  const videoRef = React.useRef(null);
  const progressRef = React.useRef(null);
  const stageRef = React.useRef(null);
  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [isPlaying, setIsPlaying] = React.useState(false);
  // v1076 — reconnects by itself after a server restart (useVideoHeal).
  const videoHeal = useVideoHeal(videoRef, playSrc);
  // v06p — Sync the stage aspect ratio to the video's actual
  // source dimensions so the dark frame is flush with the picture
  // (no horizontal or vertical black bars inside the frame).
  // v07zc — Hugo: stage opened with the project-aspect default
  // (21:9) then snapped to 16:9 when metadata loaded ("thin then
  // taller"). Cache the resolved aspect in sessionStorage keyed
  // by file_path so the SECOND time the user opens an edit, the
  // stage paints at the correct aspect from frame one.
  const aspectCacheKey = "review-aspect:" + (review.file_path || "");
  const [videoAspect, setVideoAspect] = React.useState(() => {
    try { return sessionStorage.getItem(aspectCacheKey) || null; } catch (_) { return null; }
  });
  // v06p — Default volume 1.0 (100%). Hugo: previous 0.85 default
  // felt like a bug ("why isn't this at full?"). The user can
  // always pull it down via the slider.
  const [volume, setVolume] = React.useState(1);

  // v07zz209 — A/B compare: pick another edit to view side-by-side. Independent
  // player; lets you A/B two versions without leaving the page.
  const [compareId, setCompareId] = React.useState(null);
  // v1010 — the strip used to be one flat row of everything. Split it into EDIT
  // VERSIONS and SEQUENCES, and keep admin-hidden versions (needs_review = 0)
  // out of both until asked for.
  const [showHiddenVersions, setShowHiddenVersions] = React.useState(false);
  const compareReview = compareId ? (reviews || []).find(r => String(r.id) === String(compareId)) : null;
  React.useEffect(() => { setCompareId(null); }, [review.id]);   // reset when switching edits
  const otherEdits = (reviews || []).filter(r => String(r.id) !== String(review.id));

  // v07zz373 — Cap the stage height so a large / full-screen browser viewport
  // can't grow the video so tall it shoves the toolbar + EDIT VERSIONS strip off
  // the bottom of the page (Hugo: "the bottom completely disappears, viewer too
  // big"). The stage sizes by width × aspect, so we cap WIDTH to (capVh × aspect):
  // height then can't exceed capVh of the viewport, the box stays at the true
  // aspect (no letterboxing), and the row centers whatever's left.
  // 16 Sep 2026 - with no measured video aspect yet, fall back to THIS project ratio (App.jsx
  // publishes it as window.__projectAspectRatio, e.g. 21:9) instead of the 21/9 that used to be
  // typed here: that is the Paradise Found cinema shape, and every other project inherited it.
  const _projArNum = (() => {
    try {
      const p = String(window.__projectAspectRatio || "").split(":");
      const n = p.length === 2 ? parseFloat(p[0]) / parseFloat(p[1]) : NaN;
      if (Number.isFinite(n) && n > 0) return n;
    } catch (_) {}
    return 16 / 9;
  })();
  const _arNum = (() => {
    if (!videoAspect) return _projArNum;
    const p = String(videoAspect).split("/");
    const n = p.length === 2 ? parseFloat(p[0]) / parseFloat(p[1]) : parseFloat(videoAspect);
    return Number.isFinite(n) && n > 0 ? n : _projArNum;
  })();
  const stageStyle = {
    aspectRatio: videoAspect || "var(--project-aspect)",
    // Cap height by the SMALLER of: 54% of the viewport, OR whatever is left after
    // reserving ~460px for the header + toolbar + EDIT VERSIONS strip. The first
    // keeps the video from dominating tall monitors; the second guarantees the
    // controls stay on-screen on short laptops. maxWidth = capHeight × aspect.
    maxWidth: `calc(min(54vh, 100vh - 460px) * ${_arNum.toFixed(4)})`,
  };

  // v07zz210 — compare player has its OWN custom timeline (no native controls).
  // Mirror of the primary player's ref/state set so the toolbar can split into
  // two halves, each driving its own <video>.
  const compareRef         = React.useRef(null);
  const compareProgressRef = React.useRef(null);
  const [compareTime, setCompareTime]           = React.useState(0);
  const [compareDuration, setCompareDuration]   = React.useState(0);
  const [compareIsPlaying, setCompareIsPlaying] = React.useState(false);
  const [compareScrubbing, setCompareScrubbing] = React.useState(false);
  const compareHeal = useVideoHeal(compareRef, compareReview ? compareReview.id : null);
  React.useEffect(() => {   // reset compare playback state when the compared edit changes
    setCompareTime(0); setCompareDuration(0); setCompareIsPlaying(false);
  }, [compareId]);
  const toggleComparePlay = () => {
    const v = compareRef.current; if (!v) return;
    if (compareHeal.retry()) return;
    if (v.paused) v.play(); else v.pause();
  };

  // v07zz171 — Resume playback where the user left off. Persist the playhead
  // (per review) to localStorage as it plays; on (re)open, seek back to it.
  const resumeKey = "review-resume:" + (review.id != null ? review.id : (review.file_path || ""));
  const didResumeRef = React.useRef(false);
  const lastSaveRef = React.useRef(0);
  // v07zz210 — deep-link from the To-Do page: { id, t } parked on window. When
  // this is the targeted edit, seek to t once metadata loads (overrides resume).
  const pendingSeekRef = React.useRef(
    (window.__pendingReviewSeek && String(window.__pendingReviewSeek.id) === String(review.id))
      ? Number(window.__pendingReviewSeek.t) || 0 : null
  );
  React.useEffect(() => {
    if (window.__pendingReviewSeek && String(window.__pendingReviewSeek.id) === String(review.id)) {
      pendingSeekRef.current = Number(window.__pendingReviewSeek.t) || 0;
      try { delete window.__pendingReviewSeek; } catch (_) {}
    }
  }, [review.id]);
  // v07zz372 — keep the chosen cover up until playback actually starts, so switching edits
  // never flashes frame-0 or the resume-seek jump. Reset per edit.
  const [hasPlayed, setHasPlayed] = React.useState(false);
  // Reset the "already resumed" flag + cover state whenever the open edit changes so the
  // next edit gets its own resume seek and re-shows its own cover.
  React.useEffect(() => { didResumeRef.current = false; setHasPlayed(false); }, [review.id]);

  // Comment composer state.
  const [draftBody, setDraftBody] = React.useState("");
  const [draftTime, setDraftTime] = React.useState(null);
  const [replyTo, setReplyTo] = React.useState(null);
  const [replyBody, setReplyBody] = React.useState("");

  // Filter (All / Open / Resolved).
  const [filter, setFilter] = React.useState("open");
  // v1024 — keep the list on the note you are watching. On by default: Markus
  // was scrolling a 169-note list by hand to find where the playhead was.
  const [followTc, setFollowTc] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.review.follow") !== "0"; } catch (e) { return true; }
  });
  React.useEffect(() => { try { localStorage.setItem("filmtracker.review.follow", followTc ? "1" : "0"); } catch (e) {} }, [followTc]);
  const commentsScrollRef = React.useRef(null);

  const seekTo = (tc) => {
    if (!videoRef.current) return;
    try {
      videoRef.current.currentTime = tc;
      videoRef.current.play();
    } catch (e) {}
  };
  const togglePlay = () => {
    const v = videoRef.current; if (!v) return;
    if (videoHeal.retry()) return;   // v1076 — a dead player reloads instead
    if (v.paused) v.play(); else v.pause();
  };

  // Comment popup at a given timecode.
  const startCommentAt = (tc) => {
    setDraftTime(Math.max(0, tc));
    setDraftBody("");
    if (videoRef.current) { try { videoRef.current.pause(); } catch (e) {} }
  };
  const submitComment = (e) => {
    e && e.preventDefault();
    if (draftTime == null || !draftBody.trim()) return;
    fetcher("/api/video-comments", {
      method: "POST",
      body: JSON.stringify({
        video_review_id:  review.id,
        timecode_seconds: draftTime,
        body:             draftBody.trim(),
      }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => { setDraftTime(null); setDraftBody(""); onRefresh(); })
      .catch(err => setError(err.message));
  };
  const submitReply = (parentId, parentTC) => {
    if (!replyBody.trim()) return;
    fetcher("/api/video-comments", {
      method: "POST",
      body: JSON.stringify({
        video_review_id:   review.id,
        parent_comment_id: parentId,
        timecode_seconds:  parentTC,
        body:              replyBody.trim(),
      }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => { setReplyTo(null); setReplyBody(""); onRefresh(); })
      .catch(err => setError(err.message));
  };
  const resolveComment = (id) => {
    fetcher(`/api/video-comments/${id}/resolve`, { method: "PATCH", body: JSON.stringify({ resolved: true }) })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
      .then(onRefresh)
      .catch(err => setError(err.message));
  };

  // v06p — Timeline is a scrubber now: click anywhere to jump,
  // drag to scrub. Notes are added explicitly via the toolbar
  // button or the `C` key, never by an accidental click on the
  // progress bar. Previously clicks dropped a note which felt
  // surprising — the gold cursor implied "I'm scrubbing".
  const [scrubbing, setScrubbing] = React.useState(false);
  const seekFromEvent = React.useCallback((e) => {
    if (!progressRef.current || !duration || !videoRef.current) return;
    const rect = progressRef.current.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    const t = ratio * duration;
    try { videoRef.current.currentTime = t; } catch (_) {}
    setCurrentTime(t);
  }, [duration]);
  const onProgressMouseDown = (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    setScrubbing(true);
    seekFromEvent(e);
  };
  React.useEffect(() => {
    if (!scrubbing) return;
    const onMove = (e) => seekFromEvent(e);
    const onUp   = () => setScrubbing(false);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup",   onUp);
    window.addEventListener("touchmove", onMove);
    window.addEventListener("touchend",  onUp);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup",   onUp);
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend",  onUp);
    };
  }, [scrubbing, seekFromEvent]);

  // v07zz210 — compare bar scrubbing (parallel to the primary above).
  const compareSeekFromEvent = React.useCallback((e) => {
    if (!compareProgressRef.current || !compareDuration || !compareRef.current) return;
    const rect = compareProgressRef.current.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    const t = ratio * compareDuration;
    try { compareRef.current.currentTime = t; } catch (_) {}
    setCompareTime(t);
  }, [compareDuration]);
  const onCompareProgressMouseDown = (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    setCompareScrubbing(true);
    compareSeekFromEvent(e);
  };
  React.useEffect(() => {
    if (!compareScrubbing) return;
    const onMove = (e) => compareSeekFromEvent(e);
    const onUp   = () => setCompareScrubbing(false);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup",   onUp);
    window.addEventListener("touchmove", onMove);
    window.addEventListener("touchend",  onUp);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup",   onUp);
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend",  onUp);
    };
  }, [compareScrubbing, compareSeekFromEvent]);

  // Keyboard shortcuts.
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;   // v07zz616 — Ctrl+C is COPY (it was hijacked into "new comment"), Ctrl+anything is never a shortcut here
      if (e.key === "c" || e.key === "C") {
        e.preventDefault();
        startCommentAt(videoRef.current ? videoRef.current.currentTime : 0);
      } else if (e.key === "Escape" && draftTime != null) {
        setDraftTime(null); setDraftBody("");
      } else if (e.key === " " || e.key === "k" || e.key === "K") {
        e.preventDefault();
        togglePlay();
      } else if (e.key === "j" || e.key === "J") {
        if (videoRef.current) videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 5);
      } else if (e.key === "l" || e.key === "L") {
        if (videoRef.current) videoRef.current.currentTime = Math.min(duration, videoRef.current.currentTime + 5);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [draftTime, duration]);

  const topComments = allComments.filter(c => !c.parent_comment_id);
  const repliesByParent = allComments.reduce((acc, c) => {
    if (c.parent_comment_id) {
      (acc[c.parent_comment_id] = acc[c.parent_comment_id] || []).push(c);
    }
    return acc;
  }, {});
  // v1072 — the lit note is the one under the playhead (useFollowTimecode, below) and it
  // stays lit until the playhead reaches the next note. It used to light only within
  // 1.25 s of its own time, so it flashed and went out.
  const isCurrentComment = (c) => c.id === followNowId;

  // Apply filter and sort by timecode ascending.
  const filteredTop = topComments
    .filter(c => {
      if (filter === "open")     return !c.resolved;
      if (filter === "resolved") return !!c.resolved;
      return true;
    })
    .sort((a, b) => (a.timecode_seconds || 0) - (b.timecode_seconds || 0));

  const followNowId = useFollowTimecode(".review-comments-scroll", filteredTop, currentTime, followTc);

  const openCount     = topComments.filter(c => !c.resolved).length;
  const resolvedCount = topComments.filter(c =>  c.resolved).length;

  return (
    <div className="review-detail">
      <div className="review-detail-head">
        <button type="button" className="review-back-btn" onClick={onBack}>
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M15 6l-6 6 6 6"/></svg>
          <span>All edits</span>
        </button>
        <div className="review-detail-titles">
          {editingMeta ? (
            <div className="review-meta-edit">
              <div className="review-meta-edit-row">
                <input className="review-meta-ver" value={editVer}
                  onChange={(e) => setEditVer(e.target.value)} placeholder="v002" aria-label="Version"/>
                <input className="review-meta-title" value={editTitle}
                  onChange={(e) => setEditTitle(e.target.value)} placeholder="Edit name" aria-label="Edit name"/>
              </div>
              <div className="review-meta-edit-actions">
                <button type="button" className="review-meta-save" disabled={savingMeta} onClick={saveMeta}>{savingMeta ? "Saving…" : "Save"}</button>
                <button type="button" className="review-meta-cancel" disabled={savingMeta} onClick={() => setEditingMeta(false)}>Cancel</button>
              </div>
            </div>
          ) : (
            <>
              <div className="review-detail-eyebrow">EDIT VERSION · {review.version_label || "v—"}</div>
              <div className="review-detail-title">{review.title || `Edit ${review.id}`}</div>
              <div className="review-detail-sub">
                {fmtDateShort(review.uploaded_at || review.created_at)}
                {review.uploaded_by_name && <> · uploaded by <strong>{review.uploaded_by_name}</strong></>}
              </div>
            </>
          )}
        </div>
        <div className="review-detail-tools">
          {/* v07zz369 — Share: copy a Railway link to this edit. */}
          {!editingMeta && (
            <button type="button" className={"review-meta-editbtn" + (shared ? " review-cover-saved" : "")}
              title={shared ? "Link copied!" : "Copy a shareable link to this edit"} aria-label="Share this edit — copy link" onClick={shareEdit}>
              {shared ? (
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="m5 12 5 5L20 7"/></svg>
              ) : (
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5"/></svg>
              )}
            </button>
          )}
          {!editingMeta && (
            <button type="button" className="review-meta-editbtn" title="Rename / set version" aria-label="Rename or set version" onClick={openMetaEdit}>
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
            </button>
          )}
          {/* v07zz177 — "Set cover" is now an icon-only button (admin-only),
              sitting next to the open/reveal icon. Same square shape as the
              rename button; turns green briefly when saved. */}
          {!editingMeta && isAdmin && (
            <button type="button" className={"review-meta-editbtn" + (coverSaved ? " review-cover-saved" : "")}
              onClick={setAsCover} disabled={coverSaving}
              title={coverSaving ? "Saving cover…" : (coverSaved ? "Cover set" : "Set current frame as this edit's cover")}
              aria-label="Set current frame as cover">
              {coverSaved ? (
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><path d="m5 12 5 5L20 7"/></svg>
              ) : (
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="12" cy="12" r="3"/></svg>
              )}
            </button>
          )}
          {/* v07zz209 — A/B compare with another edit (side-by-side). */}
          {!editingMeta && otherEdits.length > 0 && (
            <button type="button" className={"review-meta-editbtn" + (compareId ? " review-cover-saved" : "")}
              onClick={() => setCompareId(compareId ? null : (otherEdits[0] && otherEdits[0].id) || null)}
              title={compareId ? "Exit compare" : "Compare with another edit, side by side"} aria-label="Compare edits">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="7" height="14" rx="1"/><rect x="14" y="5" width="7" height="14" rx="1"/></svg>
            </button>
          )}
          {/* v07zz162 — version-switch dropdown removed: the OTHER EDIT
              VERSIONS cards below already switch between edits. */}
          {/* v07zz476 — reveal/download target the ORIGINAL export (file_path),
              NOT play_url: play_url is the 720p proxy in _tracker/cache/
              review-proxies/, so "browse to edit" was opening the cache folder
              instead of work/edit/premiere/exports/. Only the <video> plays
              the proxy. */}
          {(window.FileActionBtns || window.RevealInFolderBtn) && (() => {
            const Btns = window.FileActionBtns || window.RevealInFolderBtn;
            return <Btns src={review.file_path || review.play_url} label="Open this edit in your file explorer" downloadLabel="Download this edit to your computer" />;
          })()}
        </div>
      </div>

      <div className="review-detail-body">
        {/* LEFT: dark video stage + custom toolbar/timeline below */}
        <div className={"review-stage-col" + (compareReview ? " is-comparing" : "")}>
          <div className="review-stage-row">
          <div
            ref={stageRef}
            className="review-stage"
            style={stageStyle}>
            <video
              ref={videoRef}
              className="review-video"
              // v07zz173 — play the browser-friendly proxy (play_url); the
              // original is often 10-bit HEVC/ProRes .mov that won't decode.
              // v07zz476 — via playSrc (pinned per edit), NOT review.play_url:
              // the ?rt= buster changes on every metadata write and reloaded
              // the element mid-playback (Set-cover stopped the video).
              src={playSrc}
              // v07zz171 — Prefer the server-built poster JPG (R2/local) so the
              // stage paints instantly on Railway too; fall back to the
              // sessionStorage frame cached by the ReviewCard, then nothing.
              poster={review.poster || (() => {
                try { return sessionStorage.getItem("review-poster:" + (review.file_path || "")) || undefined; }
                catch (_) { return undefined; }
              })()}
              /* v07zz372 — don't fetch/decode the video (or run the resume seek) until the user
                 presses play; the chosen cover covers the stage until then, so there's no
                 first-frame flash or resume-jump. A deep-link seek still preloads + autoplays. */
              preload={pendingSeekRef.current != null ? "auto" : "none"}
              onClick={togglePlay}
              onPlay={() => setIsPlaying(true)}
              onPlaying={() => setHasPlayed(true)}
              onPause={() => setIsPlaying(false)}
              onEnded={() => { try { localStorage.removeItem(resumeKey); } catch (_) {} }}
              onTimeUpdate={(e) => {
                if (videoHeal.isReloading()) return;   // v1076 — skip the 0:00 of a reload
                const v = e.currentTarget;
                const t = v.currentTime || 0;
                setCurrentTime(t);
                // v07zz171 — persist the playhead (throttled ~2s) for resume.
                try {
                  const now = Date.now();
                  if (now - lastSaveRef.current > 2000) {
                    lastSaveRef.current = now;
                    if (v.duration && t > 3 && t < v.duration - 5) localStorage.setItem(resumeKey, String(t));
                    else if (v.duration && t >= v.duration - 5) localStorage.removeItem(resumeKey);
                  }
                } catch (_) {}
              }}
              onLoadedMetadata={(e) => {
                const v = e.currentTarget;
                setDuration(v.duration || 0);
                if (v.videoWidth && v.videoHeight) {
                  const a = `${v.videoWidth} / ${v.videoHeight}`;
                  setVideoAspect(a);
                  // v07zc — Persist so subsequent opens skip the
                  // default-aspect flash.
                  try { sessionStorage.setItem(aspectCacheKey, a); } catch (_) {}
                }
                // v1076 — a self-heal reload puts the playhead back itself (and that seek
                // must not be snapshotted as the card poster).
                if (videoHeal.isReloading()) { didResumeRef.current = true; return; }
                // v07zz210 — Deep-link seek from the To-Do page wins over resume.
                let resumed = false;
                if (pendingSeekRef.current != null) {
                  try {
                    const tgt = Math.max(0, Math.min(pendingSeekRef.current, (v.duration || pendingSeekRef.current) - 0.1));
                    v.currentTime = tgt; setCurrentTime(tgt);
                    didResumeRef.current = true; resumed = true;   // suppress poster-snapshot seek
                  } catch (_) {}
                  pendingSeekRef.current = null;
                  try { v.play(); } catch (_) {}
                  // v1075 — a 1080p/4K switch keeps a paused video paused.
                  if (resumeAfterSwitchRef.current === false) { try { v.pause(); } catch (_) {} }
                  resumeAfterSwitchRef.current = null;
                }
                // v07zz171 — Resume where the user left off (if we have a saved
                // playhead that's not at the very start/end). Takes priority
                // over the poster-snapshot seek below.
                if (!resumed) try {
                  const saved = parseFloat(localStorage.getItem(resumeKey) || "");
                  if (Number.isFinite(saved) && saved > 3 && v.duration && saved < v.duration - 5) {
                    v.currentTime = saved; didResumeRef.current = true; resumed = true;
                  }
                } catch (_) {}
                // If the card poster wasn't cached yet (user navigated
                // here directly without seeing the list), seek to t=0.2
                // and snapshot here so we cache it ourselves.
                if (!resumed && !sessionStorage.getItem("review-poster:" + (review.file_path || ""))) {
                  try { v.currentTime = Math.min(0.2, (v.duration || 1) * 0.05); } catch (_) {}
                }
              }}
              onSeeked={(e) => {
                const v = e.currentTarget;
                const key = "review-poster:" + (review.file_path || "");
                // Don't snapshot a poster from a resume seek (it would be a
                // mid-film frame, not the cover).
                if (didResumeRef.current) return;
                if (sessionStorage.getItem(key)) return;
                try {
                  const canvas = document.createElement("canvas");
                  canvas.width  = Math.min(v.videoWidth  || 1280, 1280);
                  canvas.height = Math.min(v.videoHeight || 720,  720);
                  canvas.getContext("2d").drawImage(v, 0, 0, canvas.width, canvas.height);
                  sessionStorage.setItem(key, canvas.toDataURL("image/jpeg", 0.8));
                } catch (_) {}
              }}
            />
            {/* v07zz372 — the chosen cover sits over the stage until playback actually starts. */}
            {!hasPlayed && review.poster && (
              <img className="review-cover-still"
                src={window.thumbUrl ? window.thumbUrl(review.poster, 1200) : review.poster} alt=""
                onClick={() => { const v = videoRef.current; if (v) { try { v.play(); } catch (_) {} } }}/>
            )}
            {(!isPlaying || videoHeal.status === "lost") && (
              <button type="button" className="review-stage-play" onClick={togglePlay} aria-label="Play">
                <svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
              </button>
            )}
            {videoHeal.status && (
              <div className="review-stage-heal" role="status">
                {videoHeal.status === "lost" ? "Video lost — press play to retry" : "Reconnecting the video…"}
              </div>
            )}
          </div>
          {/* v07zz209 — A/B compare: a second, self-contained stage rendered to the
              right of the primary one. Its own <video controls>; picker chooses which
              other edit to view; ✕ exits compare. */}
          {compareReview && (
            <div className="review-compare">
              <div className="review-compare-head">
                <select
                  className="review-compare-pick"
                  value={compareId || ""}
                  onChange={(e) => setCompareId(e.target.value)}
                  title="Choose an edit to compare against">
                  {otherEdits.map(r => (
                    <option key={r.id} value={r.id}>
                      {(r.title || ("Edit " + r.id)) + (r.version_label ? "  ·  " + r.version_label : "")}
                    </option>
                  ))}
                </select>
                <button type="button" className="review-compare-close" onClick={() => setCompareId(null)} title="Exit compare" aria-label="Exit compare">
                  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
                </button>
              </div>
              <div className="review-stage review-compare-stage" style={stageStyle}>
                <video
                  key={compareReview.id}
                  ref={compareRef}
                  className="review-video"
                  muted
                  src={compareReview.play_url || compareReview.file_path}
                  poster={compareReview.poster || undefined}
                  preload="metadata"
                  onClick={toggleComparePlay}
                  onPlay={() => setCompareIsPlaying(true)}
                  onPause={() => setCompareIsPlaying(false)}
                  onTimeUpdate={(e) => { if (!compareHeal.isReloading()) setCompareTime(e.currentTarget.currentTime || 0); }}
                  onLoadedMetadata={(e) => setCompareDuration(e.currentTarget.duration || 0)}
                />
                {(!compareIsPlaying || compareHeal.status === "lost") && (
                  <button type="button" className="review-stage-play" onClick={toggleComparePlay} aria-label="Play compare">
                    <svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
                  </button>
                )}
                {compareHeal.status && (
                  <div className="review-stage-heal" role="status">
                    {compareHeal.status === "lost" ? "Video lost — press play to retry" : "Reconnecting the video…"}
                  </div>
                )}
              </div>
            </div>
          )}
          </div>
          {/* Toolbar / timeline OUTSIDE the dark frame, in the cream panel.
              v07zz210 — in compare mode the toolbar splits into two equal halves,
              each a self-contained transport for its own stage. */}
          <div className={"review-toolbar" + (compareReview ? " with-compare" : "")}>
            <div className="review-toolbar-half">
              <ReviewSkip videoRef={videoRef} duration={duration} onSeek={setCurrentTime}/>
              <button type="button" className="review-toolbar-play" onClick={togglePlay} aria-label={isPlaying ? "Pause" : "Play"}>
                {isPlaying
                  ? <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
                  : <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>}
              </button>
              <ReviewSkip videoRef={videoRef} duration={duration} onSeek={setCurrentTime} side="fwd"/>
              <div className="review-tc">{fmtTC(currentTime)}<span className="review-tc-sep">/</span>{fmtTC(duration)}</div>
              <div
                ref={progressRef}
                className={"review-progress" + (scrubbing ? " is-scrubbing" : "")}
                onMouseDown={onProgressMouseDown}
                onTouchStart={onProgressMouseDown}
                role="slider"
                aria-valuemin={0}
                aria-valuemax={duration || 0}
                aria-valuenow={currentTime || 0}
                title="Click or drag to scrub"
              >
                <div className="review-progress-fill" style={{
                  width: duration ? `${(currentTime / duration) * 100}%` : "0%",
                  "--review-fill-pct": duration ? `${Math.max(0.5, (currentTime / duration) * 100)}` : "100",
                }}/>
                <span className="review-progress-handle" style={{ left: duration ? `${(currentTime / duration) * 100}%` : "0%" }}/>
                {allComments.map(c => (
                  <span
                    key={c.id}
                    className={"review-marker" + (c.resolved ? " is-resolved" : "") + (isCurrentComment(c) ? " is-current" : "")}
                    style={{ left: duration ? `${(c.timecode_seconds / duration) * 100}%` : 0 }}
                    title={`${fmtTC(c.timecode_seconds)} · ${(c.body || "").slice(0, 80)}`}
                    onMouseDown={(e) => e.stopPropagation()}
                    onClick={(e) => { e.stopPropagation(); seekTo(c.timecode_seconds); }}
                  />
                ))}
              </div>
              <input
                type="range" min={0} max={1} step={0.01}
                className="review-volume"
                value={volume}
                onChange={(e) => {
                  const v = parseFloat(e.target.value);
                  setVolume(v);
                  if (videoRef.current) videoRef.current.volume = v;
                }}
                title="Volume"
              />
              {/* v1075 — 1080p / 4K switch: only when this edit has a full-res master that
                  differs from what plays (a 4K source playing its HQ 1080p copy). */}
              {review.original_url && (
                <button type="button" className={"review-toolbar-fs review-quality-btn" + (fullRes ? " is-on" : "")}
                  onClick={switchQuality}
                  title={fullRes ? "Playing the full 4K master. Click for the smooth 1080p copy." : "Playing the smooth 1080p copy. Click for the full 4K master."}>
                  {fullRes ? "4K" : "1080p"}
                </button>
              )}
              <button type="button" className="review-toolbar-fs" onClick={() => {
                const el = stageRef.current; if (!el) return;
                if (document.fullscreenElement) document.exitFullscreen();
                else el.requestFullscreen && el.requestFullscreen();
              }} aria-label="Fullscreen" title="Fullscreen">
                <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
              </button>
            </div>
            {compareReview && (
              <div className="review-toolbar-half is-compare">
                <button type="button" className="review-toolbar-play" onClick={toggleComparePlay} aria-label={compareIsPlaying ? "Pause compare" : "Play compare"}>
                  {compareIsPlaying
                    ? <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
                    : <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>}
                </button>
                <div className="review-tc">{fmtTC(compareTime)}<span className="review-tc-sep">/</span>{fmtTC(compareDuration)}</div>
                <div
                  ref={compareProgressRef}
                  className={"review-progress" + (compareScrubbing ? " is-scrubbing" : "")}
                  onMouseDown={onCompareProgressMouseDown}
                  onTouchStart={onCompareProgressMouseDown}
                  role="slider"
                  aria-valuemin={0}
                  aria-valuemax={compareDuration || 0}
                  aria-valuenow={compareTime || 0}
                  title="Click or drag to scrub"
                >
                  <div className="review-progress-fill" style={{
                    width: compareDuration ? `${(compareTime / compareDuration) * 100}%` : "0%",
                    "--review-fill-pct": compareDuration ? `${Math.max(0.5, (compareTime / compareDuration) * 100)}` : "100",
                  }}/>
                  <span className="review-progress-handle" style={{ left: compareDuration ? `${(compareTime / compareDuration) * 100}%` : "0%" }}/>
                </div>
                <button type="button" className="review-toolbar-fs" onClick={() => {
                  const el = compareRef.current; if (!el) return;
                  if (document.fullscreenElement) document.exitFullscreen();
                  else el.requestFullscreen && el.requestFullscreen();
                }} aria-label="Fullscreen compare" title="Fullscreen">
                  <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
                </button>
              </div>
            )}
          </div>
        </div>

        {/* RIGHT: comments column */}
        <aside className="review-side">
          <div className="review-side-head">
            <div className="review-side-title">NOTES</div>
            <div className="review-side-tabs">
              <button type="button"
                className={"review-side-tab" + (filter === "open" ? " is-active" : "")}
                onClick={() => setFilter("open")}>
                Open <span className="review-side-tab-count">{openCount}</span>
              </button>
              <button type="button"
                className={"review-side-tab" + (filter === "all" ? " is-active" : "")}
                onClick={() => setFilter("all")}>
                All <span className="review-side-tab-count">{topComments.length}</span>
              </button>
              <button type="button"
                className={"review-side-tab" + (filter === "resolved" ? " is-active" : "")}
                onClick={() => setFilter("resolved")}>
                Resolved <span className="review-side-tab-count">{resolvedCount}</span>
              </button>
              {/* v1024 — follow the playhead. */}
              <button type="button"
                className={"review-follow-btn" + (followTc ? " is-on" : "")}
                title={followTc
                  ? "The list is following the video — click to stop it"
                  : "Keep the note you are watching in view as the video plays"}
                onClick={() => setFollowTc(v => !v)}>
                <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M12 5v14"/><path d="M6 13l6 6 6-6"/>
                </svg>
                Follow
              </button>
            </div>
          </div>

          <div className="review-comments-scroll" ref={commentsScrollRef}>
            {canComment && draftTime != null && (
              <form className="review-draft" onSubmit={submitComment}>
                <div className="review-draft-head">
                  <span className="review-draft-tc">{fmtTC(draftTime)}</span>
                  <span className="review-draft-label">New note at this timecode</span>
                </div>
                <textarea
                  className="review-draft-input"
                  rows={3}
                  placeholder="Type your feedback…"
                  value={draftBody}
                  onChange={(e) => setDraftBody(e.target.value)}
                  autoFocus
                />
                <div className="review-draft-actions">
                  <button type="button" className="review-btn-ghost" onClick={() => { setDraftTime(null); setDraftBody(""); }}>Cancel</button>
                  <button type="submit" className="review-btn-primary" disabled={!draftBody.trim()}>Post note</button>
                </div>
              </form>
            )}

            {filteredTop.length === 0 && (
              <div className="review-comments-empty">
                {draftTime == null
                  ? (filter === "open"
                      ? <>No open notes. Press <kbd>C</kbd> or hit <em>Add note</em> below to drop one at the current frame.</>
                      : filter === "resolved"
                        ? "Nothing resolved yet."
                        : <>No notes yet. Press <kbd>C</kbd> at the frame you want to comment on.</>)
                  : "Compose your note above."}
              </div>
            )}

            <ul className="review-comments">
              {filteredTop.map(c => {
                const replies = repliesByParent[c.id] || [];
                return (
                  <li key={c.id} className="review-comment-thread" data-cid={c.id}>
                    <div
                      className={"review-comment" + (isCurrentComment(c) ? " is-current" : "") + (c.resolved ? " is-resolved" : "")}
                      onClick={() => seekTo(c.timecode_seconds)}
                    >
                      <div className="review-comment-avatar"
                           style={{ background: reviewGradient(c.user_name || "system") }}>
                        <span>{(c.user_name || "??").slice(0, 2).toUpperCase()}</span>
                      </div>
                      <div className="review-comment-body">
                        <div className="review-comment-meta">
                          <span className="review-comment-author">{c.user_name || "system"}</span>
                          <button type="button" className="review-comment-tc-btn"
                            onClick={(e) => { e.stopPropagation(); seekTo(c.timecode_seconds); }}
                            title="Seek to this timecode">
                            {fmtTC(c.timecode_seconds)}
                          </button>
                          <span className="review-comment-date">{fmtRelative(c.created_at)}</span>
                          {c.resolved && <span className="review-comment-resolved-tag">resolved</span>}
                        </div>
                        <div className="review-comment-text">{c.body}</div>
                        <div className="review-comment-actions">
                          {canComment && (
                          <button type="button" className="review-comment-action"
                            onClick={(e) => { e.stopPropagation(); setReplyTo(c.id); setReplyBody(""); }}>
                            Reply
                          </button>
                          )}
                          {canResolve && !c.resolved && (
                            <button type="button" className="review-comment-action review-comment-action--resolve"
                              onClick={(e) => { e.stopPropagation(); resolveComment(c.id); }}>
                              ✓ Resolve
                            </button>
                          )}
                        </div>
                      </div>
                    </div>

                    {replies.length > 0 && (
                      <ul className="review-replies">
                        {replies.map(r => (
                          <li key={r.id} className={"review-comment review-comment--reply" + (r.resolved ? " is-resolved" : "")}>
                            <div className="review-comment-avatar review-comment-avatar--reply"
                                 style={{ background: reviewGradient(r.user_name || "system") }}>
                              <span>{(r.user_name || "??").slice(0, 2).toUpperCase()}</span>
                            </div>
                            <div className="review-comment-body">
                              <div className="review-comment-meta">
                                <span className="review-comment-author">{r.user_name || "system"}</span>
                                <span className="review-comment-date">{fmtRelative(r.created_at)}</span>
                              </div>
                              <div className="review-comment-text">{r.body}</div>
                            </div>
                          </li>
                        ))}
                      </ul>
                    )}

                    {canComment && replyTo === c.id && (
                      <form className="review-reply-form" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); submitReply(c.id, c.timecode_seconds); }}>
                        <textarea
                          className="review-reply-input"
                          rows={2}
                          placeholder={`Reply to ${c.user_name || "this note"}…`}
                          value={replyBody}
                          onChange={(e) => setReplyBody(e.target.value)}
                          autoFocus
                        />
                        <div className="review-reply-actions">
                          <button type="button" className="review-btn-ghost" onClick={() => { setReplyTo(null); setReplyBody(""); }}>Cancel</button>
                          <button type="submit" className="review-btn-primary" disabled={!replyBody.trim()}>Reply</button>
                        </div>
                      </form>
                    )}
                  </li>
                );
              })}
            </ul>
          </div>

          {/* Composer footer: always-available "add note at current playhead" */}
          {draftTime == null && (
            <div className="review-side-composer">
              <button type="button" className="review-btn-primary review-btn-fullwidth"
                onClick={() => startCommentAt(videoRef.current ? videoRef.current.currentTime : 0)}>
                + Add note at {fmtTC(currentTime)}
              </button>
            </div>
          )}
        </aside>
      </div>

      {/* v06p — Other edit versions, full width below the player. Lets
          Hugo switch between cuts without going back to the list.
          The card for the currently-open review is hidden. */}
      {reviews && reviews.length > 0 && (() => {
        // v1010 — two rows. Edits on top, sequences underneath, and versions the
        // admin has hidden (needs_review = 0) stay out of both until toggled on.
        // The one currently open always shows, even if it is hidden, so the strip
        // never loses the gold-highlighted card you are looking at.
        const isSeq     = (r) => String(r.category || "edit") === "sequence";
        const isHidden  = (r) => r.needs_review === 0 && r.id !== review.id;
        const hiddenN   = reviews.filter(r => r.needs_review === 0 && r.id !== review.id).length;
        const visible   = showHiddenVersions ? reviews : reviews.filter(r => !isHidden(r));
        const editRow   = visible.filter(r => !isSeq(r));
        const seqRow    = visible.filter(isSeq);
        // Rows are keyed off the FULL list, not the visible one, so toggling
        // hidden versions on and off never makes a row appear, vanish or
        // collapse — the strip keeps its height and nothing below moves.
        const anySeq    = reviews.some(isSeq);
        const emptyCard = (what) => (
          <div className="review-other-card review-other-card--empty" aria-hidden="true">
            <div className="review-other-thumb" />
            <div className="review-other-body">
              <div className="review-other-title">All {what} hidden</div>
              <div className="review-other-date">Use Show hidden</div>
            </div>
          </div>
        );

        const renderCard = (r) => (
          <button key={r.id} type="button"
            className={"review-other-card"
              + (r.id === review.id ? " is-current" : "")
              + (r.needs_review === 0 ? " is-hidden-version" : "")}
            onClick={() => onSwitchReview(r.id)}>
            <div className="review-other-thumb" style={{ background: reviewGradient(r.file_path || r.id) }}>
              {r.poster ? (
                <img className="review-other-video" src={window.thumbUrl ? window.thumbUrl(r.poster, 320) : r.poster} alt="" loading="eager"
                  onError={(e) => { e.currentTarget.style.display = "none"; }}/>
              ) : (
                <video
                  className="review-other-video"
                  src={r.play_url || r.file_path}
                  preload="metadata"
                  muted
                  playsInline
                  onLoadedMetadata={(e) => {
                    try { e.currentTarget.currentTime = Math.min(0.2, (e.currentTarget.duration || 1) * 0.05); } catch (_) {}
                  }}
                />
              )}
              <span className="review-other-version">{r.version_label || "v—"}</span>
            </div>
            <div className="review-other-body">
              <div className="review-other-title">{r.title || `Edit ${r.id}`}</div>
              <div className="review-other-date">{fmtDateShort(r.uploaded_at || r.created_at)}</div>
            </div>
          </button>
        );

        return (
          <div className="review-other-versions">
            <div className="review-other-versions-head">
              <span className="review-other-versions-title">EDIT VERSIONS</span>
              <span className="review-other-versions-count">{editRow.length}</span>
              {hiddenN > 0 && (
                <button type="button" className="review-hidden-toggle"
                  title={showHiddenVersions ? "Hide the versions you've hidden" : "Show the versions you've hidden"}
                  onClick={() => setShowHiddenVersions(v => !v)}>
                  {showHiddenVersions ? `Hide hidden (${hiddenN})` : `Show hidden (${hiddenN})`}
                </button>
              )}
            </div>
            <div className="review-other-versions-strip">
              {editRow.length ? editRow.map(renderCard) : emptyCard("edits")}
            </div>

            {anySeq && (
              <React.Fragment>
                <div className="review-other-versions-head review-other-versions-head--sub">
                  <span className="review-other-versions-title">SEQUENCES</span>
                  <span className="review-other-versions-count">{seqRow.length}</span>
                </div>
                <div className="review-other-versions-strip">
                  {seqRow.length ? seqRow.map(renderCard) : emptyCard("sequences")}
                </div>
              </React.Fragment>
            )}
          </div>
        );
      })()}
    </div>
  );
}

// v07zz221 — GlobalReviewModal: opens a single video review (an edit) + its
// comments as an OVERLAY on top of any view (e.g. the To-Do page), instead of
// navigating to the Review page. Triggered by window.__openReview(reviewId, t).
// Mounted once at the App root (like GlobalAssetModal).
function GlobalReviewModal() {
  const fetcher = window.authFetch || fetch;
  const [open, setOpen] = React.useState(null);        // { id, timecode, shotId, focusId }
  const [review, setReview] = React.useState(null);
  const [busy, setBusy] = React.useState({});
  const [replyTo, setReplyTo] = React.useState(null);
  const [replyBody, setReplyBody] = React.useState("");
  const [onlyShot, setOnlyShot] = React.useState(true);
  // v1024 — same follow behaviour in the popup.
  const [followTc, setFollowTc] = React.useState(() => {
    try { return localStorage.getItem("filmtracker.review.follow") !== "0"; } catch (e) { return true; }
  });
  const notesScrollRef = React.useRef(null);
  const videoRef = React.useRef(null);
  const progressRef = React.useRef(null);
  const stageRef = React.useRef(null);
  // v1012 — the modal now drives its own transport instead of the browser's
  // native controls, which sat on top of the burned-in shot number.
  const [currentTime, setCurrentTime] = React.useState(0);
  const [duration, setDuration] = React.useState(0);
  const [isPlaying, setIsPlaying] = React.useState(false);
  const [volume, setVolume] = React.useState(1);
  const [scrubbing, setScrubbing] = React.useState(false);
  // .review-stage has NO aspect-ratio of its own — ReviewDetail sets it inline
  // from the real video dimensions. Without it the stage collapses to zero
  // height and the absolutely-positioned <video> vanishes.
  const [stageAspect, setStageAspect] = React.useState("16 / 9");

  React.useEffect(() => {
    // Third argument is new and optional: { shotId, focusId }. Older callers
    // (TodoPage) pass two arguments and still work exactly as before.
    const fn = (id, t, opts) => {
      setOpen({ id, timecode: t, shotId: (opts && opts.shotId) || null, focusId: (opts && opts.focusId) || null });
      setReview(null); setReplyTo(null); setReplyBody(""); setOnlyShot(true);
      setCurrentTime(0); setDuration(0); setIsPlaying(false);
    };
    window.__openReview = fn;
    const onKey = (e) => { if (e.key === "Escape") setOpen(null); };
    window.addEventListener("keydown", onKey);
    return () => { if (window.__openReview === fn) delete window.__openReview; window.removeEventListener("keydown", onKey); };
  }, []);

  const refresh = React.useCallback(() => {
    if (!open) return;
    fetcher(`/api/video-reviews/${open.id}`).then(r => r.ok ? r.json() : null).then(d => {
      // v07zz224 — the endpoint returns { review, comments } (nested). Flatten it
      // so review.play_url / .file_path / .poster / .title / .comments resolve.
      if (d && d.review) setReview({ ...d.review, comments: d.comments || [] });
      else setReview(d || null);
    }).catch(() => setReview(null));
  }, [open]);
  React.useEffect(() => { refresh(); }, [refresh]);

  // Drag-to-scrub, lifted from the Review page so the bar behaves identically.
  const seekFromEvent = React.useCallback((e) => {
    if (!progressRef.current || !duration || !videoRef.current) return;
    const rect = progressRef.current.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
    const t = ratio * duration;
    try { videoRef.current.currentTime = t; } catch (_) {}
    setCurrentTime(t);
  }, [duration]);
  React.useEffect(() => {
    if (!scrubbing) return;
    const onMove = (e) => seekFromEvent(e);
    const onUp = () => setScrubbing(false);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    window.addEventListener("touchmove", onMove);
    window.addEventListener("touchend", onUp);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend", onUp);
    };
  }, [scrubbing, seekFromEvent]);

  // v1071 — hooks must run on EVERY render. useFollowTimecode used to sit below the
  // `if (!open) return null` early return, so the render that OPENED the popup called more
  // hooks than the closed render before it and React threw #310: every popup open (To-Do,
  // Edit Plan, a shot's review notes) dropped the whole app to the error screen. The follow
  // list is built here, null-safe; the render below still builds its own shownTops.
  const _followTops = ((review && review.comments) || [])
    .filter(c => !c.parent_comment_id)
    .filter(c => !(open && open.shotId && onlyShot)
      || (/^SH\d{4}$/.test(String(c.shot_id_detected || "")) ? c.shot_id_detected : null) === open.shotId)
    .sort((a, b) => (a.timecode_seconds || 0) - (b.timecode_seconds || 0));
  const _followNowId = useFollowTimecode(".grm-notes", _followTops, currentTime, !!open && followTc);
  // v1076 — the popup player reconnects by itself too. Above the early return (hook rule).
  const videoHeal = useVideoHeal(videoRef, open && review ? (review.play_url || review.file_path) : null);

  if (!open) return null;
  const close = () => setOpen(null);
  const src = review && (review.play_url || review.file_path);
  const all = (review && review.comments) || [];
  const canComment = !window.hasPerm || window.hasPerm("comment_on_shots");

  const seekTo = (t) => {
    const v = videoRef.current;
    if (v != null && t != null) { try { v.currentTime = Number(t) || 0; v.play && v.play().catch(() => {}); } catch (_) {} }
  };
  const togglePlay = () => { const v = videoRef.current; if (!v) return; if (videoHeal.retry()) return; if (v.paused) v.play(); else v.pause(); };

  // v1012 — when the popup was opened from a note, show ONLY that shot's notes.
  // Replies are never filtered out from under a parent that survives.
  const shotOf = (c) => (/^SH\d{4}$/.test(String(c.shot_id_detected || "")) ? c.shot_id_detected : null);
  const filtering = !!(open.shotId && onlyShot);
  const tops = all.filter(c => !c.parent_comment_id);
  const shownTops = (filtering ? tops.filter(c => shotOf(c) === open.shotId) : tops)
    .sort((a, b) => (a.timecode_seconds || 0) - (b.timecode_seconds || 0));
  const repliesByParent = all.reduce((acc, c) => {
    if (c.parent_comment_id) (acc[c.parent_comment_id] = acc[c.parent_comment_id] || []).push(c);
    return acc;
  }, {});
  const hiddenCount = tops.length - shownTops.length;
  // Markers follow whatever the list is showing, so the timeline matches it.
  const markerSet = shownTops;

  const resolveComment = (cid) => {
    if (busy[cid]) return;
    setBusy(b => ({ ...b, [cid]: true }));
    fetcher(`/api/video-comments/${cid}/resolve`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ resolved: true }) })
      .then(r => { if (r.ok) { setReview(rv => rv ? { ...rv, comments: (rv.comments || []).map(c => c.id === cid ? { ...c, resolved: 1 } : c) } : rv); try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "review_changed" } })); } catch (_) {} } })
      .catch(() => {}).finally(() => setBusy(b => { const n = { ...b }; delete n[cid]; return n; }));
  };

  const submitReply = (parentId, parentTC) => {
    if (!replyBody.trim()) return;
    fetcher("/api/video-comments", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ video_review_id: open.id, parent_comment_id: parentId, timecode_seconds: parentTC, body: replyBody.trim() }),
    })
      .then(r => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
      .then(() => {
        setReplyTo(null); setReplyBody(""); refresh();
        try { window.dispatchEvent(new CustomEvent("paradise-sse", { detail: { type: "review_changed" } })); } catch (_) {}
      })
      .catch(() => {});
  };

  const openFull = () => {
    try {
      window.__pendingReviewId = open.id;
      if (open.timecode != null) window.__pendingReviewSeek = { id: open.id, t: Number(open.timecode) || 0 };
      ((window.__nav && window.__nav.setView) || window.__navigate)("review");
    } catch (_) {}
    close();
  };

  // (v1071 — the follow hook runs above the early return; see _followTops.)

  const commentRow = (c, isReply) => (
    <li key={c.id} data-cid={isReply ? undefined : c.id}
      className={"grm-note" + (isReply ? " is-reply" : "") + (c.resolved ? " is-resolved" : "")
      + (c.id === open.focusId ? " is-focus" : "")
      + (!isReply && c.id === _followNowId ? " is-current" : "")}>
      <span className="grm-avatar" style={{ background: reviewGradient(c.user_name || "system") }}>
        {(c.user_name || "??").slice(0, 2).toUpperCase()}
      </span>
      <div className="grm-note-body">
        <div className="grm-note-meta">
          <span className="grm-note-author">{c.user_name || "system"}</span>
          {!isReply && (
            <button type="button" className="grm-seek" onClick={() => seekTo(c.timecode_seconds)}
              title="Play from here">{fmtTC(c.timecode_seconds)}</button>
          )}
          {!isReply && shotOf(c) && (
            <button type="button" className="grm-shot"
              onClick={() => { if (window.__nav && window.__nav.openShot) window.__nav.openShot(shotOf(c)); }}
              title={"Open " + shotOf(c)}>{shotOf(c)}</button>
          )}
          <span className="grm-note-date">{fmtRelative(c.created_at)}</span>
          {c.resolved ? <span className="grm-note-tag">resolved</span> : null}
        </div>
        <div className="grm-note-text">{c.body}</div>
        {!isReply && (
          <div className="grm-note-actions">
            {canComment && (
              <button type="button" className="grm-act"
                onClick={() => { setReplyTo(replyTo === c.id ? null : c.id); setReplyBody(""); }}>
                {replyTo === c.id ? "Cancel" : "Reply"}
              </button>
            )}
            {!c.resolved && (
              <button type="button" className="grm-act grm-act--resolve" disabled={!!busy[c.id]}
                onClick={() => resolveComment(c.id)}>{busy[c.id] ? "…" : "✓ Resolve"}</button>
            )}
          </div>
        )}
      </div>
    </li>
  );

  return ReactDOM.createPortal(
    <div className="grm-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }}>
      <div className="grm-modal" role="dialog" aria-modal="true">
        <button className="grm-close" aria-label="Close" onClick={close}>×</button>
        <div className="grm-head">
          <div className="grm-eyebrow">REVIEW{open.shotId ? " · " + open.shotId : ""}</div>
          <div className="grm-title">{review ? (review.title || ("Review #" + open.id)) : "Loading…"}</div>
        </div>

        {/* .review-stage is position:relative and the .review-* transport classes
            are global, so the Review page's own player drops straight in here. */}
        <div ref={stageRef} className="review-stage grm-stage" style={{ aspectRatio: stageAspect }}>
          {src
            ? <video ref={videoRef} className="review-video" src={src}
                poster={(review && review.poster) || undefined} playsInline preload="auto"
                onClick={togglePlay}
                onPlay={() => setIsPlaying(true)}
                onPause={() => setIsPlaying(false)}
                onTimeUpdate={(e) => { if (!videoHeal.isReloading()) setCurrentTime(e.currentTarget.currentTime || 0); }}
                onLoadedMetadata={(e) => {
                  const v = e.currentTarget;
                  setDuration(v.duration || 0);
                  if (v.videoWidth && v.videoHeight) setStageAspect(v.videoWidth + " / " + v.videoHeight);
                  try { v.volume = volume; } catch (_) {}
                  if (open.timecode != null && !videoHeal.isReloading()) seekTo(open.timecode);
                }}/>
            : <div className="grm-video-ph">{review === null ? "Loading…" : "No video for this review."}</div>}
          {src && (!isPlaying || videoHeal.status === "lost") && (
            <button type="button" className="review-stage-play" onClick={togglePlay} aria-label="Play">
              <svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>
            </button>
          )}
          {src && videoHeal.status && (
            <div className="review-stage-heal" role="status">
              {videoHeal.status === "lost" ? "Video lost — press play to retry" : "Reconnecting the video…"}
            </div>
          )}
        </div>

        {/* The transport sits UNDER the picture, so nothing covers the burned-in
            shot number along the bottom of the frame. */}
        <div className="review-toolbar grm-toolbar">
          <div className="review-toolbar-half">
            <ReviewSkip videoRef={videoRef} duration={duration} onSeek={setCurrentTime}/>
            <button type="button" className="review-toolbar-play" onClick={togglePlay} aria-label={isPlaying ? "Pause" : "Play"}>
              {isPlaying
                ? <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="6" y="5" width="4" height="14"/><rect x="14" y="5" width="4" height="14"/></svg>
                : <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M7 5v14l12-7z"/></svg>}
            </button>
            <ReviewSkip videoRef={videoRef} duration={duration} onSeek={setCurrentTime} side="fwd"/>
            <div className="review-tc">{fmtTC(currentTime)}<span className="review-tc-sep">/</span>{fmtTC(duration)}</div>
            <div ref={progressRef} className={"review-progress" + (scrubbing ? " is-scrubbing" : "")}
              onMouseDown={(e) => { if (e.button !== 0) return; e.preventDefault(); setScrubbing(true); seekFromEvent(e); }}
              onTouchStart={(e) => { setScrubbing(true); seekFromEvent(e); }}
              role="slider" aria-valuemin={0} aria-valuemax={duration || 0} aria-valuenow={currentTime || 0}
              title="Click or drag to scrub">
              <div className="review-progress-fill" style={{
                width: duration ? `${(currentTime / duration) * 100}%` : "0%",
                "--review-fill-pct": duration ? `${Math.max(0.5, (currentTime / duration) * 100)}` : "100",
              }}/>
              <span className="review-progress-handle" style={{ left: duration ? `${(currentTime / duration) * 100}%` : "0%" }}/>
              {markerSet.map(c => (
                <span key={c.id}
                  className={"review-marker" + (c.resolved ? " is-resolved" : "")
                    + (c.id === _followNowId ? " is-current" : "")}
                  style={{ left: duration ? `${(c.timecode_seconds / duration) * 100}%` : 0 }}
                  title={`${fmtTC(c.timecode_seconds)} · ${(c.body || "").slice(0, 80)}`}
                  onMouseDown={(e) => e.stopPropagation()}
                  onClick={(e) => { e.stopPropagation(); seekTo(c.timecode_seconds); }}/>
              ))}
            </div>
            <input type="range" min={0} max={1} step={0.01} className="review-volume" value={volume}
              onChange={(e) => {
                const v = parseFloat(e.target.value);
                setVolume(v);
                if (videoRef.current) videoRef.current.volume = v;
              }} title="Volume"/>
            <button type="button" className="review-toolbar-fs" aria-label="Fullscreen" title="Fullscreen"
              onClick={() => {
                const el = stageRef.current; if (!el) return;
                if (document.fullscreenElement) document.exitFullscreen();
                else el.requestFullscreen && el.requestFullscreen();
              }}>
              <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg>
            </button>
          </div>
        </div>

        {open.shotId && (
          <div className="grm-scope">
            <button type="button" className={"grm-scope-btn" + (onlyShot ? " is-on" : "")} onClick={() => setOnlyShot(true)}>
              Only {open.shotId}<span className="grm-scope-n">{tops.filter(c => shotOf(c) === open.shotId).length}</span>
            </button>
            <button type="button" className={"grm-scope-btn" + (!onlyShot ? " is-on" : "")} onClick={() => setOnlyShot(false)}>
              Whole edit<span className="grm-scope-n">{tops.length}</span>
            </button>
            {filtering && hiddenCount > 0 && <span className="grm-scope-note">{hiddenCount} other notes hidden</span>}
            <button type="button" className={"grm-scope-btn grm-follow" + (followTc ? " is-on" : "")}
              title={followTc ? "The list is following the video — click to stop it" : "Keep the note you are watching in view"}
              onClick={() => setFollowTc(v => !v)}>Follow</button>
          </div>
        )}
        {!open.shotId && (
          <div className="grm-scope">
            <button type="button" className={"grm-scope-btn grm-follow" + (followTc ? " is-on" : "")}
              title={followTc ? "The list is following the video — click to stop it" : "Keep the note you are watching in view"}
              onClick={() => setFollowTc(v => !v)}>Follow the video</button>
          </div>
        )}

        <ul className="grm-notes" ref={notesScrollRef}>
          {shownTops.length === 0
            ? <li className="grm-empty">{open.shotId && onlyShot ? "No notes on " + open.shotId + "." : "No notes on this review."}</li>
            : shownTops.map(c => (
              <React.Fragment key={c.id}>
                {commentRow(c, false)}
                {(repliesByParent[c.id] || []).length > 0 && (
                  <li className="grm-thread">
                    <ul className="grm-replies">
                      {(repliesByParent[c.id] || []).map(r => commentRow(r, true))}
                    </ul>
                  </li>
                )}
                {canComment && replyTo === c.id && (
                  <li className="grm-thread">
                    <form className="grm-reply-form" onSubmit={(e) => { e.preventDefault(); submitReply(c.id, c.timecode_seconds); }}>
                      <textarea className="grm-reply-input" rows={2} autoFocus
                        placeholder={`Reply to ${c.user_name || "this note"}…`}
                        value={replyBody} onChange={(e) => setReplyBody(e.target.value)}/>
                      <div className="grm-reply-actions">
                        <button type="button" className="grm-act" onClick={() => { setReplyTo(null); setReplyBody(""); }}>Cancel</button>
                        <button type="submit" className="grm-act grm-act--resolve" disabled={!replyBody.trim()}>Reply</button>
                      </div>
                    </form>
                  </li>
                )}
              </React.Fragment>
            ))}
        </ul>

        <div className="grm-foot"><button type="button" className="grm-openfull" onClick={openFull}>Open full review →</button></div>
      </div>
    </div>,
    document.getElementById("modal-root") || document.body
  );
}

Object.assign(window, { ReviewPage, GlobalReviewModal });
