// ===========================================================
// Community (11 Oct 2026): the activity feed and the collectors grid.
//
// /community             the feed (the landing tab)
// /community/collectors  one tile per collector
// /leaderboards          the boards, unchanged, wearing the same three tabs
//
// Public: works signed out, like the leaderboards. Both tabs read ONE jsonb row
// each (community_feed, community_collectors; their current bodies are in
// supabase/migrations/community_qa_fixes.sql), so PostgREST's 1,000-row cap
// never applies.
//
// THE FEED IS DERIVED, NOT STORED. A post is a collector's day of adds, a day's
// pressings that moved up a rarity tier and held, or a new weekly dive. Each has
// a stable `key`, which is what a like attaches to (a shelf's is shelf:<user>).
//
// IDENTITY. The server withholds display_name and member_since for an anonymous
// collector; lbLabel() is the one place a name is decided, here as on the
// boards. Nothing on this page may say or imply when an anonymous collector
// joined: "started a shelf" and "Founding member" are for named collectors only.
//
// Reworked 12 Oct 2026 after a QA of the first day (two independent reviews and
// live testing). What each change answers is said where it is made.
//
// Globals in: React, BBR_supabase, Sleeve, lbLabel, lbFetchCover,
// collectorTierName, bbrDiveFor, BBR_track, __nav, bbrOverlayPortal,
// CollectorBadge, __bbrRequireAuth. Exposes window.Community, CommunityTabs,
// CommunityNavBadge, CommunityProfileHero (the top of a shelf page) and CmtyAvatar.
// ===========================================================
const { useState: useCmState, useEffect: useCmEffect, useMemo: useCmMemo, useRef: useCmRef } = React;

const CMTY_PAGE = 30;
const CMTY_POLL_MS = 60000;
const CMTY_LOAD_ERR = "We couldn't load this just now. Refresh in a moment and it should be back.";
const CMTY_TIER_LABEL = { common: "Common", scarce: "Scarce", rare: "Rare", grail: "Grail" };
// Launch month. A named collector who joined in it is a founding member.
const CMTY_FOUNDING_BEFORE = "2026-07-01";

function cmtyGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }
function cmtyNum(n) { return (Number(n) || 0).toLocaleString("en-GB"); }
function cmtyPlural(n, one, many) { return cmtyNum(n) + " " + (Number(n) === 1 ? one : (many || one + "s")); }
function cmtyName(actor, meId) {
  const me = !!(meId && actor && actor.user_id === meId);
  return window.lbLabel ? window.lbLabel(actor, me) : (actor && actor.display_name) || "Collector";
}
function cmtyInitials(actor) {
  const s = actor && actor.is_public ? (actor.display_name || "") : (actor && actor.anon_alias) || "";
  // By code point, and from letters and digits where a name has any: a name
  // that opens with an emoji gave half a surrogate pair, drawn as a broken
  // glyph, and one in quotes or brackets gave the punctuation.
  const lets = (w) => Array.from(w).filter((c) => /[\p{L}\p{N}]/u.test(c));
  const words = s.trim().split(/\s+/).map(lets).filter((l) => l.length);
  if (!words.length) return Array.from(s.trim())[0] || "?";
  return (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2).join("")).toUpperCase();
}
function cmtySince(iso) {
  const d = String(iso).slice(0, 10).split("-");
  const mo = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(d[1], 10) - 1] || "";
  return mo + " " + d[0];
}
function cmtyLevel(count) { return window.collectorTierName ? window.collectorTierName(Number(count) || 0) : ""; }
function cmtyTier(t) { return CMTY_TIER_LABEL[t] || t; }

// Days are the reader's own: a post from late last night reads "Yesterday".
function cmtyDayKey(iso) { const d = new Date(iso); return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); }
function cmtyDayLabel(iso) {
  const d = new Date(iso), now = new Date();
  const start = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
  const diff = Math.round((start(now) - start(d)) / 86400000);
  const date = d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: d.getFullYear() === now.getFullYear() ? undefined : "numeric" });
  if (diff === 0) return "Today · " + date;
  if (diff === 1) return "Yesterday · " + date;
  return d.toLocaleDateString("en-GB", { weekday: "long" }) + " · " + date;
}
function cmtyAgo(iso) {
  if (!iso) return "a while ago";
  const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
  if (days < 1) return "today";
  if (days < 2) return "yesterday";
  if (days < 14) return days + " days ago";
  if (days < 60) return Math.floor(days / 7) + " weeks ago";
  return Math.floor(days / 30) + " months ago";
}

// "12 min ago", "3 hours ago", then the clock time on older days.
function cmtyWhen(iso) {
  const d = new Date(iso), mins = Math.floor((Date.now() - d.getTime()) / 60000);
  if (mins < 1) return "just now";
  if (mins < 60) return mins + " min ago";
  if (mins < 60 * 24) { const h = Math.floor(mins / 60); return h + (h === 1 ? " hour ago" : " hours ago"); }
  return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
// One colour per genre, the same on every tile, so a shelf's mix reads at a
// glance and two shelves can be compared without reading a word. Every genre
// canonical_genre() produces has its own: four real ones had none and drew as
// the same grey side by side (QA, 12 Oct 2026).
const CMTY_GENRE_HUE = {
  "Rock": "#b4472e", "Electronic": "#2f5d8a", "Pop": "#c58a1c", "Jazz": "#3d6b52", "Soul / R&B": "#7a3f6b",
  "Hip-Hop": "#2b2b2b", "Folk / Country": "#8a6a3a", "Stage & Screen": "#5a6b7a", "Classical": "#6b5a8a",
  "Reggae": "#4f7a2f", "Reggae / Dub": "#4f7a2f", "Blues": "#35507a", "Latin": "#a5552b",
  "Punk / Indie": "#a23a6a", "Metal": "#4a4a55", "Children's": "#d0a23c", "Brass & Military": "#7a5a2a",
  "Krautrock": "#2f7a78", "Ambient": "#7f93a8",
};
function cmtyGenreHue(g) { return CMTY_GENRE_HUE[g] || "#8a8072"; }

// ---- cover lookups, bounded ---------------------------------------------------
// A record with no cover from the server asks /api/cover, which costs one or
// two Discogs calls against a limit of about sixty a minute for the whole site,
// and a miss is cached for hours. The first version asked for every such record
// the moment its sleeve mounted: a cold visitor could fire 150 lookups before
// any answered, the Collectors tab 400, and each of the wall's records three
// times over (QA, 12 Oct 2026). Now a sleeve asks only once it is near the
// screen, at most four lookups are in flight, and lbFetchCover() shares one
// request between everything asking for the same record.
const CMTY_COVER_MAX = 4;
const cmtyCoverQ = { active: 0, wait: [] };
function cmtyCoverPump() {
  while (cmtyCoverQ.active < CMTY_COVER_MAX && cmtyCoverQ.wait.length) {
    const job = cmtyCoverQ.wait.shift();
    if (!job.live()) continue;                       // its sleeve has gone
    cmtyCoverQ.active++;
    Promise.resolve(window.lbFetchCover(job.item)).then((u) => u, () => null).then((u) => {
      cmtyCoverQ.active--;
      job.done(u);
      cmtyCoverPump();
    });
  }
}
function cmtyCoverAsk(item, live, done) { cmtyCoverQ.wait.push({ item, live, done }); cmtyCoverPump(); }

let cmtyIO = null;
const cmtyIOCbs = new WeakMap();
// Run cb once, when el first comes within a screen-and-a-bit of the viewport.
function cmtyWhenVisible(el, cb) {
  if (!("IntersectionObserver" in window)) { cb(); return () => {}; }
  if (!cmtyIO) {
    cmtyIO = new IntersectionObserver((entries) => {
      entries.forEach((en) => {
        if (!en.isIntersecting) return;
        const f = cmtyIOCbs.get(en.target);
        cmtyIOCbs.delete(en.target);
        cmtyIO.unobserve(en.target);
        if (f) f();
      });
    }, { rootMargin: "300px 0px" });
  }
  cmtyIOCbs.set(el, cb);
  cmtyIO.observe(el);
  return () => { cmtyIOCbs.delete(el); cmtyIO.unobserve(el); };
}

// A record's cover as a URL, looked up only when it has to be (see above): a
// dive has its own file, the server sends a cached cover where it has one, and
// anything else waits until the element is near the screen. Returns [url, ref];
// the ref goes on whatever box the picture sits in.
function useCmtyCover(item) {
  const known = item ? (item.slug ? "/covers/" + item.slug + ".jpg" : (item.cover || null)) : null;
  const [url, setUrl] = useCmState(known);
  const ref = useCmRef(null);
  useCmEffect(() => {
    setUrl(known);
    if (!item || known || !window.lbFetchCover || !ref.current) return;
    let live = true;
    const stop = cmtyWhenVisible(ref.current, () => cmtyCoverAsk(item, () => live, (u) => { if (live && u) setUrl(u); }));
    return () => { live = false; stop(); };
  }, [known, item && item.artist, item && item.title]);
  return [url, ref];
}

// A sleeve for a record the feed names. The span is the thing the observer watches.
function CmtySleeve({ item, size }) {
  const [url, ref] = useCmtyCover(item);
  const album = { title: item.title, artist: item.artist, year: item.year, rank: -1, slug: item.slug || null };
  if (url) album.coverUrl = url;
  return <span className="cmty-sv" ref={ref}>{window.Sleeve ? <Sleeve album={album} size={size} /> : null}</span>;
}

// ---- avatars ----------------------------------------------------------------
// A collector's picture is a record from their own shelf, or a drawn record
// label in a colour of their choosing with their initials (12 Oct 2026). Never
// an upload. The server decides which (community_avatar() in
// supabase/migrations/community_profile.sql): what they chose, else the first
// of their front-ten picks, else their most valuable record, else nothing, and
// then the initials stand alone as they always did.
//
// Twelve colours, in the order of the genres they are suggested for. The same
// hues as the genre bars, so a jazz collector's label and the jazz in their
// mix are one colour.
const CMTY_LABEL_HUES = ["#b4472e", "#2f5d8a", "#c58a1c", "#3d6b52", "#7a3f6b", "#2b2b2b", "#8a6a3a", "#5a6b7a", "#6b5a8a", "#4f7a2f", "#35507a", "#a23a6a"];
const CMTY_LABEL_GENRES = ["Rock", "Electronic", "Pop", "Jazz", "Soul / R&B", "Hip-Hop", "Folk / Country", "Stage & Screen", "Classical", "Reggae", "Blues", "Punk / Indie"];
const CMTY_LABEL_NAMES = ["Ring", "Split", "Grooves", "Sunburst", "Stripe", "Rim"];
function cmtyHueForGenre(g) { const i = CMTY_LABEL_GENRES.indexOf(g); return i < 0 ? 0 : i; }

// The label itself, drawn. Six layouts, none of them any real label's.
function CmtyLabelArt({ pattern, hue, text }) {
  const c = CMTY_LABEL_HUES[Number(hue)] || CMTY_LABEL_HUES[0];
  const cream = "#f3ede1", ink = "#17130f";
  const p = Number(pattern) || 1;
  // One id per drawing: the same label twice on a page (the chooser over the
  // header) shared an id, and a clip path that resolves to a hidden twin does
  // not clip.
  const rid = React.useId ? React.useId() : "";
  const id = "cl" + p + "-" + (Number(hue) || 0) + "-" + rid.replace(/[^a-zA-Z0-9]/g, "");
  let art, fg = cream;
  if (p === 2) {
    art = <React.Fragment><rect width="100" height="100" fill={cream} /><rect width="100" height="52" fill={c} />
      <rect x="20" y="64" width="60" height="2" fill={ink} opacity=".35" /><rect x="28" y="72" width="44" height="2" fill={ink} opacity=".25" /></React.Fragment>;
  } else if (p === 3) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      {[46, 38, 30].map((r) => <circle key={r} cx="50" cy="50" r={r} fill="none" stroke="#fff" strokeWidth="2.5" opacity=".28" />)}</React.Fragment>;
  } else if (p === 4) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      {Array.from({ length: 12 }, (_, i) => i).filter((i) => i % 2 === 0).map((i) => {
        const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
        return <path key={i} d={"M50 50 L" + (50 + 70 * Math.cos(a0)) + " " + (50 + 70 * Math.sin(a0)) + " L" + (50 + 70 * Math.cos(a1)) + " " + (50 + 70 * Math.sin(a1)) + " Z"} fill="#fff" opacity=".2" />;
      })}
      <circle cx="50" cy="50" r="24" fill={c} /></React.Fragment>;
  } else if (p === 5) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      <path d="M-10 78 L78 -10 L104 -10 L-10 104 Z" fill="#fff" opacity=".22" /><path d="M20 110 L110 20 L110 34 L34 110 Z" fill="#000" opacity=".16" /></React.Fragment>;
  } else if (p === 6) {
    fg = c;
    art = <React.Fragment><rect width="100" height="100" fill={cream} /><circle cx="50" cy="50" r="46" fill="none" stroke={c} strokeWidth="8" /></React.Fragment>;
  } else {
    art = <React.Fragment><rect width="100" height="100" fill={c} /><circle cx="50" cy="50" r="42" fill="none" stroke={cream} strokeWidth="1.6" opacity=".7" /></React.Fragment>;
  }
  const t = Array.from(String(text || "")).slice(0, 2);
  return (
    <svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden="true" focusable="false">
      <defs><clipPath id={id}><circle cx="50" cy="50" r="50" /></clipPath></defs>
      <g clipPath={"url(#" + id + ")"}>{art}</g>
      <text x="50" y={p === 2 ? 38 : 51} textAnchor="middle" dominantBaseline="middle" fill={fg}
        style={{ font: "400 " + (t.length > 1 ? 34 : 40) + "px 'DM Serif Display', Georgia, serif" }}>{t.join("")}</text>
    </svg>
  );
}

