// ===========================================================
// ShelfShare (C1, roadmap F3-F5, 28 Sep 2026): the owner's "Share your shelf"
// sheet. A short link, a preview image drawn for this shelf, and the ways out.
//
//   * THE LINK is /u/<handle>. A public collector's handle comes from their
//     name (editable here); an anonymous collector's is their alias
//     (/u/anonymous-petty) and is not editable, because the only thing it could
//     be edited to is something closer to who they are. Both resolve only while
//     they match the shelf's visibility (supabase/migrations/shelf_handles.sql),
//     so this sheet always states which one is being shared.
//   * THE PREVIEW is the 1200x630 image a link unfurls into in WhatsApp,
//     iMessage and every social app: the nine most valuable records, the name or
//     alias, the count and the value. Drawn here, in the owner's browser (the
//     site has no build step, so no server-side image rendering), uploaded to the
//     public shelf-og bucket as a JPEG (see drawAndStore) and recorded with
//     set_shelf_og(), which stamps the identity it was drawn for. The server hands it to scrapers only while that
//     identity still matches, so going anonymous retires a named image at once.
//     Redrawn only when what it shows has changed (the `sig`).
//   * THE WAYS OUT: the device's share sheet (link only: most apps drop a URL
//     that travels with a file, and the link carries the preview anyway), copy
//     link, and save the image for a story. Each is a shelf_share event, and the
//     link carries the owner's ?r= code so a signup from it is credited.
//
// Covers: canon records from same-origin /covers; everything else through
// /api/cover?img=, the same-origin passthrough, because Discogs' CDN sends no
// CORS header and a canvas that draws it can never be exported.
//
// Globals in: React, bbrOverlayPortal, BBR_supabase, BBR_ref, lbFetchCover,
// bdDrawMark, bdWrap. Exposes window.ShelfShare, window.shelfShareRender.
// ===========================================================
const { useState: useShState, useEffect: useShEffect, useRef: useShRef, useMemo: useShMemo } = React;

const SH_SITE = "https://www.theleadin.com";
const SH_W = 1200, SH_H = 630;
const SH_MAX_BYTES = 290 * 1024; // under WhatsApp's reported ~300KB preview ceiling

function shGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }

// The records at the front of the shelf: the collector's own front ten (F6) when
// the caller passes it, else the nine most valuable, so the image leads with
// what makes a shelf worth opening.
function shFront(records, front) {
  if (front && front.length) return front.filter((r) => r && (r.title || r.artist)).slice(0, 9);
  // No front ten passed: the most valuable distinct albums (FrontTen's rule).
  const sorted = (records || [])
    .filter((r) => r && (r.title || r.artist))
    .slice()
    .sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0));
  if (typeof window.ftAlbumKey !== "function") return sorted.slice(0, 9);
  const seen = new Set(), out = [];
  for (const r of sorted) { const k = window.ftAlbumKey(r); if (!seen.has(k)) { seen.add(k); out.push(r); } if (out.length >= 9) break; }
  return out;
}

// What the image shows, reduced to a string: when it is unchanged, the image
// already uploaded is still true and is reused instead of redrawn.
// Built from the ordered record ids, hashed: an earlier version keyed records
// by 12 characters of "artist|title", which for "Fleetwood Mac" never reached
// the title, so swapping Rumours for Tusk kept serving the old image (24 of 36
// live shelves had colliding keys), and two pressings of one album shared a slug.
function shHash(str) {
  let h1 = 0x811c9dc5, h2 = 0x01000193;
  for (let i = 0; i < str.length; i++) {
    const c = str.charCodeAt(i);
    h1 = Math.imul(h1 ^ c, 16777619) >>> 0;
    h2 = Math.imul(h2 ^ c, 2246822519) >>> 0;
  }
  return h1.toString(36) + h2.toString(36);
}
function shSig(label, records, total, path, frontIn, tiers) {
  const front = shFront(records, frontIn).map((r) => String(r.id || r.slug || ((r.artist || "") + "|" + (r.title || "")))).join(",");
  const tc = tiers ? [tiers.grail || 0, tiers.rare || 0, tiers.scarce || 0].join(".") : "";
  // v5 (30 Sep 2026): the real brand mark. A stored preview with the drawn
  // approximation must not be reused, so the version moves and it is redrawn.
  return ["v5", path || "", shHash(String(label || "")), (records || []).length, Math.round(Number(total) || 0), shHash(front), tc].join("~");
}
// "1 Grail · 4 Rare · 11 Scarce" for the preview; empty below Scarce.
function shTierLine(tiers) {
  if (!tiers) return "";
  return [["grail", "Grail"], ["rare", "Rare"], ["scarce", "Scarce"]]
    .filter(([k]) => tiers[k] > 0).map(([k, l]) => tiers[k] + " " + l).join(" \u00b7 ");
}
// supabase-js builders are thenables without a .catch of their own.
function shTry(q) { return Promise.resolve(q).then((x) => x, () => null); }