// A cover as a round picture. The initials are drawn first and the picture is
// laid over them once it has actually loaded, so a slow or missing image is a
// collector's initials, never an empty circle.
function CmtyCoverAvatar({ actor, item, style, className }) {
  const [url, ref] = useCmtyCover(item);
  const [state, setState] = useCmState("wait");      // wait | ok | bad
  useCmEffect(() => { setState("wait"); }, [url]);
  const ok = !!url && state === "ok";
  return (
    <span className={className + (ok ? " cover" : "") + (actor && actor.is_public ? "" : " anon")} style={style} ref={ref} aria-hidden="true">
      {cmtyInitials(actor)}
      {url && state !== "bad"
        ? <img src={url} alt="" loading="lazy" decoding="async" className={ok ? "on" : ""}
            ref={(el) => { if (el && el.complete && el.naturalWidth > 0 && state === "wait") setState("ok"); }}
            onLoad={() => setState("ok")} onError={() => setState("bad")} />
        : null}
    </span>
  );
}

// `avatar` overrides the actor's own (the picker previews a choice that way).
// `disc` draws it as the label of a record, for the top of a profile.
function CmtyAvatar({ actor, size, avatar, disc }) {
  const av = avatar !== undefined ? avatar : ((actor && actor.avatar) || null);
  if (disc) {
    const s = size || 112;
    return (
      <span className="cmty-disc" style={{ width: s, height: s }} aria-hidden="true">
        <CmtyAvatar actor={actor} avatar={av} size={Math.round(s * 0.64)} />
        <i />
      </span>
    );
  }
  const style = size ? { width: size, height: size, fontSize: Math.round(size * 0.34) } : undefined;
  if (av && av.kind === "label") {
    return <span className="cmty-av label" style={style} aria-hidden="true"><CmtyLabelArt pattern={av.pattern} hue={av.hue} text={cmtyInitials(actor)} /></span>;
  }
  if (av && av.kind === "cover") return <CmtyCoverAvatar actor={actor} item={av} style={style} className="cmty-av" />;
  return (
    <span className={"cmty-av" + (actor && actor.is_public ? "" : " anon")} style={style} aria-hidden="true">
      {cmtyInitials(actor)}
    </span>
  );
}


// ---- likes and follows ------------------------------------------------------
// Counts and "did I" arrive with the feed and the tiles. A tap is applied at
// once and then settled by what the server says; a refusal puts it back. To
// like or follow you need an account with a record in it (community_actor() in
// the migration): signed out opens the sign-up gate, and an empty shelf gets a
// line pointing at /collection.
const CMTY_SOCIAL_ERR = {
  need_record: "Add your first record to like and follow.",
  not_yourself: "That one’s yours.",
  slow_down: "That’s a lot for one day. Try again tomorrow.",
  not_allowed: "This account can’t like or follow.",
  no_such_collector: "That shelf has no records on it any more.",
};
// What the reader has liked and followed this session, kept outside the
// component so it survives opening a shelf and coming back (the page unmounts).
// `over` is what the buttons show; `sure` is the server's last answer per
// target, which is what a failed tap falls back to.
const cmtySocKeep = { meId: null, over: { like: {}, follow: {} }, sure: { like: {}, follow: {} } };
function useCmtySocial(meId, authReady, onRequireAuth) {
  const [over, setOverState] = useCmState(cmtySocKeep.meId === (meId || null) ? cmtySocKeep.over : { like: {}, follow: {} });
  const [note, setNote] = useCmState(null);
  // The kept store is written first and the page second. A reply that lands
  // after the reader has left (a refusal, say) used to be lost with the page,
  // because an update to an unmounted component never runs: the feed they went
  // back to kept the optimistic "Following". And not for a different reader.
  const setOver = (fn) => {
    if (typeof fn === "function" && cmtySocKeep.meId !== (meId || null)) return;
    const n = typeof fn === "function" ? fn(cmtySocKeep.over) : fn;
    cmtySocKeep.over = n; setOverState(n);
  };
  // A different reader means different "did I" flags; the refetch brings them.
  // Not before the session has been read: a shelf opened mid-session mounts
  // with no reader yet, and treating that as "signed out" wiped what they had
  // just liked, so the feed they went back to showed it undone. Once auth
  // answers with the same reader, pick the kept flags back up.
  useCmEffect(() => {
    if (authReady === false) return;
    if (cmtySocKeep.meId === (meId || null)) { setOverState(cmtySocKeep.over); return; }
    cmtySocKeep.meId = meId || null;
    cmtySocKeep.sure = { like: {}, follow: {} };
    setOver({ like: {}, follow: {} }); setNote(null);
  }, [meId, authReady]);
  // The refusal line clears itself; it is a toast now, not a paragraph in the page.
  useCmEffect(() => {
    if (!note) return;
    const t = setTimeout(() => setNote(null), 8000);
    return () => clearTimeout(t);
  }, [note]);
  const put = (kind, k, v) => setOver((o) => ({ ...o, [kind]: { ...o[kind], [k]: v } }));
  const drop = (kind, k) => setOver((o) => { const m = { ...o[kind] }; delete m[k]; return { ...o, [kind]: m }; });
  // TAPS RACE. Like then unlike in quick succession sends two requests; if the
  // first answer lands last it used to repaint the button "liked" over a server
  // that had ended on "not liked". Only the newest tap for a target may settle
  // its button. And a REFUSED tap goes back to what the server last confirmed,
  // not to what the button showed when it was tapped: two quick taps by a member
  // with no records were both refused, and the second "rollback" restored the
  // first tap's optimistic "liked" (QA, 11-12 Oct 2026).
  const seq = useCmRef({});
  const next = (k) => (seq.current[k] = (seq.current[k] || 0) + 1);
  const latest = (k, n) => seq.current[k] === n;
  const settle = (kind, k, v) => { cmtySocKeep.sure[kind][k] = v; put(kind, k, v); };
  const undo = (kind, k) => { const v = cmtySocKeep.sure[kind][k]; if (v) put(kind, k, v); else drop(kind, k); };
  const fail = (e) => {
    const code = String((e && e.message) || "");
    setNote({ code, text: CMTY_SOCIAL_ERR[code] || "That didn’t save. Try again in a moment." });
  };
  const call = (fn, args) => Promise.resolve(window.BBR_supabase.rpc(fn, args)).then((out) => {
    if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
    return out.data;
  });
  // Signed out opens the gate, but NOT while the session is still being read: a
  // member who tapped in that first moment was asked to create the account they
  // already have. Until auth has answered, a tap does nothing.
  const gate = () => { if (authReady === false) return; onRequireAuth && onRequireAuth("community"); };
  // What a button shows. The page's own row (`base`) is the fresher count once
  // it agrees with the reader's last action; until then the action wins.
  const likeOf = (key, base) => {
    const b = { liked: !!(base && base.liked), likes: Number(base && base.likes) || 0 };
    const o = over.like[key];
    return o && o.liked !== b.liked ? o : b;
  };
  const followOf = (uid, base) => {
    const b = { following: !!(base && base.following), followers: Number(base && base.followers) || 0 };
    const o = over.follow[uid];
    return o && o.following !== b.following ? o : b;
  };
  return {
    meId, note, clearNote: () => setNote(null), likeOf, followOf, gate,
    toggleLike(key, base) {
      if (!meId) { gate(); return; }
      const cur = likeOf(key, base), on = !cur.liked;
      put("like", key, { liked: on, likes: Math.max(0, cur.likes + (on ? 1 : -1)) });
      setNote(null);
      window.BBR_track && window.BBR_track("community_like", { on: on ? 1 : 0, kind: key.split(":")[0] });
      const n = next("l:" + key);
      call("community_like", { p_target: key, p_on: on }).then(
        (d) => { if (latest("l:" + key, n)) settle("like", key, { liked: !!d.liked, likes: Number(d.likes) || 0 }); },
        (e) => { if (latest("l:" + key, n)) { undo("like", key); fail(e); } });
    },
    toggleFollow(uid, base) {
      if (!meId) { gate(); return; }
      const cur = followOf(uid, base), on = !cur.following;
      put("follow", uid, { following: on, followers: Math.max(0, cur.followers + (on ? 1 : -1)) });
      setNote(null);
      window.BBR_track && window.BBR_track("community_follow", { on: on ? 1 : 0 });
      const n = next("f:" + uid);
      call("community_follow", { p_user: uid, p_on: on }).then(
        (d) => { if (latest("f:" + uid, n)) settle("follow", uid, { following: !!d.following, followers: Number(d.followers) || 0 }); },
        (e) => { if (latest("f:" + uid, n)) { undo("follow", uid); fail(e); } });
    },
  };
}
// `mine` is the reader's own post or shelf: the count shows, the button doesn't.
// `what` names the thing for a screen reader ("Maya’s new records"): thirty
// buttons all called "Like" told nobody which was which, and flipping the name
// to "Unlike" as well as aria-pressed read as "Unlike, pressed".
function CmtyLike({ soc, target, base, mine, what }) {
  const st = soc.likeOf(target, base);
  if (mine) return st.likes ? <span className="cmty-like mine">♥ {cmtyNum(st.likes)}<span className="cmty-vh"> {st.likes === 1 ? "like" : "likes"}</span></span> : <span />;
  return (
    <button className={"cmty-like" + (st.liked ? " on" : "")} aria-pressed={st.liked}
      aria-label={"Like " + (what || "this") + (st.likes ? ", " + cmtyPlural(st.likes, "like") : "")}
      onClick={() => soc.toggleLike(target, base)}>
      <span aria-hidden="true">{st.liked ? "♥" : "♡"}</span> {st.likes ? cmtyNum(st.likes) : "Like"}
    </button>
  );
}
function CmtyFollow({ soc, user }) {
  if (!user || (soc.meId && user.user_id === soc.meId)) return null;
  const st = soc.followOf(user.user_id, user);
  return (
    <button className={"cmty-follow" + (st.following ? " on" : "")} aria-pressed={st.following}
      aria-label={"Follow " + cmtyName(user, null)}
      onClick={() => soc.toggleFollow(user.user_id, user)}>{st.following ? "Following" : "Follow"}</button>
  );
}
// One toast for the page, fixed to the bottom of the screen. It used to be a
// paragraph above the first post: a refusal twenty posts down showed a heart
// fill and empty with the reason several screens out of sight (QA, 12 Oct 2026).
function CmtySocialNote({ soc }) {
  if (!soc.note) return null;
  return (
    <p className="cmty-note" role="status">
      <span>{soc.note.text}</span>
      {soc.note.code === "need_record" && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goCollection()}>Add a record</button> : null}
      <button className="cmty-note-x" aria-label="Dismiss" onClick={soc.clearNote}>&times;</button>
    </p>
  );
}


// ---- what happened on your shelf --------------------------------------------
// community_inbox() for the signed-in reader, shared by the nav badge and the
// panel at the top of the feed. Opening the feed reads it AGAIN and marks seen
// only as far as it actually showed: it used to be read once per page load and
// marked seen with the server's clock, so a like that landed in between was
// marked seen without ever being shown, and (the email only sends what is
// unseen) was never emailed either (QA, 12 Oct 2026).
const cmtyInbox = { uid: null, data: null, subs: new Set(), req: 0, at: 0, failed: false };
function cmtyInboxSet(data) { cmtyInbox.data = data; cmtyInbox.subs.forEach((f) => f(data)); }
function cmtyInboxLoad(uid, again) {
  uid = uid || null;
  const same = cmtyInbox.uid === uid;
  if (same && !cmtyInbox.failed && !(again && Date.now() - cmtyInbox.at > 5000)) return;
  cmtyInbox.uid = uid;
  if (!same) cmtyInboxSet(null);
  if (!uid || !window.BBR_supabase) return;
  const n = ++cmtyInbox.req;
  cmtyInbox.at = Date.now();
  cmtyInbox.failed = false;
  Promise.resolve(window.BBR_supabase.rpc("community_inbox", {})).then((out) => {
    if (cmtyInbox.uid !== uid || n !== cmtyInbox.req) return;
    // A failed read is remembered so the next mount asks again, not never.
    if (!out || out.error || !out.data) { cmtyInbox.failed = true; return; }
    cmtyInboxSet({ unseen: Number(out.data.unseen) || 0, items: out.data.items || [] });
  }, () => { if (n === cmtyInbox.req) cmtyInbox.failed = true; });
}
function useCmtyInbox(uid) {
  const [data, setData] = useCmState(cmtyInbox.uid === (uid || null) ? cmtyInbox.data : null);
  useCmEffect(() => {
    cmtyInbox.subs.add(setData);
    cmtyInboxLoad(uid);
    setData(cmtyInbox.data);
    return () => { cmtyInbox.subs.delete(setData); };
  }, [uid]);
  return data;
}
// The count beside "Community" in the nav. Nothing at all when there is nothing new.
function CommunityNavBadge({ userId }) {
  const data = useCmtyInbox(userId || null);
  if (!data || !data.unseen) return null;
  return <span className="cmty-navbadge" aria-label={data.unseen + " new on your shelf"}>{data.unseen > 9 ? "9+" : data.unseen}</span>;
}
function cmtyInboxLine(it) {
  if (it.kind === "follow") return "started following you";
  const t = String(it.target || "");
  if (t.indexOf("add:") === 0) {
    const d = new Date(t.split(":")[2] + "T12:00:00");
    if (!isNaN(d)) return "liked the records you added on " + d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
  }
  return "liked your shelf";
}
function CmtyInboxPanel({ meId, onViewCollection }) {
  const data = useCmtyInbox(meId || null);
  const [all, setAll] = useCmState(false);
  useCmEffect(() => { if (meId) cmtyInboxLoad(meId, true); }, [meId]);
  // Mark seen once the panel has been on screen for a moment, not on fetch: a
  // reader who bounces straight off the page has not seen it. And only up to the
  // newest line this panel is showing.
  const newest = data && data.items[0] ? data.items[0].at : null;
  useCmEffect(() => {
    if (!data || !data.unseen || !newest || !window.BBR_supabase) return;
    const t = setTimeout(() => {
      Promise.resolve(window.BBR_supabase.rpc("community_inbox_seen", { p_upto: newest })).then((out) => {
        if (out && !out.error && cmtyInbox.data) cmtyInboxSet({ ...cmtyInbox.data, unseen: 0 });
      }, () => {});
    }, 1500);
    return () => clearTimeout(t);
  }, [data && data.unseen, newest]);
  if (!data || !data.items.length) return null;
  const rows = all ? data.items : data.items.slice(0, 4);
  return (
    <section className="cmty-inbox" aria-label="On your shelf">
      <h2>On your shelf</h2>
      {rows.map((it, i) => (
        <div className={"cmty-inbox-row" + (it.new ? " new" : "")} key={i}>
          <CmtyAvatar actor={it.actor} size={28} />
          <span><button className="cmty-name" onClick={() => onViewCollection(it.actor.user_id, cmtyName(it.actor, null))}>{cmtyName(it.actor, null)}</button> {cmtyInboxLine(it)}{it.new ? <span className="cmty-vh"> (new)</span> : null}</span>
          <span className="cmty-meta">{cmtyAgo(it.at)}</span>
        </div>
      ))}
      {!all && data.items.length > rows.length ? <button className="cmty-link" onClick={() => setAll(true)}>Show {data.items.length - rows.length} more</button> : null}
    </section>
  );
}

// The three tabs. Real links, so each is crawlable and cmd-clicks like the nav.
function CommunityTabs({ tab }) {
  const nav = window.__nav || {};
  const to = (href, fn) => ({
    href,
    onClick: (e) => {
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0 || !fn) return;
      e.preventDefault();
      fn();
    },
  });
  const cls = (k) => "cmty-tab" + (tab === k ? " active" : "");
  const cur = (k) => (tab === k ? "page" : undefined);
  return (
    <nav className="cmty-tabs" aria-label="Community">
      <a className={cls("feed")} aria-current={cur("feed")} {...to("/community", nav.goCommunity)}>Activity</a>
      <a className={cls("collectors")} aria-current={cur("collectors")} {...to("/community/collectors", nav.goCollectors)}>Collectors</a>
      <a className={cls("leaderboards")} aria-current={cur("leaderboards")} {...to("/leaderboards", nav.goLeaderboards)}>Leaderboards</a>
    </nav>
  );
}