// Resolve a record's cover to something a canvas may read.
function shCoverSrc(r) {
  if (r.slug) return Promise.resolve("/covers/" + encodeURIComponent(r.slug) + ".jpg?v=2");
  const known = r.coverUrl || null;
  const find = known ? Promise.resolve(known)
    : (typeof window.lbFetchCover === "function" ? shTry(window.lbFetchCover(r)) : Promise.resolve(null));
  return find.then((u) => (u ? "/api/cover?img=" + encodeURIComponent(u) : null));
}
function shLoadImg(src, ms) {
  return new Promise((resolve) => {
    if (!src) { resolve(null); return; }
    const img = new Image();
    const t = setTimeout(() => resolve(null), ms || 7000);
    img.onload = () => { clearTimeout(t); resolve(img); };
    img.onerror = () => { clearTimeout(t); resolve(null); };
    img.src = src;
  });
}

// Draw the preview. Returns a canvas; exported for the harness.
async function shelfShareRender({ label, records, total, link, front: frontIn, tiers }) {
  const canvas = document.createElement("canvas");
  canvas.width = SH_W; canvas.height = SH_H;
  const ctx = canvas.getContext("2d");
  const PAPER = "#f3ede1", INK = "#17130f", INK2 = "#3a322a", MUTE = "#8a8072", RULE = "#d8d0bd", ACCENT = "#d94e2c", SLEEVE = "#e4dccb";

  try {
    await Promise.all([
      document.fonts.load("400 64px 'DM Serif Display'"),
      document.fonts.load("600 24px 'JetBrains Mono'"),
      document.fonts.load("800 36px 'Inter'"),
      document.fonts.load("600 22px 'Inter'"),
    ]);
    await document.fonts.ready;
    await window.bdMarkReady;   // the brand mark's artwork (BestDig.jsx bdDrawMark)
  } catch (e) { /* draw with what is loaded */ }

  ctx.fillStyle = PAPER; ctx.fillRect(0, 0, SH_W, SH_H);

  // Left: the front of the shelf, 3x3.
  const front = shFront(records, frontIn);
  const G = 36, CELL = 178, GAP = 8;
  const imgs = await Promise.all(front.map((r) => shCoverSrc(r).then((s) => shLoadImg(s))));
  for (let i = 0; i < 9; i++) {
    const x = G + (i % 3) * (CELL + GAP), y = G + Math.floor(i / 3) * (CELL + GAP);
    const r = front[i], img = imgs[i];
    if (img) {
      // Centre-crop to a square: most sleeves are square, some scans are not.
      const s = Math.min(img.naturalWidth || img.width, img.naturalHeight || img.height);
      const sx = ((img.naturalWidth || img.width) - s) / 2, sy = ((img.naturalHeight || img.height) - s) / 2;
      ctx.drawImage(img, sx, sy, s, s, x, y, CELL, CELL);
    } else {
      ctx.fillStyle = r ? SLEEVE : "#ece5d6"; ctx.fillRect(x, y, CELL, CELL);
      if (r) {
        ctx.fillStyle = INK2; ctx.font = "400 21px 'DM Serif Display', Georgia, serif";
        const lines = window.bdWrap ? window.bdWrap(ctx, r.title || "", CELL - 28, 3) : [r.title || ""];
        lines.forEach((ln, k) => ctx.fillText(ln, x + 14, y + 36 + k * 25));
        ctx.fillStyle = MUTE; ctx.font = "600 13px 'JetBrains Mono', monospace";
        const a = String(r.artist || "").toUpperCase();
        ctx.fillText(a.length > 18 ? a.slice(0, 17) + "…" : a, x + 14, y + CELL - 16);
      }
    }
    ctx.strokeStyle = "rgba(23,19,15,.10)"; ctx.lineWidth = 1; ctx.strokeRect(x + .5, y + .5, CELL - 1, CELL - 1);
  }

  // Right: who, how many, what it is worth, and where.
  const X = G + 3 * CELL + 2 * GAP + 48, RW = SH_W - X - 44;
  if (window.bdDrawMark) {
    ctx.save(); ctx.translate(X, 52); ctx.scale(0.62, 0.62); window.bdDrawMark(ctx, 0, 0, ACCENT, INK); ctx.restore();
  }
  ctx.fillStyle = INK; ctx.font = "400 30px 'DM Serif Display', Georgia, serif";
  ctx.fillText("The Lead-In", X + 62, 82);

  ctx.fillStyle = MUTE; ctx.font = "600 17px 'JetBrains Mono', monospace";
  ctx.fillText("A VINYL SHELF", X, 178);
  ctx.fillStyle = INK; ctx.font = "400 58px 'DM Serif Display', Georgia, serif";
  const name = window.bdWrap ? window.bdWrap(ctx, label, RW, 2) : [label];
  name.forEach((ln, k) => ctx.fillText(ln, X, 240 + k * 64));
  const yStats = 240 + name.length * 64 + 28;

  const n = (records || []).length;
  ctx.fillStyle = INK; ctx.font = "800 44px 'Inter', Helvetica, sans-serif";
  ctx.fillText(shGbp(total), X, yStats);
  ctx.fillStyle = INK2; ctx.font = "600 22px 'Inter', Helvetica, sans-serif";
  ctx.fillText(n.toLocaleString("en-GB") + " record" + (n === 1 ? "" : "s") + ", valued daily", X, yStats + 38);
  // Tier counts (F7): the shelf's rarest pressings, in the accent.
  const tl = shTierLine(tiers);
  if (tl) { ctx.fillStyle = ACCENT; ctx.font = "600 20px 'JetBrains Mono', monospace"; ctx.fillText(tl.toUpperCase(), X, yStats + 76); }

  ctx.strokeStyle = RULE; ctx.lineWidth = 2;
  ctx.beginPath(); ctx.moveTo(X, SH_H - 104); ctx.lineTo(SH_W - 44, SH_H - 104); ctx.stroke();
  ctx.fillStyle = ACCENT; ctx.font = "600 21px 'JetBrains Mono', monospace";
  let l = String(link || "theleadin.com");
  while (l.length > 12 && ctx.measureText(l).width > RW) l = l.slice(0, -2);
  ctx.fillText(l, X, SH_H - 64);
  return canvas;
}