function CmtyOwners({ owners, count, meId, lone }) {
  if (!count) return null;
  const list = owners || [];
  const text = count === 1 && list[0]
    ? (lone || "On") + " " + (list[0].user_id === meId ? "your" : cmtyName(list[0], null) + "’s") + " shelf"
    : cmtyPlural(count, "collector") + " own" + (count === 1 ? "s" : "") + " it";
  return (
    <div className="cmty-owners">
      <span className="cmty-owners-av">{list.map((o) => <CmtyAvatar key={o.user_id} actor={o} size={22} />)}</span>
      <span>{text}</span>
    </div>
  );
}

// "Scarce → Rare", with the same thing in words for a screen reader: the old
// tier was a strikethrough on a wide screen and not there at all on a phone.
function CmtyTierMove({ it }) {
  return (
    <span className="cmty-tiermove">
      <span className="cmty-vh">Moved from {cmtyTier(it.from)} to {cmtyTier(it.to)}</span>
      <span className="cmty-tier-from" aria-hidden="true">{cmtyTier(it.from)}</span>
      <span aria-hidden="true">&rarr;</span>
      <span className={"tier-chip tier-chip-" + it.to} aria-hidden="true">{cmtyTier(it.to)}</span>
    </span>
  );
}

function CmtyAddsPost({ ev, meId, soc, onViewCollection }) {
  const a = ev.actor || {};
  const name = cmtyName(a, meId);
  const items = ev.items || [];
  const open = () => onViewCollection(a.user_id, cmtyName(a, null));
  const level = ev.level ? cmtyLevel(ev.level) : null;
  let did;
  if (level) did = <React.Fragment>reached <b>{level}</b> with {cmtyPlural(ev.level, "record")}</React.Fragment>;
  else if (ev.first && a.is_public) did = <React.Fragment>started a shelf with {cmtyPlural(ev.n, "record")}</React.Fragment>;
  else did = <React.Fragment>added {ev.n === 1 ? "a record" : cmtyPlural(ev.n, "record")}</React.Fragment>;
  const more = ev.n - items.length;
  // The link is only offered for a record that really has a dive.
  const isDive = (slug) => !!(slug && window.bbrDiveFor && window.bbrDiveFor(slug));
  return (
    <article className={"cmty-post" + (level ? " milestone" : "")}>
      {level ? <div className="cmty-ribbon"><span aria-hidden="true">&#9733;</span> Level up · {level}</div> : null}
      <header className="cmty-who">
        <CmtyAvatar actor={a} />
        <div className="cmty-line">
          <button className="cmty-name" onClick={open}>{name}</button> {did}
          <div className="cmty-meta">
            {cmtyWhen(ev.at)} · {a.is_public ? "" : "Anonymous collector · "}{cmtyPlural(a.item_count, "record")} · {cmtyGbp(a.total_value)}
            {!level && cmtyLevel(a.item_count) ? " · " + cmtyLevel(a.item_count) : ""}
          </div>
        </div>
        <CmtyFollow soc={soc} user={a} />
      </header>
      {items.length === 1 ? (
        <div className="cmty-one big">
          <CmtySleeve item={items[0]} size={132} />
          <div>
            <div className="cmty-one-t">{items[0].title}</div>
            <div className="cmty-meta">{items[0].artist}{items[0].year ? " · " + items[0].year : ""}</div>
            {isDive(items[0].slug) && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goDeepDive(items[0].slug)}>Read the deep dive</button> : null}
          </div>
        </div>
      ) : items.length === 2 ? (
        <button className="cmty-mosaic two" onClick={open} aria-label={"Open " + cmtyName(a, null) + "’s shelf"}>
          {items.map((it, i) => <CmtySleeve key={i} item={it} size={136} />)}
        </button>
      ) : items.length ? (
        // The most valuable record of the day leads; the rest stack beside it.
        <button className="cmty-mosaic" onClick={open} aria-label={"Open " + cmtyName(a, null) + "’s shelf"}>
          <CmtySleeve item={items[0]} size={196} />
          <span className="cmty-mosaic-side">
            {items.slice(1, 4).map((it, i) => <CmtySleeve key={i} item={it} size={items.length === 3 ? 94 : 60} />)}
          </span>
        </button>
      ) : null}
      {items.length > 1 ? (
        // The count is its own box so it is never the part that gets cut: on a
        // phone "+8 more" was clipped off the end of the artists' names.
        <div className="cmty-mosaic-cap">
          <span className="cmty-cap-a">{items.slice(0, 3).map((it) => it.artist).filter(Boolean).join(" · ")}</span>
          {more > 0 ? <span className="cmty-more">+{cmtyNum(more)} more</span> : null}
        </div>
      ) : null}
      <footer className="cmty-acts">
        <CmtyLike soc={soc} target={ev.key} base={ev} mine={!!(meId && a.user_id === meId)} what={cmtyName(a, null) + "’s new records"} />
        <button className="cmty-link" onClick={open}>View shelf</button>
      </footer>
    </article>
  );
}

function CmtyTierPost({ ev, meId, soc }) {
  const items = ev.items || [];
  // One pressing is one line, not a card with a headline over a list of one:
  // three quiet market days in a row were pushing every collector off the screen.
  if (items.length === 1) {
    const it = items[0];
    return (
      <article className="cmty-post slim">
        <CmtySleeve item={it} size={56} />
        <div className="cmty-move-t">
          <div className="cmty-meta">Market move · held for three days</div>
          <div><b>{it.title}</b> <span className="cmty-meta">{it.artist}</span></div>
          <CmtyOwners owners={it.owners} count={it.owner_count} meId={meId} />
        </div>
        <div className="cmty-slim-end">
          <CmtyTierMove it={it} />
          <CmtyLike soc={soc} target={ev.key} base={ev} what={"this market move, " + it.title} />
        </div>
      </article>
    );
  }
  return (
    <article className="cmty-post">
      <header className="cmty-who">
        <span className="cmty-av sys" aria-hidden="true">&#8599;</span>
        <div className="cmty-line">
          <b>{cmtyPlural(items.length, "pressing")}</b> moved up a rarity tier
          <div className="cmty-meta">Market move · held for three days</div>
        </div>
      </header>
      <ul className="cmty-moves">
        {items.slice(0, 5).map((it, i) => (
          <li key={i}>
            <CmtySleeve item={it} size={48} />
            <div className="cmty-move-t">
              <div><b>{it.title}</b> <span className="cmty-meta">{it.artist}</span></div>
              <CmtyOwners owners={it.owners} count={it.owner_count} meId={meId} />
            </div>
            <CmtyTierMove it={it} />
          </li>
        ))}
      </ul>
      {items.length > 5 ? <div className="cmty-meta cmty-moves-more">and {items.length - 5} more</div> : null}
      <footer className="cmty-acts"><CmtyLike soc={soc} target={ev.key} base={ev} what="these market moves" /></footer>
    </article>
  );
}

function CmtyDivePost({ ev, meId, soc }) {
  const r = ev.record || {};
  const go = () => window.__nav && window.__nav.goDeepDive(r.slug);
  // "New" for a week. The feed runs back months, and a forty-day-old dive is not.
  const isNew = Date.now() - new Date(ev.at).getTime() < 7 * 86400000;
  return (
    <article className="cmty-post">
      <header className="cmty-who">
        <span className="cmty-av sys" aria-hidden="true">&sect;</span>
        <div className="cmty-line">
          <b>{isNew ? "New deep dive" : "Deep dive"}: {r.title}</b>
          <div className="cmty-meta">{r.artist}{r.year ? " · " + r.year : ""}</div>
        </div>
      </header>
      <div className="cmty-one big">
        <CmtySleeve item={r} size={132} />
        <div>
          {ev.excerpt ? <p className="cmty-excerpt">{ev.excerpt}</p> : null}
          <a className="cmty-link" href={"/deep-dive/" + encodeURIComponent(r.slug)} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); go(); }}>Read the dive</a>
        </div>
      </div>
      <CmtyOwners owners={ev.owners} count={ev.owner_count} meId={meId} lone="Already on" />
      <footer className="cmty-acts"><CmtyLike soc={soc} target={ev.key} base={ev} what={"the deep dive on " + r.title} /></footer>
    </article>
  );
}

function CmtyCount({ value }) {
  const n = Math.round(Number(value) || 0);
  return window.HdCountUp ? <window.HdCountUp value={n} /> : <React.Fragment>{n.toLocaleString("en-GB")}</React.Fragment>;
}
// FRESH ON THE SHELVES. The newest records across every shelf as one moving
// row of sleeves, drawn from the adds already loaded for the feed (no second
// read). The row is doubled so the scroll loops without a seam; it pauses under
// a pointer or a held finger, and under prefers-reduced-motion it does not move
// at all and scrolls by hand instead. The tiles are out of the tab order: they
// were 18 tab stops between the tabs and the filters, and every record on the
// wall is in the feed below with its own "View shelf".
function CmtyWall({ events, onViewCollection }) {
  const tiles = useCmMemo(() => {
    const out = [], seen = new Set();
    for (const ev of events || []) {
      if (ev.kind !== "adds") continue;
      for (const it of ev.items || []) {
        const k = (it.artist || "") + "|" + (it.title || "");
        if (seen.has(k)) continue;
        seen.add(k);
        out.push({ it, actor: ev.actor });
        if (out.length >= 18) return out;
      }
    }
    return out;
  }, [events]);
  if (tiles.length < 8) return null;
  const row = (dup) => tiles.map((t, i) => (
    <button className="cmty-wall-t" key={(dup ? "d" : "") + i} tabIndex={-1} aria-hidden={dup ? "true" : undefined}
      title={t.it.title + " · " + t.it.artist + " · on " + cmtyName(t.actor, null) + "’s shelf"}
      onClick={() => onViewCollection(t.actor.user_id, cmtyName(t.actor, null))}>
      <CmtySleeve item={t.it} size={104} />
      <span className="cmty-wall-c"><b>{t.it.artist}</b>{cmtyName(t.actor, null)}</span>
    </button>
  ));
  return (
    <section className="cmty-wall" aria-label="Fresh on the shelves">
      <h2 className="cmty-wall-h"><span className="cmty-pulse" aria-hidden="true" /> Fresh on the shelves</h2>
      <div className="cmty-wall-view"><div className="cmty-wall-track" style={{ "--n": tiles.length }}>{row(false)}{row(true)}</div></div>
    </section>
  );
}

// "following" is answered by the server (p_following); the rest filter what is loaded.
const CMTY_FILTERS = [["all", "Everything"], ["following", "Following"], ["adds", "Collectors"], ["tier", "Market"], ["dive", "Deep dives"]];

// THE FEED SURVIVES LEAVING IT. Opening a shelf, or the Collectors tab, unmounts
// this page; coming back used to mean a fresh first page at the top, the
// reader's place, filter and every "earlier activity" page gone, and the
// heaviest read on the site run again. What is loaded is kept here and put back
// when the page is re-entered within ten minutes by the same reader. The scroll
// position is put back too, but only for Back and Forward: a click on a tab or
// the nav means "take me there", which is the top.
const cmtyFeedKeep = { meId: null, filter: "all", events: null, wall: null, more: false, stats: null, y: 0, at: 0 };
let cmtyPoppedAt = 0;
window.addEventListener("popstate", () => { cmtyPoppedAt = Date.now(); });
function cmtyFeedRestorable(meId) {
  return !!cmtyFeedKeep.events && cmtyFeedKeep.meId === (meId || null) && Date.now() - cmtyFeedKeep.at < 10 * 60 * 1000;
}

function CmtyFeed({ meId, authReady, soc, onViewCollection, onRequireAuth, onStats }) {
  const restored = useCmRef(cmtyFeedRestorable(meId));
  const keep = restored.current ? cmtyFeedKeep : null;
  const [events, setEvents] = useCmState(keep ? keep.events : null);
  // The wall shows everyone's newest records whatever the filter says, so it is
  // fed from the last unfiltered page and does not blink out under the pointer
  // (and come back as "Fresh on the shelves" of only the people you follow).
  const [wall, setWall] = useCmState(keep ? keep.wall : null);
  const [more, setMore] = useCmState(keep ? keep.more : false);
  const [stats, setStats] = useCmState(keep ? keep.stats : null);
  const [err, setErr] = useCmState(false);
  const [busy, setBusy] = useCmState(false);
  const [swapping, setSwapping] = useCmState(false);
  const [filter, setFilter] = useCmState(keep ? keep.filter : "all");
  const [fresh, setFresh] = useCmState(false);
  // Auth has answered, or two and a half seconds have passed without it. The
  // first read waits for that so a signed-in page load reads the feed ONCE, as
  // the member; it used to read it signed-out and then again a moment later.
  const [waited, setWaited] = useCmState(authReady !== false);
  useCmEffect(() => {
    if (waited) return;
    if (authReady !== false) { setWaited(true); return; }
    const t = setTimeout(() => setWaited(true), 2500);
    return () => clearTimeout(t);
  }, [authReady, waited]);

  const following = filter === "following";
  // Which reader and filter the posts on screen belong to.
  const shownFor = useCmRef(keep ? { meId: keep.meId, following: keep.filter === "following" } : null);
  // ONLY THE NEWEST REQUEST MAY WRITE. Switching Following on and off quickly, or
  // signing in while the first read was in flight, left two requests racing, and
  // whichever answered LAST won: the page showed the Following feed under
  // "Everything", or lost the reader's likes to the signed-out answer. Every
  // load takes a number; an answer that is no longer the newest is dropped,
  // including a "show earlier" page for a feed that has since changed.
  const reqRef = useCmRef(0);
  const load = (after) => {
    const id = ++reqRef.current;
    const top = !after;
    setBusy(true); setErr(false);
    if (top) setSwapping(true);
    return Promise.resolve(window.BBR_supabase.rpc("community_feed", {
      p_limit: CMTY_PAGE, p_following: following,
      // The key breaks a tie: two posts with the same timestamp on a page edge
      // used to lose one.
      p_before: after ? after.at : null, p_before_key: after ? after.key : null,
    }))
      .then((out) => {
        if (id !== reqRef.current) return;
        if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
        const rows = out.data.events || [];
        setEvents((prev) => (!top && prev ? prev.concat(rows) : rows));
        setMore(!!out.data.more);
        if (top) {
          shownFor.current = { meId: meId || null, following };
          if (!following) setWall(rows);
          if (out.data.stats) { setStats(out.data.stats); onStats && onStats(out.data.stats); }
        }
      })
      .catch((e) => {
        if (id !== reqRef.current) return;
        console.error("[community] feed", e);
        setErr(true);
        // A failed reload keeps what was on screen, unless what is on screen
        // belongs to a different filter or reader: those posts would be a lie
        // under the chip that is now lit.
        const s = shownFor.current;
        if (top && s && (s.following !== following || s.meId !== (meId || null))) { setEvents(null); shownFor.current = null; }
      })
      .then(() => { if (id === reqRef.current) { setBusy(false); setSwapping(false); } });
  };
  // Reload from the top when the reader changes (their likes and follows ride
  // on the rows) or when Following is switched on or off. Not on the first pass
  // of a restored feed: that one already has its posts.
  useCmEffect(() => {
    if (!waited) return;
    if (restored.current) {
      // Only if the kept posts really are this reader's, under this filter. The
      // flag must not swallow the reload that a reader arriving later needs.
      restored.current = false;
      const s = shownFor.current;
      if (s && s.meId === (meId || null) && s.following === following) return;
    }
    setFresh(false);
    if (window.BBR_supabase) load(null); else setErr(true);
  }, [meId, following, waited]);
  // Keep what is loaded, and where the reader is, for the way back.
  useCmEffect(() => {
    Object.assign(cmtyFeedKeep, { meId: meId || null, filter, events, wall, more, stats, at: Date.now() });
  }, [meId, filter, events, wall, more, stats]);
  // LIVE. Ask for the newest post's key; if it is not the one at the top of
  // this feed, offer it. Never splice posts in under the reader's thumb.
  const topKey = events && events[0] ? events[0].key : null;
  const check = (isLive) => {
    if (document.hidden || !window.BBR_supabase) return;
    Promise.resolve(window.BBR_supabase.rpc("community_feed", { p_limit: 1, p_before: null, p_following: following })).then((out) => {
      const k = out && out.data && out.data.events && out.data.events[0] && out.data.events[0].key;
      if (isLive() && k && k !== topKey) setFresh(true);
    }, () => {});
  };
  useCmEffect(() => {
    if (keep && keep.stats && onStats) onStats(keep.stats);
    // Back and Forward return to where the reader was; a restored feed may be
    // minutes old, so it is checked for news straight away.
    if (keep && Date.now() - cmtyPoppedAt < 1500) requestAnimationFrame(() => window.scrollTo(0, cmtyFeedKeep.y));
    let live = true;
    if (keep) check(() => live);
    let raf = 0;
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; cmtyFeedKeep.y = window.scrollY || 0; }); };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => { live = false; window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); reqRef.current = -1; };
  }, []);
  useCmEffect(() => {
    if (!topKey) return;
    // `live` goes false when the filter or the top post changes, so an answer
    // already on its way cannot light the pill for a feed that has moved on.
    let live = true;
    const t = setInterval(() => check(() => live), CMTY_POLL_MS);
    return () => { live = false; clearInterval(t); };
  }, [topKey, following]);
  const pick = (k) => {
    if (k === "following" && !meId) { soc.gate(); return; }
    setFilter(k);
    window.BBR_track && window.BBR_track("community_filter", { filter: k });
  };

  const shown = useCmMemo(() => (events || []).filter((e) => filter === "all" || following || e.kind === filter), [events, filter]);

  return (
    <div className="cmty-cols">
      <div className="cmty-feed">
        {stats ? (
          <div className="lb-stats cmty-stats">
            <div className="lb-stat lb-stat-hero">
              <div className="lb-stat-n"><span className="cur">£</span><CmtyCount value={stats.total_value} /></div>
              <div className="lb-stat-l">Across all shelves</div>
            </div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.records} /></div><div className="lb-stat-l">Records logged</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.added_7d} /></div><div className="lb-stat-l">Added this week</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.tier_moves_7d} /></div><div className="lb-stat-l">Up a tier this week</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.collectors} /></div><div className="lb-stat-l">Collectors</div></div>
          </div>
        ) : null}
        <CmtyWall events={wall || (following ? null : events)} onViewCollection={onViewCollection} />
        <CmtyInboxPanel meId={meId} onViewCollection={onViewCollection} />
        <div className="cmty-chips" role="group" aria-label="Show">
          {CMTY_FILTERS.map(([k, label]) => (
            <button key={k} className={"cmty-chip" + (filter === k ? " on" : "")} aria-pressed={filter === k}
              onClick={() => pick(k)}>{label}</button>
          ))}
        </div>
        {err && !events ? <p className="cmty-empty">{CMTY_LOAD_ERR}</p> : null}
        {!events && !err ? <p className="cmty-empty">Loading the feed…</p> : null}
        {fresh ? (
          // The posts stay where they are until the new ones arrive. This used to
          // empty the feed first, so a failed request took the page with it.
          <button className="cmty-fresh" onClick={() => { setFresh(false); load(null); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
            <span className="cmty-pulse" aria-hidden="true" /> New activity. Show it
          </button>
        ) : null}
        {err && events ? <p className="cmty-empty">{CMTY_LOAD_ERR}</p> : null}
        <div className={"cmty-list" + (swapping && events ? " is-swapping" : "")} aria-busy={swapping ? "true" : undefined}>
          {events && !shown.length && !swapping ? (
            <p className="cmty-empty">
              {following ? "Nothing yet from the collectors you follow. Follow a few and their new records land here. " : "Nothing of that kind in the posts loaded so far."}
              {following && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goCollectors()}>Find collectors</button> : null}
            </p>
          ) : null}
          {shown.map((ev, i) => {
            const head = i === 0 || cmtyDayKey(shown[i - 1].at) !== cmtyDayKey(ev.at);
            return (
              <React.Fragment key={ev.key}>
                {head ? <h2 className="cmty-day">{cmtyDayLabel(ev.at)}</h2> : null}
                <div className="cmty-in" style={{ "--i": Math.min(i, 8) }}>
                {ev.kind === "adds" ? <CmtyAddsPost ev={ev} meId={meId} soc={soc} onViewCollection={onViewCollection} />
                  : ev.kind === "tier" ? <CmtyTierPost ev={ev} meId={meId} soc={soc} />
                  : ev.kind === "dive" ? <CmtyDivePost ev={ev} meId={meId} soc={soc} /> : null}
                </div>
              </React.Fragment>
            );
          })}
        </div>
        {events && more ? (
          <button className="cmty-loadmore" disabled={busy}
            onClick={() => { load(events[events.length - 1]); window.BBR_track && window.BBR_track("community_more", {}); }}>
            {busy ? "Loading…" : "Show earlier activity"}
          </button>
        ) : null}
      </div>
    </div>
  );
}