// What <Sleeve> draws for a card tile: the record's canon album when it has
// one, else its own fields and cover.
function shThumbAlbum(r) {
  const canon = r && r.slug && (window.BBR_ALBUMS || []).find((a) => a.slug === r.slug);
  return (r && r.album) || canon || { title: (r && r.title) || "", artist: (r && r.artist) || "", year: r && r.year, rank: -1, slug: null, coverUrl: (r && r.coverUrl) || null };
}

function shRandomName() {
  const a = new Uint8Array(15);
  (window.crypto || window.msCrypto).getRandomValues(a);
  return Array.from(a, (b) => (b % 36).toString(36)).join("") + Date.now().toString(36).slice(-5);
}

const SH_ERR = {
  format: "3 to 30 letters, numbers or hyphens, starting and ending with a letter or number.",
  reserved: "That one's reserved. Try another.",
  taken: "Someone already has that link.",
  anonymous: "An anonymous shelf's link is its alias.",
};

// records: [{ title, artist, year, slug, value, coverUrl?, discogs_release_id? }]
// front:   the same shape, in the shelf's front-ten order (optional)
// tierItems: { itemId: tier } for the story cards (F9); frontChosen: whether the
//   collector picked their ten; initialCard: { kind, itemId } to open straight
//   onto a card (the Grail moment's "Share it", App.jsx TierCheer).
function ShelfShare({ records, total, front, tiers, tierItems, frontChosen, initialCard, onClose }) {
  const sb = window.BBR_supabase;
  const [state, setState] = useShState({ phase: "loading" }); // loading | ready | error
  const [handle, setHandle] = useShState(null);
  const [kind, setKind] = useShState(null);          // named | anon
  const [label, setLabel] = useShState("");
  const [link, setLink] = useShState("");
  const [img, setImg] = useShState({ src: null, blob: null, drawing: true });
  // The link is not handed out until its preview is recorded (or 8s pass): the
  // first scrape of a link is the one WhatsApp keeps, and a tap on Share before
  // set_shelf_og() landed unfurled the generic card.
  const [ogReady, setOgReady] = useShState(false);
  const ctx = useShRef(null);   // { uid, lab, p, k } for a redraw after a link change
  const [copied, setCopied] = useShState(false);
  const [editing, setEditing] = useShState(false);
  const [draft, setDraft] = useShState("");
  const [editErr, setEditErr] = useShState("");
  const [saving, setSaving] = useShState(false);
  const onCloseRef = useShRef(onClose); onCloseRef.current = onClose;
  const blobUrl = useShRef(null);
  // False once the sheet closes: a draw or upload still in flight must not be
  // recorded for a sheet nobody is looking at (the identity may have changed).
  const alive = useShRef(true);
  useShEffect(() => () => { alive.current = false; }, []);
  const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function";

  // ---- the story cards (F9, components/ShareCards.jsx) ----------------------
  // Built from the same records, plus the fields only the owner's own rows hold
  // (the price paid for Best Dig, the label and grade a card prints), read from
  // the store: the public shelf's rows carry none of them.
  const [siteCounts, setSiteCounts] = useShState(null);
  useShEffect(() => {
    let live = true;
    if (sb) shTry(sb.rpc("pressing_tier_counts")).then((o) => { if (live && o && !o.error && o.data) setSiteCounts(o.data); });
    return () => { live = false; };
  }, []);
  // The month card's facts (month_card.sql shelf_month): the caller's own shelf only.
  // Opened from the monthly report (F11), it is that email's month, not whichever
  // month shelf_month() defaults to today: a first Monday on the 7th would
  // otherwise show a week of the new month under last month's headline.
  const askMonth = initialCard && initialCard.kind === "month" && initialCard.month ? initialCard.month : null;
  const [month, setMonth] = useShState(null);
  useShEffect(() => {
    let live = true;
    if (sb) shTry(askMonth ? sb.rpc("shelf_month", { p_month: askMonth }) : sb.rpc("shelf_month"))
      .then((o) => { if (live && o && !o.error && o.data) setMonth(o.data); });
    return () => { live = false; };
  }, [askMonth]);
  const cardRecs = useShMemo(() => {
    const own = new Map((((window.BBR_store && window.BBR_store.collectionV2) || [])).map((r) => [String(r.id), r]));
    const add = (r) => {
      const o = own.get(String(r && r.id)) || {};
      return Object.assign({}, r, {
        label: o.label || null, catalogue_no: o.catalogue_no || null, media_condition: o.media_condition || null,
        purchase_price: o.purchase_price, is_gift: o.is_gift, confidence: o.confidence || null,
      });
    };
    return { all: (records || []).map(add), front: front ? front.map(add) : null };
  }, [records, front]);
  const tierOf = (r) => (tierItems && r && tierItems[String(r.id)]) || null;
  const cards = useShMemo(() => (window.BBR_cards && state.phase === "ready")
    ? window.BBR_cards.cards({ records: cardRecs.all, front: cardRecs.front, chosen: !!frontChosen, tierOf,
                               counts: tiers, siteCounts, focusId: initialCard && initialCard.itemId, month })
    : [], [cardRecs, tierItems, tiers, siteCounts, frontChosen, state.phase, month]);
  const [cardView, setCardView] = useShState(null);   // { card, phase: drawing | ready | error, src, canvas }
  const [cardNote, setCardNote] = useShState("");
  const cardGen = useShRef(0);
  const cardUrl = useShRef(null);
  useShEffect(() => () => { if (cardUrl.current) URL.revokeObjectURL(cardUrl.current); }, []);
  const canShareFiles = (() => {
    try { return !!(navigator.canShare && typeof File === "function" && navigator.canShare({ files: [new File([""], "c.jpg", { type: "image/jpeg" })] })); }
    catch (e) { return false; }
  })();
  // The card is printed with the identity the sheet resolved, the same one the
  // link and the preview carry: the name for a public shelf, the alias for an
  // anonymous one.
  // The stored twins, kept across card opens so a second share reuses them.
  const twinCards = useShRef(null);
  const who = () => {
    const c = ctx.current || {};
    if (!twinCards.current) twinCards.current = Object.assign({}, (c.p && c.p.shelf_cards) || {});
    return {
      label, first: kind === "named" ? (String(label).split(/\s+/)[0] || label) : label,
      path: handle ? "/u/" + handle : (c.uid ? "/collector/" + c.uid : "/"),
      uid: c.uid || null, mode: kind, alias: (c.p && c.p.anon_alias) || null, cards: twinCards.current,
    };
  };
  async function openCard(card) {
    const my = ++cardGen.current;
    setCardNote("");
    setCardView({ card, phase: "drawing", src: null, canvas: null, twin: false });
    try {
      let c = card;
      // The Grail card quotes the pressing's Near Mint price when the tier row has it.
      if (c.kind === "grail" && c.data && c.data.rec && c.data.rec.id && !(c.data.nm > 0) && sb) {
        const t = await shTry(sb.rpc("my_item_tier", { p_item: c.data.rec.id }));
        if (t && t.data && t.data.ref_nm) c = Object.assign({}, c, { data: Object.assign({}, c.data, { nm: Number(t.data.ref_nm) }) });
      }
      const w = who();
      // The twin, in parallel: the card's link (?c=) unfurls as it once it is
      // recorded. Share waits for that, or 8s, as the shelf link waits for its
      // preview (the first scrape is the one a messaging app keeps).
      const twin = window.BBR_cards.storeTwin
        ? Promise.race([window.BBR_cards.storeTwin(c, w).catch(() => false), new Promise((res) => setTimeout(() => res(false), 8000))])
        : Promise.resolve(false);
      const canvas = await window.BBR_cards.render(c, w);
      const blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", 0.9));
      if (!alive.current || cardGen.current !== my) return;
      if (cardUrl.current) URL.revokeObjectURL(cardUrl.current);
      cardUrl.current = blob ? URL.createObjectURL(blob) : null;
      setCardView({ card: c, phase: "ready", src: cardUrl.current, canvas, twin: false });
      await twin;
      if (alive.current && cardGen.current === my) setCardView((v) => (v && v.card === c ? Object.assign({}, v, { twin: true }) : v));
    } catch (e) {
      if (alive.current && cardGen.current === my) setCardView({ card, phase: "error", src: null, canvas: null });
    }
  }
  async function sendCard() {
    if (!cardView || !cardView.canvas) return;
    const how = await window.BBR_cards.send(cardView.card, cardView.canvas, who());
    if (how === "download") setCardNote("Saved to your device. Post it to your story, and your link is on the card.");
  }
  // Straight onto a card, once, when the sheet was opened for one.
  const openedInitial = useShRef(false);
  useShEffect(() => {
    if (openedInitial.current || !initialCard || !cards.length) return;
    const c = cards.find((x) => x.kind === initialCard.kind);
    if (!c) return;
    openedInitial.current = true;
    openCard(c);
    // eslint-disable-next-line
  }, [cards]);
  const track = (method) => { try { window.BBR_ref && window.BBR_ref.track("shelf_share", { method, kind: kind || "unknown", entry: "sheet" }); } catch (e) {} };

  // Body lock, back gesture, Escape: as InviteFriend.
  useShEffect(() => {
    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); };
  }, []);
  // renamed.current = { from, to } after a link change. Correcting the address
  // inside the sheet only rewrote the entry the sheet pushed; closing it runs
  // history.back() onto the ORIGINAL /u/old entry, which then no longer
  // resolves. So the correction is made on that entry, once back() has landed.
  const renamed = useShRef(null);
  const fixAddress = () => {
    const r = renamed.current;
    if (!r) return;
    try {
      if (location.pathname.replace(/\/+$/, "").toLowerCase() === "/u/" + r.from) {
        history.replaceState(history.state, "", "/u/" + r.to + location.search + location.hash);
      }
    } catch (e) { /* harmless */ }
  };
  useShEffect(() => {
    history.pushState({ bbrShelfShare: true }, "", location.pathname + location.search + location.hash);
    let popped = false;
    const onPop = () => { popped = true; setTimeout(fixAddress, 0); onCloseRef.current(); };
    window.addEventListener("popstate", onPop);
    return () => {
      window.removeEventListener("popstate", onPop);
      if (!popped) {
        const after = () => { window.removeEventListener("popstate", after); fixAddress(); };
        if (renamed.current) window.addEventListener("popstate", after);
        history.back();
      }
    };
  }, []);
  useShEffect(() => {
    // An input that handles Escape itself (the link editor) marks it handled.
    const k = (e) => { if (e.key === "Escape" && !e.defaultPrevented) onCloseRef.current(); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, []);
  useShEffect(() => () => { if (blobUrl.current) URL.revokeObjectURL(blobUrl.current); }, []);

  // Handle, identity, link, then the image.
  useShEffect(() => {
    let live = true;
    (async () => {
      if (!sb) { setState({ phase: "error" }); return; }
      const sess = await shTry(sb.auth.getSession());
      const uid = sess && sess.data && sess.data.session && sess.data.session.user && sess.data.session.user.id;
      if (!uid) { setState({ phase: "error" }); return; }
      const eh = await shTry(sb.rpc("ensure_shelf_handle"));
      const h = eh && eh.data && eh.data.ok ? eh.data : null;
      const pr = await shTry(sb.from("profiles").select("display_name,is_public,anon_alias,shelf_og,shelf_cards").eq("id", uid).maybeSingle());
      const p = (pr && pr.data) || {};
      if (!live) return;
      const k = h ? h.kind : (p.is_public ? "named" : "anon");
      const lab = k === "named" ? (String(p.display_name || "").trim() || "A collector")
                                : (p.anon_alias ? "Anonymous " + p.anon_alias : "An anonymous collector");
      const path = h && h.handle ? "/u/" + h.handle : "/collector/" + uid;
      setKind(k); setLabel(lab); setHandle(h ? h.handle : null);
      const withRef = window.BBR_ref ? await window.BBR_ref.link(SH_SITE + path, "shelf") : SH_SITE + path;
      if (!live) return;
      setLink(withRef);
      setState({ phase: "ready" });
      ctx.current = { uid, lab, p, k };
      const first = gen.current + 1;
      const t = setTimeout(() => { if (alive.current && gen.current === first) setOgReady(true); }, 8000);
      await drawAndStore(uid, lab, path, p, k);
      clearTimeout(t);
      if (alive.current && gen.current === first) setOgReady(true);
    })();
    return () => { live = false; };
    // eslint-disable-next-line
  }, []);

  // Only the latest draw may set the image, record the preview or free the
  // buttons: a link change mid-draw started a second one, and whichever finished
  // last won, sometimes printing the dead address.
  const gen = useShRef(0);
  async function drawAndStore(uid, lab, path, p, k) {
    const my = ++gen.current;
    const current = () => alive.current && gen.current === my;
    const sig = shSig(lab, records, total, path, front, tiers);
    const og = p.shelf_og;
    const reusable = og && og.sig === sig && og.path &&
      ((k === "named" && og.mode === "named") || (k === "anon" && og.mode === "anon" && og.alias === p.anon_alias));
    if (reusable) {
      if (current()) setImg({ src: sb.storage.from("shelf-og").getPublicUrl(og.path).data.publicUrl, blob: null, drawing: false });
      return;
    }
    try {
      const canvas = await shelfShareRender({ label: lab, records, total, front, tiers, link: "theleadin.com" + path });
      // JPEG, not PNG. A shelf of real sleeve photos is ~630KB as PNG and ~105KB
      // as JPEG, and WhatsApp (the channel this is for) is widely reported to
      // skip preview images over ~300KB. The canvas is opaque (paper-filled), so
      // JPEG loses nothing. Stepped down if a busy shelf still comes out heavy.
      let blob = null;
      for (const q of [0.88, 0.78, 0.66]) {
        blob = await new Promise((res) => canvas.toBlob(res, "image/jpeg", q));
        if (!blob || blob.size <= SH_MAX_BYTES) break;
      }
      if (!blob) throw new Error("toBlob");
      if (!current()) return;
      if (blobUrl.current) URL.revokeObjectURL(blobUrl.current);
      blobUrl.current = URL.createObjectURL(blob);
      setImg({ src: blobUrl.current, blob, drawing: false });
      // Upload and record. A failure here costs the unfurl, not the share: the
      // link still works and falls back to the generic image.
      const name = uid + "/" + shRandomName() + ".jpg";
      // Recorded with the identity it was DRAWN for: set_shelf_og() refuses it
      // if the shelf has since switched between named and anonymous, or the
      // alias changed, so a named card can never be stamped onto an anonymous
      // shelf by a switch made while this was uploading (shelf_identity_guard.sql).
      if (!current()) return;
      const up = await sb.storage.from("shelf-og").upload(name, blob, { contentType: "image/jpeg", upsert: false, cacheControl: "31536000" });
      if (up.error) return;
      if (!current()) { shTry(sb.storage.from("shelf-og").remove([name])); return; }
      const stamp = await shTry(sb.rpc("set_shelf_og", {
        p_path: name, p_sig: sig, p_mode: k, p_alias: k === "anon" ? (p.anon_alias || null) : null,
        // The name the card shows: refused if the display name has changed since.
        p_name: k === "named" ? lab : null,
      }));
      const ok = stamp && !stamp.error && stamp.data && stamp.data.ok;
      // Tidy up: the image this one replaces, or this one if it was refused.
      const prev = lastPath.current || (og && og.path) || null;
      const stale = ok ? (prev && prev !== name ? prev : null) : name;
      if (ok) lastPath.current = name;
      if (stale) shTry(sb.storage.from("shelf-og").remove([stale]));
    } catch (e) {
      if (current()) setImg({ src: null, blob: null, drawing: false });
    }
  }
  const lastPath = useShRef(null);   // the image this sheet last recorded, for tidying the next

  async function doShare() {
    try {
      await navigator.share({ title: label + "’s shelf on The Lead-In", text: "My record collection on The Lead-In:", url: link });
      track("share");
    } catch (e) { /* cancelled */ }
  }
  async function doCopy() {
    try { await navigator.clipboard.writeText(link); setCopied(true); track("copy"); setTimeout(() => setCopied(false), 2200); }
    catch (e) { /* the field stays selectable */ }
  }
  async function doSaveImage() {
    let blob = img.blob;
    if (!blob && img.src) { try { blob = await (await fetch(img.src)).blob(); } catch (e) { blob = null; } }
    if (!blob) return;
    const file = typeof File === "function" ? new File([blob], "my-shelf.jpg", { type: "image/jpeg" }) : null;
    if (file && navigator.canShare && navigator.canShare({ files: [file] })) {
      try { await navigator.share({ files: [file] }); track("image"); return; }
      catch (e) { if (e && e.name === "AbortError") return; /* cancelled: no download. Anything else: fall through */ }
    }
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob); a.download = "my-shelf.jpg";
    document.body.appendChild(a); a.click(); a.remove();
    setTimeout(() => URL.revokeObjectURL(a.href), 4000);
    track("image");
  }
  async function saveHandle() {
    const v = String(draft || "").trim().toLowerCase();
    if (!v || v === handle) { setEditing(false); return; }
    setSaving(true); setEditErr("");
    const out = await shTry(sb.rpc("set_shelf_handle", { p_handle: v }));
    setSaving(false);
    const d = out && out.data;
    if (!d || !d.ok) { setEditErr(SH_ERR[(d && d.error) || ""] || "That didn't save. Try again."); return; }
    const old = handle;
    setHandle(d.handle); setEditing(false);
    const base = SH_SITE + "/u/" + d.handle;
    setLink(window.BBR_ref ? await window.BBR_ref.link(base, "shelf") : base);
    // The old address no longer resolves. Corrected on close (fixAddress), on
    // the page's own history entry, so a refresh doesn't say "no shelf here".
    if (old) renamed.current = { from: renamed.current ? renamed.current.from : old, to: d.handle };
    // And redraw: the image prints the address, and the old one is now dead.
    if (ctx.current) {
      setOgReady(false);
      setImg({ src: null, blob: null, drawing: true });
      const c = ctx.current;
      const my = gen.current + 1;
      const t = setTimeout(() => { if (alive.current && gen.current === my) setOgReady(true); }, 8000);
      await drawAndStore(c.uid, c.lab, "/u/" + d.handle, c.p, c.k);
      clearTimeout(t);
      if (alive.current && gen.current === my) setOgReady(true);
    }
  }

  const shortLink = handle ? "theleadin.com/u/" + handle : link.replace(/^https?:\/\/(www\.)?/, "").split("?")[0];

  return bbrOverlayPortal(
    <div className="av2-backdrop" onClick={onClose}>
      <div className="av2-modal inv-modal sh-modal" onClick={(e) => e.stopPropagation()}>
        <div className="av2-head">
          <div className="av2-head-left">
            <button className="av2-back-arrow" aria-label="Back" onClick={onClose}>
              <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 4l-5 6 5 6" /></svg>
            </button>
            <span className="av2-head-title">Share your shelf</span>
          </div>
          <button className="av2-x" aria-label="Close" onClick={onClose}>×</button>
        </div>
        <div className="av2-body">
          <div className="inv-pane">
            {state.phase === "error" ? (
              <p className="inv-lede">Sign in to share your shelf.</p>
            ) : cardView ? (
              <div className="sh-card-view">
                <button className="av2-back-link" onClick={() => { cardGen.current++; setCardView(null); setCardNote(""); }}>&larr; Your link</button>
                <div className={"sh-card-big" + (cardView.phase === "drawing" ? " is-drawing" : "")}>
                  {cardView.src
                    ? <img src={cardView.src} alt={cardView.card.title + " card for " + (label || "your") + " shelf, with its link"} />
                    : <span>{cardView.phase === "drawing" ? "Drawing your card\u2026" : "The card didn\u2019t draw. Try again in a moment."}</span>}
                </div>
                <p className="sh-as">
                  {kind === "anon"
                    ? <>The card shows your alias, <b>{label}</b>, and your shelf&rsquo;s link.</>
                    : <>The card shows your name, <b>{label}</b>, and your shelf&rsquo;s link.</>}
                </p>
                <button className="inv-share" onClick={sendCard} disabled={cardView.phase !== "ready" || !cardView.twin || !link}>
                  {cardView.phase === "ready" && !cardView.twin ? "Preparing link preview\u2026" : (canShareFiles ? "Share card" : "Save card")}
                </button>
                {cardNote && <p className="sh-card-note" role="status">{cardNote}</p>}
              </div>
            ) : (
              <>
                <div className={"sh-preview" + (img.drawing ? " is-drawing" : "")}>
                  {img.src
                    ? <img src={img.src} alt={"Preview of " + (label || "your") + " shelf: the nine most valuable records, the count and the value"} />
                    : <span>{img.drawing ? "Drawing your preview…" : "The preview didn’t draw. The link still works."}</span>}
                </div>
                <p className="sh-as">
                  {kind === "anon"
                    ? <>You&rsquo;re sharing as <b>{label}</b>. Your name isn&rsquo;t on the page, the link or the preview.</>
                    : kind === "named"
                    ? <>Your name is on this link and its preview: <b>{label}</b>. To share without it, switch your shelf to anonymous first.</>
                    : " "}
                </p>

                {canShare && (
                  <button className="inv-share" onClick={doShare} disabled={!link || !ogReady}>
                    <svg width="17" height="17" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                      <circle cx="13.5" cy="3.8" r="2.3" /><circle cx="4.5" cy="9" r="2.3" /><circle cx="13.5" cy="14.2" r="2.3" />
                      <path d="M6.5 7.8l5 -2.8M6.5 10.2l5 2.8" />
                    </svg>
                    {ogReady ? "Share link" : "Preparing preview\u2026"}
                  </button>
                )}

                <div className="inv-copy">
                  <div className="inv-copy-label">Your shelf&rsquo;s link</div>
                  {editing ? (
                    <div className="sh-edit">
                      <div className="inv-copy-row">
                        <span className="sh-prefix">theleadin.com/u/</span>
                        <input className="inv-copy-url" type="text" value={draft} autoFocus maxLength={30}
                               onChange={(e) => { setDraft(e.target.value.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/-{2,}/g, "-")); setEditErr(""); }}
                               onKeyDown={(e) => { if (e.key === "Enter") saveHandle(); if (e.key === "Escape") { e.preventDefault(); setEditing(false); } }}
                               aria-label="Your shelf's link" />
                        <button className="inv-copy-btn" onClick={saveHandle} disabled={saving}>{saving ? "Saving" : "Save"}</button>
                      </div>
                      {editErr && <div className="sh-err" role="alert">{editErr}</div>}
                    </div>
                  ) : (
                    <div className="inv-copy-row">
                      <input className="inv-copy-url" type="text" readOnly value={shortLink}
                             onFocus={(e) => e.target.select()} aria-label="Your shelf's link" />
                      <button className={"inv-copy-btn" + (copied ? " done" : "")} onClick={doCopy} disabled={!link || !ogReady}>{copied ? "Copied" : (ogReady ? "Copy" : "Preparing")}</button>
                    </div>
                  )}
                  <div className="sh-links">
                    {kind === "named" && !editing && (
                      <button className="sh-text-btn" onClick={() => { setDraft(handle || ""); setEditing(true); }}>Change link</button>
                    )}
                    <button className="sh-text-btn" onClick={doSaveImage} disabled={!img.src}>Save image for a story</button>
                  </div>
                </div>
                {cards.length > 0 && (
                  <div className="sh-cards">
                    <div className="inv-copy-label">Or post a card</div>
                    <div className="sh-card-row">
                      {cards.map((c) => (
                        <button key={c.kind} type="button" className={"sh-card-tile sh-card-" + c.kind} onClick={() => openCard(c)}>
                          <span className="sh-card-thumb" aria-hidden="true">
                            {/* Your ten shows four of its ten; the month shows the month (a
                                sleeve there repeated the Grail tile's); the others lead with
                                their record. */}
                            {c.kind === "month"
                              ? <span className="sh-card-cal"><b>{String(c.data.month.month || "").slice(0, 3)}</b><small>{String(c.data.month.from || "").slice(0, 4)}</small></span>
                              : window.Sleeve && (c.kind === "ten"
                                ? <span className="sh-card-grid">{(c.data.front || []).slice(0, 4).map((r, i) => <Sleeve key={i} album={shThumbAlbum(r)} size={40} />)}</span>
                                : c.cover && <Sleeve album={shThumbAlbum(c.cover)} size={64} />)}
                            <i /><i />
                          </span>
                          <span className="sh-card-t">{c.title}</span>
                          <span className="sh-card-s">{c.sub}</span>
                        </button>
                      ))}
                    </div>
                  </div>
                )}
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { ShelfShare, shelfShareRender, shShelfSig: shSig });