// ---- the tile's one-line description ---------------------------------------
// Written from what the shelf holds (its genres, decades and rarity tiers), all
// of which the public item view already shows. It is NOT the owner's Collection
// DNA read: that is addressed to them ("Your collection…") and is theirs alone.
const CMTY_GENRE_WORDS = {
  "Rock": "rock", "Electronic": "electronic", "Pop": "pop", "Jazz": "jazz", "Soul / R&B": "soul and R&B",
  "Hip-Hop": "hip-hop", "Folk / Country": "folk and country", "Stage & Screen": "soundtracks",
  "Classical": "classical", "Reggae": "reggae", "Reggae / Dub": "reggae and dub", "Children's": "children’s records",
  "Blues": "blues", "Metal": "metal", "Punk / Indie": "punk and indie", "Latin": "Latin",
  "Krautrock": "krautrock", "Ambient": "ambient", "Brass & Military": "brass and military bands",
};
function cmtyGenreWord(g) { return CMTY_GENRE_WORDS[g] || String(g || "").toLowerCase().replace(" / ", " and ").replace(" & ", " and "); }
function cmtyDescribe(c) {
  const count = Number(c.item_count) || 0;
  // Shares are of the WHOLE shelf, classified or not: ten rock records among a
  // hundred with no genre is "some rock", and used to read "Mostly rock".
  const gs = (c.genres || []).map((g) => ({ w: cmtyGenreWord(g.genre), s: g.n / (count || 1) }));
  let first = "";
  if (gs.length) {
    if (gs[0].s >= 0.7) first = "Almost all " + gs[0].w;
    else if (gs[0].s >= 0.45) first = "Mostly " + gs[0].w + (gs[1] && gs[1].s >= 0.15 ? ", with a strong line in " + gs[1].w : "");
    else if (gs.length >= 3 && gs[2].s >= 0.1) first = "A mix of " + gs[0].w + ", " + gs[1].w + " and " + gs[2].w;
    else if (gs.length >= 2 && gs[1].s >= 0.1) first = "A mix of " + gs[0].w + " and " + gs[1].w;
    else first = "Some " + gs[0].w;
  }
  const ds = c.decades || [], dated = Number(c.dated) || 0;
  let when = "";
  if (ds.length && dated >= 5) {
    const s0 = ds[0].n / dated, s1 = ds[1] ? ds[1].n / dated : 0;
    if (ds[1] && s0 >= 0.25 && s1 >= 0.25 && Math.abs(ds[0].decade - ds[1].decade) === 10) {
      const lo = Math.min(ds[0].decade, ds[1].decade);
      when = "centred on the " + lo + "s and " + (lo + 10) + "s";
    } else if (s0 >= 0.35) when = "weighted to the " + ds[0].decade + "s";
  }
  let out = first ? first + (when ? ", " + when : "") + "." : (when ? "A shelf " + when + "." : "");
  const t = c.tiers || {};
  if (t.grail > 0) out += " " + cmtyPlural(t.grail, "Grail pressing") + " on the shelf.";
  else if (t.rare > 0) out += " " + cmtyPlural(t.rare, "Rare pressing") + " on the shelf.";
  // A size, not a history. "Just getting started" said when the shelf began,
  // which is the one thing this page may not say of an anonymous collector, and
  // was wrong for an old small shelf anyway.
  else if (count < 10) out += " A small shelf.";
  return out.trim() || "A shelf in the making.";
}
// The shelf's genre mix as one bar: its top three genres in their own colours,
// the remainder in grey. Widths are shares of the whole shelf.
function CmtyGenreBar({ c }) {
  const count = Number(c.item_count) || 0, gs = c.genres || [];
  if (!count || !gs.length) return null;
  const used = gs.reduce((n, g) => n + g.n, 0);
  return (
    <div className="cmty-mix">
      <div className="cmty-mix-bar" role="img" aria-label={"Genre mix: " + gs.map((g) => g.genre + " " + Math.round(100 * g.n / count) + "%").join(", ")}>
        {gs.map((g) => <span key={g.genre} style={{ width: (100 * g.n / count) + "%", background: cmtyGenreHue(g.genre) }} />)}
        {used < count ? <span style={{ width: (100 * (count - used) / count) + "%", background: "var(--rule)" }} /> : null}
      </div>
      <div className="cmty-mix-key" aria-hidden="true">
        {gs.slice(0, 2).map((g) => <span key={g.genre}><i style={{ background: cmtyGenreHue(g.genre) }} />{g.genre} {Math.round(100 * g.n / count)}%</span>)}
      </div>
    </div>
  );
}
// Scarce first: a badge nobody lacks is not a badge. Four at most.
function cmtyBadges(c) {
  const out = [];
  if (c.rank_value === 1) out.push({ label: "Top shelf", hot: true, title: "The most valuable shelf on The Lead-In" });
  if (c.rank_count === 1) out.push({ label: "Biggest shelf", hot: true, title: "The most records on The Lead-In" });
  const t = c.tiers || {};
  if (t.grail > 0) out.push({ label: cmtyPlural(t.grail, "Grail"), title: "Grail pressings: about £400 or more at Near Mint, and scarce" });
  const leads = c.leads || [];
  if (leads.length) out.push({ label: leads[0] + " leader" + (leads.length > 1 ? " +" + (leads.length - 1) : ""), title: "Holds the most valuable " + leads.join(", ") + " shelf" });
  if (c.is_public && c.member_since && String(c.member_since).slice(0, 10) < CMTY_FOUNDING_BEFORE) out.push({ label: "Founding member", title: "Here since the first month" });
  const level = cmtyLevel(c.item_count);
  if (level && Number(c.item_count) >= 10) out.push({ label: level, title: c.item_count + " records. Level is by record count, not value" });
  return out.slice(0, 4);
}

const CMTY_SORTS = [
  ["active", "Recently active", (a, b) => String(b.last_add || "").localeCompare(String(a.last_add || ""))],
  ["count", "Most records", (a, b) => b.item_count - a.item_count],
  ["value", "Highest value", (a, b) => b.total_value - a.total_value],
  ["followers", "Most followed", (a, b) => (b.followers - a.followers) || (b.likes - a.likes) || String(b.last_add || "").localeCompare(String(a.last_add || ""))],
];

function CmtyCollectors({ collectors, err, meId, soc, onViewCollection }) {
  const [sort, setSort] = useCmState("active");
  const rows = useCmMemo(() => {
    const s = CMTY_SORTS.find((x) => x[0] === sort) || CMTY_SORTS[0];
    return (collectors || []).slice().sort(s[2]);
  }, [collectors, sort]);
  if (err && !collectors) return <p className="cmty-empty">{CMTY_LOAD_ERR}</p>;
  if (!collectors) return <p className="cmty-empty">Loading collectors…</p>;
  const named = collectors.filter((c) => c.is_public).length;
  return (
    <div>
      <div className="cmty-tools">
        <div className="cmty-chips" role="group" aria-label="Sort by">
          {CMTY_SORTS.map(([k, label]) => (
            <button key={k} className={"cmty-chip" + (sort === k ? " on" : "")} aria-pressed={sort === k}
              onClick={() => { setSort(k); window.BBR_track && window.BBR_track("community_sort", { sort: k }); }}>{label}</button>
          ))}
        </div>
        <span className="cmty-meta">{named} named · {collectors.length - named} anonymous</span>
      </div>
      <div className="cmty-grid">
        {rows.map((c, idx) => {
          const me = !!(meId && c.user_id === meId);
          const open = () => onViewCollection(c.user_id, cmtyName(c, null));
          const since = c.is_public && c.member_since ? cmtySince(c.member_since) : null;
          const badges = cmtyBadges(c);
          return (
            <article className={"cmty-tile cmty-in" + (me ? " me" : "")} key={c.user_id} style={{ "--i": Math.min(idx, 8) }}>
              <header className="cmty-who">
                <CmtyAvatar actor={c} />
                <div className="cmty-line">
                  <button className="cmty-tile-n" onClick={open}>{cmtyName(c, meId)}</button>
                  <div className="cmty-meta">{c.is_public ? (since ? "Collecting since " + since : "Collector") : "Anonymous collector"}</div>
                </div>
                <CmtyFollow soc={soc} user={c} />
              </header>
              <button className="cmty-strip" onClick={open} aria-label={"Open " + cmtyName(c, null) + "’s shelf"}>
                {(c.front || []).map((it, i) => <CmtySleeve key={i} item={it} size={72} />)}
              </button>
              <CmtyGenreBar c={c} />
              <p className="cmty-desc">{cmtyDescribe(c)}</p>
              <div className="cmty-nums">
                <div><b>{cmtyNum(c.item_count)}</b>records</div>
                <div><b>{cmtyGbp(c.total_value)}</b>shelf value</div>
                <div><b>#{c.rank_value}</b>by value</div>
              </div>
              <div className="cmty-badges">{badges.map((b) => <span key={b.label} className={"cmty-badge" + (b.hot ? " hot" : "")} title={b.title}>{b.label}</span>)}</div>
              <footer className="cmty-acts">
                <CmtyLike soc={soc} target={"shelf:" + c.user_id} base={c} mine={me} what={cmtyName(c, null) + "’s shelf"} />
                <span className="cmty-meta">{cmtyPlural(soc.followOf(c.user_id, c).followers, "follower")} · added {cmtyAgo(c.last_add)}</span>
                <button className="cmty-link" onClick={open}>View shelf</button>
              </footer>
            </article>
          );
        })}
      </div>
    </div>
  );
}

// `divesLoaded` is not read: it is passed so the page draws again once the dive
// catalogue has arrived, which is when "Read the deep dive" can be decided.
function Community({ tab, currentUserId, authReady, divesLoaded, onViewCollection, onRequireAuth }) {
  const soc = useCmtySocial(currentUserId || null, authReady, onRequireAuth);
  const [collectors, setCollectors] = useCmState(null);
  const [cErr, setCErr] = useCmState(false);
  const [count, setCount] = useCmState(cmtyFeedKeep.stats ? cmtyFeedKeep.stats.collectors : null);
  // The tiles are read when the Collectors tab is opened, as whoever is reading
  // (after auth has answered), and again if that changes. The feed tab used to
  // fetch all of them on every visit to print one number, which the feed's own
  // stats already carry.
  const wantFor = tab === "collectors" && authReady !== false ? (currentUserId || "anon") : null;
  const gotFor = useCmRef(null);
  useCmEffect(() => {
    if (!wantFor || gotFor.current === wantFor) return;
    if (!window.BBR_supabase) { setCErr(true); return; }
    let live = true;
    setCErr(false);
    Promise.resolve(window.BBR_supabase.rpc("community_collectors", {}))
      .then((out) => {
        if (!live) return;
        if (!out || out.error || !Array.isArray(out.data)) throw (out && out.error) || new Error("no data");
        gotFor.current = wantFor;
        setCollectors(out.data);
        setCount(out.data.length);
      })
      .catch((e) => { console.error("[community] collectors", e); if (live) setCErr(true); });
    return () => { live = false; };
  }, [wantFor]);
  useCmEffect(() => { window.BBR_track && window.BBR_track("community_tab", { tab }); }, [tab]);
  return (
    <div className="lb cmty">
      <div className="lb-head">
        <h1 className="lb-title">Community</h1>
        <p className="lb-sub">
          {tab === "collectors"
            ? "Every collector on The Lead-In and what their shelf is made of. Each one opens; the name is the part that’s optional."
            : "New records, new levels and pressings on the move, from every shelf on The Lead-In" + (count ? " (" + count + " collectors so far)." : ".")}
        </p>
      </div>
      <CommunityTabs tab={tab} />
      {tab === "collectors"
        ? <CmtyCollectors collectors={collectors} err={cErr} meId={currentUserId} soc={soc} onViewCollection={onViewCollection} />
        : <CmtyFeed meId={currentUserId} authReady={authReady} soc={soc} onViewCollection={onViewCollection} onRequireAuth={onRequireAuth}
            onStats={(s) => { if (s && s.collectors) setCount(s.collectors); }} />}
      <CmtySocialNote soc={soc} />
    </div>
  );
}

// ---- choosing a picture -----------------------------------------------------
// Opened from the collector's own profile. Three ways in, most personal first:
// records suggested from their own shelf (each with the reason it is offered),
// any record on the shelf by search, and the drawn labels. Nothing is saved
// until Save, so a tap is a preview.
const CMTY_AV_ERR = {
  not_your_record: "That record isn’t on your shelf any more.",
  sign_in: "Sign in again to choose a picture.",
};
// A session that has lapsed is refused before the function runs, so it never
// raises its own 'sign_in': it arrives as a permission error.
function cmtyAvErr(e) {
  const m = String((e && e.message) || ""), c = String((e && e.code) || "");
  if (c === "42501" || c === "PGRST301" || /permission denied|JWT/i.test(m)) return CMTY_AV_ERR.sign_in;
  return CMTY_AV_ERR[m] || "That didn’t save. Try again in a moment.";
}
function CmtyAvatarPicker({ actor, current, auto, genres, items, onClose, onSaved }) {
  const [opts, setOpts] = useCmState(null);
  const [optsBad, setOptsBad] = useCmState(false);
  const [pick, setPick] = useCmState(null);        // { choice, avatar }, or null while untouched
  // Opens on the label they already have, if they have one, not on the colour
  // of their top genre with nothing marked.
  const has = current && current.kind === "label" ? current : null;
  const [hue, setHue] = useCmState(has ? (Number(has.hue) || 0) : cmtyHueForGenre(genres && genres[0]));
  // A double click on the link that opens this lands its second click on the
  // backdrop, which closed it again at once.
  const born = useCmRef(Date.now());
  const [q, setQ] = useCmState("");
  const [saving, setSaving] = useCmState(false);
  const [err, setErr] = useCmState("");
  const onCloseRef = useCmRef(onClose); onCloseRef.current = onClose;

  useCmEffect(() => {
    let live = true;
    Promise.resolve(window.BBR_supabase.rpc("community_avatar_options", {})).then((out) => {
      if (!live) return;
      const ok = out && !out.error && Array.isArray(out.data);
      setOpts(ok ? out.data : []); setOptsBad(!ok);
    }, () => { if (live) { setOpts([]); setOptsBad(true); } });
    return () => { live = false; };
  }, []);
  // Body lock, back gesture, Escape: as the front-ten picker.
  useCmEffect(() => {
    const y = window.scrollY || 0, b = document.body;
    const prev = { position: b.style.position, top: b.style.top, left: b.style.left, right: b.style.right, width: b.style.width, overflow: b.style.overflow };
    Object.assign(b.style, { position: "fixed", top: "-" + y + "px", left: "0", right: "0", width: "100%", overflow: "hidden" });
    return () => { Object.assign(b.style, prev); window.scrollTo(0, y); };
  }, []);
  useCmEffect(() => {
    history.pushState({ bbrAvatar: true }, "", location.pathname + location.search + location.hash);
    let popped = false;
    const onPop = () => { popped = true; onCloseRef.current(); };
    window.addEventListener("popstate", onPop);
    // Step back over our own entry only. If the page has moved on underneath
    // (a link followed while this was open), back() would undo that move.
    return () => { window.removeEventListener("popstate", onPop); if (!popped && history.state && history.state.bbrAvatar) history.back(); };
  }, []);
  // A dialog: focus comes in, Tab stays in, and focus goes back where it was.
  const boxRef = useCmRef(null);
  useCmEffect(() => {
    const was = document.activeElement, box = boxRef.current;
    if (box) box.focus({ preventScroll: true });
    const onTab = (e) => {
      if (e.key !== "Tab" || !box) return;
      const f = box.querySelectorAll("button:not([disabled]), input, a[href]");
      if (!f.length) return;
      const first = f[0], last = f[f.length - 1], at = document.activeElement;
      if (!box.contains(at)) { e.preventDefault(); first.focus(); }
      else if (e.shiftKey && (at === first || at === box)) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && at === last) { e.preventDefault(); first.focus(); }
    };
    document.addEventListener("keydown", onTab);
    return () => {
      document.removeEventListener("keydown", onTab);
      if (was && was.focus && document.contains(was)) { try { was.focus({ preventScroll: true }); } catch (e) {} }
    };
  }, []);
  useCmEffect(() => {
    const k = (e) => { if (e.key === "Escape" && !e.defaultPrevented) onCloseRef.current(); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, []);

  const asCover = (r) => ({ kind: "cover", artist: r.artist, title: r.title, year: r.year, slug: r.slug || null,
                            discogs_release_id: r.discogs_release_id || null, cover: r.cover || null });
  const matches = useCmMemo(() => {
    const t = q.trim().toLowerCase();
    if (t.length < 2) return [];
    return (items || []).filter((r) => ((r.title || "") + " " + (r.artist || "")).toLowerCase().includes(t)).slice(0, 6);
  }, [items, q]);
  const shown = pick ? pick.avatar : current;
  const isPick = (choice) => pick ? pick.choice === choice
    : !!has && choice === "label:" + has.pattern + ":" + (Number(has.hue) || 0);

  function save() {
    if (!pick) { onClose(); return; }
    setSaving(true); setErr("");
    Promise.resolve(window.BBR_supabase.rpc("community_set_avatar", { p_choice: pick.choice })).then((out) => {
      setSaving(false);
      if (!out || out.error) { setErr(cmtyAvErr(out && out.error)); return; }
      window.BBR_track && window.BBR_track("community_avatar", { kind: pick.choice.split(":")[0] });
      onSaved(out.data || null, pick.choice !== "auto");
      onClose();
    }, () => { setSaving(false); setErr("That didn’t save. Try again in a moment."); });
  }

  const portal = window.bbrOverlayPortal || ((n) => n);
  return portal(
    <div className="av2-backdrop" onClick={() => { if (Date.now() - born.current > 450) onClose(); }}>
      <div className="av2-modal cmty-avp" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Choose your picture" tabIndex={-1} ref={boxRef}>
        <div className="av2-head">
          <div className="av2-head-left"><span className="av2-head-title">Your picture</span></div>
          <button className="av2-x" aria-label="Close" onClick={onClose}>×</button>
        </div>
        <div className="av2-body">
          <div className="cmty-avp-top">
            <CmtyAvatar actor={actor} avatar={shown} disc size={104} />
            <p>{!pick ? "This is how other collectors see you: on your shelf, in the feed and beside anything you like or follow."
                : pick.choice === "auto" ? "The automatic picture: the first record in your front ten, or your most valuable one if you haven’t chosen a front ten. Save to keep it."
                : "A preview. Save to keep it."}</p>
          </div>

          <h3 className="cmty-avp-h">From your shelf</h3>
          {opts === null ? <p className="cmty-meta">Looking through your records…</p> : null}
          {opts && !opts.length ? <p className="cmty-meta">{optsBad ? "Your suggestions didn’t load. You can still search your shelf or choose a label." : "Add a record and it can be your picture."}</p> : null}
          <div className="cmty-avp-grid">
            {(opts || []).map((o) => (
              <button key={o.choice} className={"cmty-avp-opt" + (isPick(o.choice) ? " on" : "")} aria-pressed={isPick(o.choice)}
                onClick={() => { setErr(""); setPick({ choice: o.choice, avatar: asCover(o) }); }}>
                <CmtyAvatar actor={actor} avatar={asCover(o)} size={64} />
                <span className="cmty-avp-t">{o.title}</span>
                <span className="cmty-meta">{o.why}</span>
              </button>
            ))}
          </div>

          <h3 className="cmty-avp-h">Or any record you own</h3>
          <input className="cmty-avp-q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search your shelf" aria-label="Search your shelf" />
          {matches.map((r) => (
            <button key={r.id} className={"cmty-avp-row" + (isPick("item:" + r.id) ? " on" : "")} aria-pressed={isPick("item:" + r.id)}
              onClick={() => { setErr(""); setPick({ choice: "item:" + r.id, avatar: asCover(r) }); }}>
              <CmtyAvatar actor={actor} avatar={asCover(r)} size={36} />
              <span><b>{r.title}</b> <span className="cmty-meta">{r.artist}</span></span>
            </button>
          ))}
          {q.trim().length >= 2 && !matches.length ? <p className="cmty-meta">Nothing on your shelf matches that.</p> : null}

          <h3 className="cmty-avp-h">Or a label</h3>
          <div className="cmty-avp-hues" role="group" aria-label="Label colour">
            {CMTY_LABEL_HUES.map((c, i) => (
              <button key={i} className={"cmty-avp-hue" + (hue === i ? " on" : "")} style={{ background: c }} aria-pressed={hue === i}
                aria-label={"Colour " + (i + 1) + (CMTY_LABEL_GENRES[i] ? ", " + CMTY_LABEL_GENRES[i] : "")} title={CMTY_LABEL_GENRES[i]}
                onClick={() => { setHue(i); if (pick && pick.choice.indexOf("label:") === 0) { const p = Number(pick.choice.split(":")[1]); setPick({ choice: "label:" + p + ":" + i, avatar: { kind: "label", pattern: p, hue: i } }); } }} />
            ))}
          </div>
          <div className="cmty-avp-labels">
            {CMTY_LABEL_NAMES.map((nm, i) => {
              const choice = "label:" + (i + 1) + ":" + hue;
              return (
                <button key={nm} className={"cmty-avp-opt" + (isPick(choice) ? " on" : "")} aria-pressed={isPick(choice)}
                  onClick={() => { setErr(""); setPick({ choice, avatar: { kind: "label", pattern: i + 1, hue } }); }}>
                  <CmtyAvatar actor={actor} avatar={{ kind: "label", pattern: i + 1, hue }} size={56} />
                  <span className="cmty-meta">{nm}</span>
                </button>
              );
            })}
          </div>
        </div>
        <div className="cmty-avp-foot">
          {err ? <p className="cmty-avp-err" role="alert">{err}</p> : null}
          <button className="cmty-link" onClick={() => { setErr(""); setPick({ choice: "auto", avatar: auto || null }); }}>Use the automatic picture</button>
          <button className="cmty-follow on" disabled={saving || !pick} onClick={save}>{saving ? "Saving…" : "Save"}</button>
        </div>
      </div>
    </div>
  );
}

// ---- the top of a collector's shelf -----------------------------------------
// Who this is, before what they own (12 Oct 2026, Ric): a picture, the name,
// the level, follow and like, then the figures that carry a collector's weight
// set as a band, not a row of chips: shelf value, records, how many have a
// deep dive, how much is graded, and followers. Followers sit with the others
// on purpose. Record count is why a collector's opinion counts; so is who
// chooses to follow them.
//
// Rendered by PublicCollectionV2 (Leaderboards_v2.jsx), which owns the records
// and passes the figures in. This owns what the item view does not carry:
// community_profile() for the followers, the reader's own follow and like, and
// the avatar.
function CommunityProfileHero({ userId, name, isPublic, since, isOwner, total, count, canon, graded, items, onShare, shareReady }) {
  // undefined until the session has been read: a tap before then must not open
  // the sign-up gate over a member (see useCmtySocial).
  const [meId, setMeId] = useCmState(undefined);
  useCmEffect(() => {
    let live = true;
    if (!window.BBR_supabase || !window.BBR_supabase.auth) { setMeId(null); return; }
    window.BBR_supabase.auth.getSession().then((res) => {
      const u = res && res.data && res.data.session && res.data.session.user;
      if (live) setMeId(u ? u.id : null);
    }, () => { if (live) setMeId(null); });
    // And followed after that. Signing in from the gate leaves the reader on
    // this page ("community" is a stay-in-place intent in App.jsx): read once,
    // the header went on thinking nobody was signed in, and Follow and Like
    // did nothing until a reload.
    const off = window.BBR_auth && window.BBR_auth.onChange
      ? window.BBR_auth.onChange((u) => { if (live) setMeId(u ? u.id : null); }) : null;
    return () => { live = false; if (off) { try { off(); } catch (e) {} } };
  }, []);
  // The page works out `isOwner` once per shelf; a reader who signs in here and
  // turns out to own it must not be offered Follow on themselves.
  const mine = !!isOwner || (!!meId && meId === userId);
  const soc = useCmtySocial(meId || null, meId !== undefined, window.__bbrRequireAuth);
  const [prof, setProf] = useCmState(null);
  const [picking, setPicking] = useCmState(false);
  useCmEffect(() => {
    if (!userId || meId === undefined || !window.BBR_supabase) return;
    let live = true;
    setProf(null);
    Promise.resolve(window.BBR_supabase.rpc("community_profile", { p_user: userId })).then((out) => {
      if (live && out && !out.error && out.data) setProf(out.data);
    }, () => {});
    return () => { live = false; };
  }, [userId, meId]);
  // Account settings sends the owner here to choose (BBR_OPEN_AVATAR). The
  // request names the collector and the moment: one left behind (an empty
  // shelf never shows this header, and signing out does not reload the page)
  // must not open the chooser later, or for the next account on this tab.
  useCmEffect(() => {
    const want = window.BBR_OPEN_AVATAR;
    if (!want) return;
    if (want.user !== userId || !(Date.now() - want.at < 20000)) { window.BBR_OPEN_AVATAR = null; return; }
    if (isOwner) { window.BBR_OPEN_AVATAR = null; setPicking(true); }
  }, [isOwner, userId]);

  const alias = isPublic || /^anonymous collector$/i.test(String(name || "").trim())
    ? null : String(name || "").replace(/^Anonymous\s+/, "");
  const actor = {
    user_id: userId, is_public: !!isPublic, display_name: isPublic ? name : null, anon_alias: alias,
    avatar: prof ? prof.avatar : null, following: prof ? prof.following : false, followers: prof ? prof.followers : 0,
  };
  const followers = soc.followOf(userId, actor).followers;
  const topGenres = useCmMemo(() => {
    const n = {};
    (items || []).forEach((r) => { if (r.genre) n[r.genre] = (n[r.genre] || 0) + 1; });
    return Object.keys(n).sort((a, b) => n[b] - n[a]);
  }, [items]);
  const Badge = window.CollectorBadge;
  return (
    <React.Fragment>
      <div className="pubcol-hero">
        <div className="pubcol-pic">
          <CmtyAvatar actor={actor} disc size={112} />
          {mine ? <button className="cmty-link" onClick={() => setPicking(true)}>{prof && prof.chosen ? "Change picture" : "Choose a picture"}</button> : null}
        </div>
        <div className="pubcol-id">
          <div className="pubcol-kicker">{isPublic ? "Collector" + (since ? " · since " + since : "") : "Anonymous collector"}</div>
          <h1 className="lb-title">{name}</h1>
          <div className="pubcol-level">{Badge ? <Badge count={count} /> : <b>{cmtyLevel(count)}</b>}</div>
          <div className="pubcol-actions">
            {!mine ? <CmtyFollow soc={soc} user={actor} /> : null}
            <CmtyLike soc={soc} target={"shelf:" + userId} base={prof || {}} mine={mine} what={name + "’s shelf"} />
            {isOwner && onShare ? <button type="button" className="mc-invite pubcol-share" onClick={onShare} disabled={!shareReady}>Share your shelf</button> : null}
          </div>
        </div>
      </div>
      <div className="lb-stats pubcol-band">
        <div className="lb-stat lb-stat-hero">
          <div className="lb-stat-n"><span className="cur">£</span><CmtyCount value={total} /></div>
          <div className="lb-stat-l">Shelf value</div>
        </div>
        <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={count} /></div><div className="lb-stat-l">{count === 1 ? "Record" : "Records"}</div></div>
        <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={canon} /></div><div className="lb-stat-l">With a deep dive</div></div>
        <div className="lb-stat" title={"Share of this collection's value priced on the owner's own condition grades. The rest is priced as " + (window.BBR_ASSUMED_GRADE || "VG+") + " until they grade it."}>
          <div className="lb-stat-n">{graded ? <React.Fragment>{graded.full ? "100" : graded.pct}<small>%</small></React.Fragment> : "–"}</div>
          <div className="lb-stat-l">Graded</div>
        </div>
        {/* A dash until the count is known (it read "0" while loading, and for good
            if the read failed). Set plainly: the counting-up figure rewound to
            nought and spun up again on every Follow and Unfollow. */}
        <div className="lb-stat"><div className="lb-stat-n">{prof ? (Number(followers) || 0).toLocaleString("en-GB") : "–"}</div><div className="lb-stat-l">{followers === 1 ? "Follower" : "Followers"}</div></div>
      </div>
      {picking ? (
        <CmtyAvatarPicker actor={actor} current={prof ? prof.avatar : null} auto={prof ? prof.auto : null} genres={topGenres} items={items}
          onClose={() => setPicking(false)}
          onSaved={(av, chosen) => {
            setProf((p) => Object.assign({}, p || {}, { avatar: av, chosen: !!chosen }));
            // The feed kept for the way back was read with the old picture on
            // this collector's own posts; let it be read again.
            cmtyFeedKeep.events = null;
          }} />
      ) : null}
      <CmtySocialNote soc={soc} />
    </React.Fragment>
  );
}

Object.assign(window, { Community, CommunityTabs, CommunityNavBadge, CommunityProfileHero, CmtyAvatar });
