// Vanday DAM — publish composer + crops grid + published view

// Tiny outlined aspect-ratio glyph used on the publish crop chips. Draws a
// max-15px rectangle in the chip's ratio so the shape reads at a glance.
function CropGlyph({ w, h }) {
  const max = 15;
  let bw, bh;
  if (w >= h) { bw = max; bh = Math.round((max * h) / w); }
  else { bh = max; bw = Math.round((max * w) / h); }
  return <span className="crop-glyph" style={{ width: bw, height: Math.max(bh, 5) }} />;
}

function CropsGrid({ asset, onOpenVariant, highlightId }) {
  const parent = getParent(asset);
  const siblings = parent ? VARIANTS.filter((v) => v.parentId === parent.id) : [];

  return (
    <div className="crops-grid">
      {siblings.map((v) => {
        const [rw, rh] = v.ratio.split(":").map(parseFloat);
        const aspect = `${rw} / ${rh}`;
        const isHere = highlightId === v.id;
        return (
          <div
            className="crop-card"
            key={v.id}
            style={{
              cursor: onOpenVariant ? "pointer" : "default",
              borderColor: isHere ? "var(--accent)" : undefined,
              boxShadow: isHere ? "0 0 0 2px var(--accent-soft)" : undefined,
            }}
            onClick={() => onOpenVariant && onOpenVariant(v)}
          >
            <div className={`crop-thumb ${parent?.hasAlpha ? "is-alpha" : ""}`} style={{ aspectRatio: aspect }}>
              <span className="ratio-tag">{v.ratio}</span>
              <img src={v.url} alt={`${v.name} ${v.ratio}`} />
            </div>
            <div className="crop-meta">
              <div style={{ minWidth: 0 }}>
                <div className="crop-name">{v.ratioName}</div>
                <div className="crop-size">{v.w} × {v.h}</div>
              </div>
              <button
                className="btn ghost sm"
                title="Download crop"
                onClick={(e) => {
                  // Don't bubble to the parent .crop-card click (which opens
                  // the variant). The click here is a discrete "save the
                  // generated crop file" action.
                  e.stopPropagation();
                  try {
                    // Real server-uploaded asset → hit /api/crop with
                    // download=1; the server generates (or returns cached)
                    // the actual cropped JPEG and forces a file save via
                    // Content-Disposition. ratio slug is encoded in the
                    // variant id as "<parentId>--<slug>" (see data.jsx).
                    if (parent && parent.isServer) {
                      const slug = (v.id.split("--")[1]) || "1x1";
                      const cropUrl = `/api/crop/${encodeURIComponent(parent.id)}?ratio=${encodeURIComponent(slug)}&w=1080&download=1`;
                      const a = document.createElement("a");
                      a.href = cropUrl;
                      a.style.display = "none";
                      document.body.appendChild(a);
                      a.click();
                      a.remove();
                      return;
                    }
                    // Demo/sample asset → no server crop to fetch; best-effort
                    // download of the original via VandayServer.downloadAsset
                    // so the button at least does something.
                    window.VandayServer?.downloadAsset?.(v);
                  } catch (err) {
                    console.warn("Crop download failed:", err);
                  }
                }}
              >
                <IcDownload size={13} />
              </button>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// Mark a publication as "deleted on the platform" — keeps the record in
// Vanday so the user's history is preserved, but shows the post as no
// longer live (grayed-out, no "View" link). Use case: the user manually
// took the post down on the social platform.
function MarkDeletedButton({ asset, pub }) {
  const [busy, setBusy] = React.useState(false);
  if (pub.status !== "live") return null;

  const click = async () => {
    const platform = (window.SITES || []).find((s) => s.id === pub.site)?.name || pub.site;
    const ok = window.confirm(
      `Mark this ${platform} post as deleted in Vanday?\n\n` +
      `Use this if you've already taken the post down on ${platform}. ` +
      `The Vanday record stays as a history entry.`,
    );
    if (!ok) return;
    setBusy(true);
    try {
      const r = await fetch(
        `/api/assets/${encodeURIComponent(asset.id)}/publications`,
        {
          method: "PATCH",
          headers: {
            "Content-Type": "application/json",
            ...(window.Clerk?.session ? { Authorization: `Bearer ${await window.Clerk.session.getToken()}` } : {}),
          },
          body: JSON.stringify({
            site: pub.site,
            platformPostId: pub.platformPostId || null,
            postedAt: pub.postedAt || null,
            status: "deleted",
          }),
        },
      );
      if (!r.ok) {
        const body = await r.json().catch(() => ({}));
        window.alert("Couldn't update: " + (body.error || `HTTP ${r.status}`));
        return;
      }
      const { publications } = await r.json();
      asset.publications = publications || [];
      try { VandayServer.bumpRev && VandayServer.bumpRev(); } catch {}
    } catch {
      window.alert("Couldn't reach the server. Try again in a moment.");
    } finally { setBusy(false); }
  };

  return (
    <button
      type="button"
      onClick={click}
      disabled={busy}
      title="Mark this post as deleted on the platform (keeps the Vanday record as history)"
      style={{
        border: "1px solid var(--border)", background: "var(--surface)",
        color: "var(--text-muted)", borderRadius: 6,
        padding: "4px 8px", fontSize: 11.5,
        cursor: busy ? "wait" : "pointer", flexShrink: 0,
      }}
    >
      I deleted it
    </button>
  );
}

// Removes a single publication entry from Vanday's record. Does NOT take the
// post down on the actual social platform — Upload-Post has no API for that.
// We make that distinction explicit in the confirmation prompt.
function RemovePubButton({ asset, pub }) {
  const [busy, setBusy] = React.useState(false);

  const remove = async () => {
    const platform = (window.SITES || []).find((s) => s.id === pub.site)?.name || pub.site;
    const ok = window.confirm(
      `Remove this ${platform} post from Vanday?\n\n` +
      `This only removes Vanday's record. The actual post will stay live on ${platform} ` +
      `until you delete it there.`,
    );
    if (!ok) return;
    setBusy(true);
    try {
      const r = await fetch(
        `/api/assets/${encodeURIComponent(asset.id)}/publications`,
        {
          method: "DELETE",
          headers: {
            "Content-Type": "application/json",
            ...(window.Clerk?.session ? { Authorization: `Bearer ${await window.Clerk.session.getToken()}` } : {}),
          },
          body: JSON.stringify({
            site: pub.site,
            platformPostId: pub.platformPostId || null,
            postedAt: pub.postedAt || null,
          }),
        },
      );
      if (!r.ok) {
        const body = await r.json().catch(() => ({}));
        window.alert("Couldn't remove: " + (body.error || `HTTP ${r.status}`));
        return;
      }
      const { remaining } = await r.json();
      asset.publications = remaining || [];
      try { VandayServer.bumpRev && VandayServer.bumpRev(); } catch {}
    } catch (err) {
      window.alert("Couldn't reach the server. Try again in a moment.");
    } finally {
      setBusy(false);
    }
  };

  return (
    <button
      type="button"
      onClick={remove}
      disabled={busy}
      title="Remove from Vanday (post stays live on the platform)"
      style={{
        border: "1px solid var(--border)", background: "var(--surface)",
        color: "var(--text-muted)", borderRadius: 6,
        padding: "4px 8px", fontSize: 11.5,
        cursor: busy ? "wait" : "pointer", flexShrink: 0,
      }}
    >
      <IcTrash size={11} /> Remove
    </button>
  );
}

// Some social posts come back Live from Upload-Post WITHOUT a permalink (the
// platform confirmed the post but didn't return its URL). Rather than leave
// those with no "View" link — while Shopify always has one — we fall back to a
// link to the connected account's profile on that platform, so the user can
// still click through to where the post lives. The profile URL is built
// SERVER-SIDE (it knows each platform's right handle field — Instagram needs
// the @handle, Facebook the numeric id, etc.), returned by the
// social-connections endpoint; cached at module level so we fetch it once.
let _socialProfiles = null;       // { instagram: "https://…/profile", ... }
let _socialProfilesLoading = null;
function loadSocialProfiles() {
  if (_socialProfiles) return Promise.resolve(_socialProfiles);
  if (_socialProfilesLoading) return _socialProfilesLoading;
  _socialProfilesLoading = (async () => {
    try {
      const t = window.Clerk?.session ? await window.Clerk.session.getToken() : null;
      const r = await fetch("/api/social/connections", { headers: t ? { Authorization: "Bearer " + t } : {} });
      const j = await r.json();
      const m = {};
      for (const [id, v] of Object.entries(j.platforms || {})) {
        if (v && v.status === "connected" && v.profileUrl) m[id] = v.profileUrl;
      }
      _socialProfiles = m;
      return m;
    } catch { _socialProfiles = {}; return {}; }
  })();
  return _socialProfilesLoading;
}

function PublicationList({ asset }) {
  VandayServer.useServerRev(); // re-render when publications change
  // Everyone can SEE where an asset went (incl. the Shopify link); only
  // publishers (admin/editor) can mutate the records.
  const canPublish = (typeof window.useVandayFeatures === "function")
    ? window.useVandayFeatures().canPublish : true;
  const pubs = asset.publications || [];
  // Load connected-account handles once, so live social posts that lack a
  // permalink can still link to the account's profile.
  const [profiles, setProfiles] = React.useState(_socialProfiles);
  React.useEffect(() => {
    if (_socialProfiles) { if (!profiles) setProfiles(_socialProfiles); return; }
    const needsFallback = pubs.some((p) =>
      p.site !== "shopify" && !p.url &&
      p.status !== "failed" && p.status !== "deleted" && p.status !== "pending");
    if (needsFallback) loadSocialProfiles().then(setProfiles);
  }, [asset.id]); // eslint-disable-line
  if (pubs.length === 0) {
    return (
      <div style={{ fontSize: 12.5, color: "var(--text-faint)", padding: "6px 0" }}>
        Not yet published. Use Publish to schedule a post.
      </div>
    );
  }
  return (
    <div>
      {pubs.map((p, i) => {
        const site = SITES.find((s) => s.id === p.site)
          || (window.DESTINATIONS && window.DESTINATIONS[p.site])
          || { id: p.site, name: p.site || "Unknown", short: "?", color: "var(--text-faint)" };
        const isPending = p.status === "pending";
        const isFailed = p.status === "failed";
        const isScheduled = p.status === "scheduled";
        const isDeleted = p.status === "deleted";
        const rowStyle = isFailed
          ? { borderColor: "oklch(0.85 0.07 24)", background: "oklch(0.97 0.02 24)" }
          : isDeleted
            ? { opacity: 0.6 }
            : undefined;
        return (
          <div className="pub-item" key={i} style={rowStyle}>
            <SiteMark site={site} size={20} />
            <div style={{ minWidth: 0, flex: 1 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
                <span className="pub-name" style={isDeleted ? { textDecoration: "line-through" } : undefined}>
                  {site.name}
                </span>
                <span
                  className={`pub-status ${isScheduled || isPending ? "scheduled" : ""}`}
                  style={
                    isFailed ? { background: "oklch(0.55 0.18 24)", color: "white" }
                    : isDeleted ? { background: "var(--text-faint)", color: "white" }
                    : undefined
                  }
                >
                  {isFailed ? <IcInfo size={10} />
                    : isPending ? <IcClock size={10} />
                    : isScheduled ? <IcClock size={10} />
                    : isDeleted ? <IcTrash size={10} />
                    : <IcCheck size={10} />}
                  {isFailed ? "Failed" : isPending ? "Publishing…" : isScheduled ? "Scheduled" : isDeleted ? "Deleted" : "Live"}
                </span>
              </div>
              <div className="pub-when" title={isFailed ? p.error : undefined}>
                {isFailed ? (p.error || "Failed to post")
                  : isDeleted ? `Deleted on platform · was posted ${p.postedAt}`
                  : p.postedAt}
              </div>
              {p.site === "shopify" && p.caption && !isFailed && (
                <div style={{ fontSize: 11.5, color: "var(--text-faint)", marginTop: 1 }}>on “{p.caption}”</div>
              )}
            </div>
            <div className="pub-actions">
              {(() => {
                // Prefer the real permalink; for a live social post that lacks
                // one, fall back to the connected account's profile so there's
                // always a way to click through. Shopify always has p.url, so
                // it's unaffected.
                const canFallback = p.site !== "shopify" && !isFailed && !isPending;
                const fallbackUrl = (!p.url && canFallback && profiles)
                  ? (profiles[p.site] || null)
                  : null;
                const viewUrl = p.url || fallbackUrl;
                if (!viewUrl || isDeleted) return null;
                return (
                  <a
                    className="pub-link"
                    href={viewUrl}
                    target="_blank"
                    rel="noreferrer"
                    title={p.url
                      ? `Open this post on ${site.name} in a new tab`
                      : `Open your ${site.name} profile — the exact post link wasn’t returned`}
                  >
                    View <IcExternal size={11} />
                  </a>
                );
              })()}
              {canPublish && <MarkDeletedButton asset={asset} pub={p} />}
              {canPublish && <RemovePubButton asset={asset} pub={p} />}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// Build a believable "live post" URL per site. These don't reach a real
// server — they just match each platform's URL shape so the "Open post"
// link inside the DAM looks like a real outbound link the user can verify.
function fakePublishUrl(siteId) {
  const slug = () => Math.random().toString(36).slice(2, 12);
  switch (siteId) {
    case "instagram": return `https://www.instagram.com/p/${slug().toUpperCase()}/`;
    case "facebook":  return `https://www.facebook.com/share/p/${slug()}/`;
    case "linkedin":  return `https://www.linkedin.com/feed/update/urn:li:share:${Date.now()}${Math.floor(Math.random() * 1000)}/`;
    case "x":         return `https://x.com/vanday/status/${Date.now()}`;
    case "tiktok":    return `https://www.tiktok.com/@vanday/video/${Date.now()}`;
    case "pinterest": return `https://www.pinterest.com/pin/${slug()}/`;
    default:          return `https://vanday.com/p/${slug()}`;
  }
}

// Platforms we'd pre-select for the user once we know they're connected,
// in priority order. We never auto-select an account that isn't connected.
const PREFERRED_DEFAULTS = ["instagram", "facebook", "linkedin"];

function PublishModal({ asset, onClose }) {
  // Start with nothing selected. Defaulting to a fixed list here caused a flash
  // where unconnected accounts briefly showed as selected before the
  // connection status loaded; instead we pick sensible defaults once we know
  // what's actually connected (see the init effect below).
  const [selected, setSelected] = React.useState([]);
  const [activeTab, setActiveTab] = React.useState(null);
  const [captions, setCaptions] = React.useState({
    instagram: "",
    x: "",
    linkedin: "",
    facebook: "",
    tiktok: "",
    pinterest: "",
  });
  const today = new Date();
  const defaultDate = today.toISOString().slice(0, 10);
  const [date, setDate] = React.useState(defaultDate);
  const [time, setTime] = React.useState("09:00");
  const [scheduleMode, setScheduleMode] = React.useState("now"); // 'now' | 'schedule'
  // After Publish is hit we flip to a success state with live links.
  // `null` until publish; then an array of { site, status, url, postedAt }.
  const [published, setPublished] = React.useState(null);
  const [publishing, setPublishing] = React.useState(false);
  // Background-publish poll: publishing returns immediately with "pending"
  // records; we poll the asset until they resolve to live/failed. The ref lets
  // us stop the loop if the modal closes mid-flight.
  const pollStop = React.useRef(false);
  React.useEffect(() => () => { pollStop.current = true; }, []);

  // Per-platform connection state from Upload-Post. null until loaded.
  // Shape: { instagram: "connected"|"not_connected", facebook: ..., ... }
  const [connStatus, setConnStatus] = React.useState(null);
  const [openingConnect, setOpeningConnect] = React.useState(false);
  // Which crop gets published. "auto" = send the full image and let each
  // platform crop as it likes; otherwise a ratio slug (e.g. "1x1", "16x9")
  // which we generate server-side and send to every selected destination.
  const [cropChoice, setCropChoice] = React.useState("auto");
  // Video can't be cropped server-side (Sharp is image-only), so we always
  // send the full video and let each platform auto-crop to its best aspect.
  // NOTE: this hook must live above the early `if (published)` return so the
  // hook order stays stable across renders (moving it below blanks the app).
  const isVideo = !!(window.assetIsVideo && window.assetIsVideo(asset));
  React.useEffect(() => {
    if (isVideo && cropChoice !== "auto") setCropChoice("auto");
  }, [isVideo, cropChoice]);
  const authHeaders = React.useCallback(async () => {
    try {
      const t = window.Clerk?.session ? await window.Clerk.session.getToken() : null;
      return t ? { Authorization: `Bearer ${t}` } : {};
    } catch { return {}; }
  }, []);
  const loadConnections = React.useCallback(async () => {
    try {
      const r = await fetch("/api/social/connections", { headers: await authHeaders() });
      const data = await r.json();
      const map = {};
      for (const [pid, info] of Object.entries(data?.platforms || {})) {
        map[pid] = info?.status || "unknown";
      }
      setConnStatus(map);
    } catch { setConnStatus({}); }
  }, [authHeaders]);
  React.useEffect(() => { loadConnections(); }, [loadConnections]);
  // If user comes back from the Upload-Post connect tab, refresh status.
  React.useEffect(() => {
    const onFocus = () => loadConnections();
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
  }, [loadConnections]);
  // Once we know which platforms are connected, drop any pre-selected
  // destination that isn't connected — users can only publish to connected
  // accounts, so they shouldn't stay checked.
  React.useEffect(() => {
    if (!connStatus) return;
    setSelected((sel) => {
      const next = sel.filter((id) => connStatus[id] === "connected");
      return next.length === sel.length ? sel : next;
    });
  }, [connStatus]);
  // Once we know what's connected, pre-select sensible defaults — the preferred
  // platforms that are connected, or (if none of those are) every connected
  // account. Runs once; after that the user's checkbox choices are respected.
  const didInitSelection = React.useRef(false);
  React.useEffect(() => {
    if (!connStatus || didInitSelection.current) return;
    didInitSelection.current = true;
    const connected = Object.keys(connStatus).filter((id) => connStatus[id] === "connected");
    const preferred = PREFERRED_DEFAULTS.filter((id) => connected.includes(id));
    const initial = preferred.length ? preferred : connected;
    if (initial.length) {
      setSelected(initial);
      setActiveTab(initial[0]);
    }
  }, [connStatus]);
  // Keep the active caption tab pointing at a platform that's still selected.
  // Without this, pruning unconnected platforms (above) can leave activeTab on
  // a dropped platform — e.g. the caption header reads "Caption for Instagram"
  // when Facebook is the only destination left.
  React.useEffect(() => {
    if (selected.length && !selected.includes(activeTab)) setActiveTab(selected[0]);
  }, [selected, activeTab]);

  const openConnectFlow = React.useCallback(async (platformArg) => {
    // Accepts a single platform id or an array of them. Passing several lets
    // the Upload-Post hosted page show all of them at once so the user can
    // authorize everything in one trip.
    const platforms = Array.isArray(platformArg) ? platformArg : [platformArg];
    if (platforms.length === 0) return;
    setOpeningConnect(true);
    try {
      const redirectUrl = `${window.location.origin}/?back_from=connect`;
      const r = await fetch("/api/social/connect-link", {
        method: "POST",
        headers: { "Content-Type": "application/json", ...(await authHeaders()) },
        body: JSON.stringify({ platforms, redirectUrl }),
      });
      const body = await r.json();
      if (!r.ok || !body.accessUrl) {
        window.alert(body.error === "social_at_capacity"
          ? (body.message || "Social publishing is temporarily at capacity while we add more seats. Please check back shortly.")
          : "Couldn't generate the connect link: " + (body.error || `HTTP ${r.status}`));
        setOpeningConnect(false);
        return;
      }
      // Navigate the whole tab (not a new tab) so the Upload-Post redirect
      // lands us right back on Vanday. We remember which asset's Publish
      // modal to reopen on return, and the remounted modal re-fetches the
      // connection status — so the just-connected platform shows as live.
      try { sessionStorage.setItem("vanday_publish_return", asset.id); } catch {}
      window.location.href = body.accessUrl;
    } catch {
      window.alert("Couldn't reach the server. Try again in a moment.");
      setOpeningConnect(false);
    }
  }, [authHeaders, asset.id]);

  // Pinterest needs a board to pin to. Fetched lazily when Pinterest is in
  // the selected platforms; cached for the rest of the modal lifetime.
  const [pinterestBoards, setPinterestBoards] = React.useState(null); // null = not loaded
  const [pinterestBoardId, setPinterestBoardId] = React.useState("");
  const [pinterestBoardsError, setPinterestBoardsError] = React.useState(null);
  React.useEffect(() => {
    if (!selected.includes("pinterest")) return;
    if (pinterestBoards !== null) return;
    (async () => {
      try {
        const r = await fetch("/api/social/pinterest-boards", {
          headers: window.Clerk?.session
            ? { Authorization: `Bearer ${await window.Clerk.session.getToken()}` }
            : {},
        });
        const data = await r.json();
        if (!r.ok) {
          setPinterestBoardsError(data?.detail?.message || data?.error || "Couldn't load Pinterest boards");
          setPinterestBoards([]);
          return;
        }
        // Upload-Post returns { boards: [...] } or just an array — handle both.
        const boards = Array.isArray(data) ? data : (data.boards || data.results || []);
        setPinterestBoards(boards);
        if (boards.length > 0 && !pinterestBoardId) setPinterestBoardId(boards[0].id);
      } catch (err) {
        setPinterestBoardsError("Couldn't reach the server.");
        setPinterestBoards([]);
      }
    })();
  }, [selected, pinterestBoards, pinterestBoardId]);

  const toggle = (id) => {
    setSelected((sel) => {
      const next = sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
      // keep activeTab valid
      if (!next.includes(activeTab) && next.length) setActiveTab(next[0]);
      return next;
    });
  };

  const activeSite = SITES.find((s) => s.id === activeTab) || SITES[0];
  const caption = captions[activeTab] || "";
  const captionMax = activeSite.caption.max;
  const remaining = captionMax - caption.length;
  const counterClass =
    remaining < 0 ? "over" : remaining < captionMax * 0.1 ? "warn" : "";

  // Poll the asset until this publish batch's pending records resolve, then
  // update the success view + in-memory asset. Gives up quietly after ~60s
  // (the post still finishes server-side; it'll show resolved next time the
  // asset is opened). Stops immediately if the modal closes.
  const pollPublishBatch = (batchId, assetIdToPoll) => {
    pollStop.current = false;
    let tries = 0;
    const tick = async () => {
      if (pollStop.current || tries++ > 24) return;
      try {
        const r = await fetch(`/api/assets/${encodeURIComponent(assetIdToPoll)}`, {
          headers: { ...(window.Clerk?.session ? { Authorization: `Bearer ${await window.Clerk.session.getToken()}` } : {}) },
        });
        if (r.ok) {
          const a = await r.json();
          const batch = (a.publications || []).filter((p) => p.batchId === batchId);
          if (batch.length && batch.every((p) => p.status !== "pending")) {
            asset.publications = a.publications;
            try { VandayServer.bumpRev && VandayServer.bumpRev(); } catch {}
            if (!pollStop.current) setPublished(batch);
            return; // resolved — stop polling
          }
        }
      } catch {/* transient — keep trying */}
      if (!pollStop.current) setTimeout(tick, 2500);
    };
    setTimeout(tick, 2500);
  };

  // Push the asset to the selected social platforms via our /api/publish
  // endpoint (which proxies to Upload-Post). The server records the result
  // in publications_json and returns the per-platform outcome, which we use
  // to drive the success view — including any per-platform failures.
  const doPublish = async () => {
    if (selected.length === 0) return;
    setPublishing(true);
    const isNow = scheduleMode === "now";
    // Combine per-platform captions into a single "title" the server sends.
    // Upload-Post supports a shared caption; per-platform tailoring would
    // need its own X-specific / FB-specific fields. For v1 we send the
    // first non-empty caption (or the active tab's) as the shared title.
    const sharedCaption =
      Object.values(captions).find((c) => c && c.trim()) ||
      captions[activeTab] || "";

    // Per-platform captions: build a map of just the selected platforms so
    // each can have its own copy (X stays under 280, LinkedIn can be longer,
    // etc.). The server uses these to set Upload-Post's per-platform title
    // fields like `x_title` for X. Anything not customized falls back to
    // the shared caption.
    const perPlatformCaptions = {};
    for (const id of selected) {
      const c = captions[id];
      if (c && c.trim()) perPlatformCaptions[id] = c;
    }

    // Resolve the crop to publish. "auto" sends the full original (each
    // platform crops as it likes); a specific ratio sends that crop variant
    // id ("<parentId>--<ratio>") which the server generates on the fly.
    const parentId = asset.parentId || String(asset.id).replace(/--.+$/, "");
    const publishAssetId = cropChoice === "auto" ? parentId : `${parentId}--${cropChoice}`;

    let newPubs = [];
    try {
      const r = await fetch("/api/publish", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...(window.Clerk?.session ? { Authorization: `Bearer ${await window.Clerk.session.getToken()}` } : {}),
        },
        body: JSON.stringify({
          assetId: publishAssetId,
          caption: sharedCaption,
          captions: perPlatformCaptions,
          platforms: selected,
          pinterestBoardId: selected.includes("pinterest") ? pinterestBoardId : null,
          // scheduled_date passed through when the user picked Schedule:
          scheduledDate: isNow ? null : new Date(`${date}T${time}:00`).toISOString(),
        }),
      });
      const data = await r.json();
      if (!r.ok) {
        // Plan paywall — server tells us which plan to upgrade to. Pop the
        // global paywall modal and bail out of the publish flow before
        // recording any failed pubs (the action just didn't run).
        if (r.status === 403 && data.error === "publishing_not_available" && data.upgradeTo) {
          window.openVandayPaywall?.({
            title: "Social publishing is on Growth",
            plan: data.plan,
            upgradeTo: data.upgradeTo,
            message: data.message,
          });
          return; // exit handler — no published records to render
        }
        // Surface a single "failed" pub per selected platform so the user
        // sees what went wrong instead of a silent close. For 429 quota
        // errors we also include the usage stats Upload-Post returned.
        let errMsg = data.message || data.error || `Server returned ${r.status}`;
        if (data.usage) {
          errMsg += ` (Upload-Post usage: ${data.usage.count}/${data.usage.limit})`;
        }
        newPubs = selected.map((siteId) => ({
          site: siteId, status: "failed", postedAt: new Date().toLocaleString(),
          url: null, caption: captions[siteId] || "", error: errMsg,
        }));
      } else {
        newPubs = data.publications || [];
        // Keep the in-memory asset in sync so the preview's Publications
        // tab re-renders without a full reload.
        asset.publications = [...(asset.publications || []), ...newPubs];
        try { VandayServer.bumpRev && VandayServer.bumpRev(); } catch {}
        // Background publish: records come back "pending". Poll the asset until
        // they resolve so the success view flips from "Publishing…" to the
        // final Live / Failed state on its own.
        if (data.pending && data.batchId) {
          pollPublishBatch(data.batchId, parentId);
        }
      }
    } catch (err) {
      newPubs = selected.map((siteId) => ({
        site: siteId, status: "failed", postedAt: new Date().toLocaleString(),
        url: null, caption: captions[siteId] || "",
        error: "Couldn't reach the server. Try again in a moment.",
      }));
    }
    setPublished(newPubs);
    setPublishing(false);
  };

  // ---------- Success view ----------
  if (published) {
    const liveCount = published.filter((p) => p.status === "live").length;
    const failedCount = published.filter((p) => p.status === "failed").length;
    const scheduledCount = published.filter((p) => p.status === "scheduled").length;
    const pendingCount = published.filter((p) => p.status === "pending").length;

    // Name the actual platforms ("Facebook", "Facebook and Instagram") rather
    // than a bare count ("1 site"), which reads awkwardly and tells the user
    // less than the names do.
    const nameOf = (id) => (SITES.find((s) => s.id === id) || {}).name || id || "the site";
    const joinNames = (arr) => {
      if (arr.length <= 1) return arr[0] || "";
      if (arr.length === 2) return `${arr[0]} and ${arr[1]}`;
      return `${arr.slice(0, -1).join(", ")} and ${arr[arr.length - 1]}`;
    };
    const liveNames = published.filter((p) => p.status === "live").map((p) => nameOf(p.site));
    const failedNames = published.filter((p) => p.status === "failed").map((p) => nameOf(p.site));
    const scheduledNames = published.filter((p) => p.status === "scheduled").map((p) => nameOf(p.site));
    const pendingNames = published.filter((p) => p.status === "pending").map((p) => nameOf(p.site));

    // Title reflects what actually happened, not what was requested:
    //   - all failed   → "Couldn't post to Facebook"
    //   - all live     → "Posted to Facebook and Instagram"
    //   - all scheduled→ "Scheduled for Facebook"
    //   - mixed        → "Posted to Facebook · 1 failed"
    let title;
    if (pendingCount > 0) {
      // Still sending in the background — the rows below flip to Live/Failed
      // as each platform confirms (or when the user reopens the asset).
      title = `Publishing to ${joinNames(pendingNames)}…`;
    } else if (liveCount === 0 && failedCount > 0 && scheduledCount === 0) {
      title = `Couldn't post to ${joinNames(failedNames)}`;
    } else if (scheduledCount === published.length) {
      title = `Scheduled for ${joinNames(scheduledNames)}`;
    } else if (failedCount === 0) {
      title = `Posted to ${joinNames(liveNames)}`;
    } else {
      title = `Posted to ${joinNames(liveNames)} · ${failedCount} failed`;
    }

    return (
      <div className="modal-backdrop" onClick={onClose}>
        <div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 520 }}>
          <header className="modal-head">
            <div>
              <h2 className="modal-title">
                {title}
              </h2>
              <div style={{ fontSize: 12.5, color: "var(--text-muted)", marginTop: 2 }}>
                <span style={{ fontFamily: "var(--font-mono)" }}>{asset.name}</span>
              </div>
            </div>
            <button className="btn ghost sm" onClick={onClose} title="Close"><IcClose size={16} /></button>
          </header>
          <div className="modal-body" style={{ display: "block", padding: 20 }}>
            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
              {published.map((p, i) => {
                const site = SITES.find((s) => s.id === p.site) || { id: p.site, name: p.site || "Unknown", color: "var(--text-faint)" };
                const isPending = p.status === "pending";
                const isFailed = p.status === "failed";
                const isScheduled = p.status === "scheduled";
                const isLive = !isPending && !isFailed && !isScheduled;
                return (
                  <div
                    key={i}
                    className="pub-item"
                    style={{
                      padding: "10px 12px",
                      border: `1px solid ${isFailed ? "oklch(0.85 0.07 24)" : "var(--border)"}`,
                      background: isFailed ? "oklch(0.97 0.02 24)" : undefined,
                      borderRadius: 10,
                      display: "flex",
                      alignItems: "center",
                      gap: 10,
                      flexWrap: "wrap",
                    }}
                  >
                    <SiteMark site={site} size={22} />
                    <div style={{ minWidth: 0, flex: 1 }}>
                      <div className="pub-name">{site.name}</div>
                      <div className="pub-when" style={{ color: isFailed ? "oklch(0.45 0.16 24)" : undefined }}>
                        {isFailed ? (p.error || "Upload-Post couldn't post this") : isPending ? "Sending to your account…" : p.postedAt}
                      </div>
                      {/* Secondary note lives under the timestamp so it never
                          collides with the status badge / action buttons. */}
                      {isLive && !p.url ? (
                        <div style={{ fontSize: 11.5, color: "var(--text-faint)", marginTop: 2 }}>
                          Posted — check your account for the link
                        </div>
                      ) : isScheduled && !p.url ? (
                        <div style={{ fontSize: 11.5, color: "var(--text-faint)", marginTop: 2 }}>
                          Publishes on schedule
                        </div>
                      ) : null}
                    </div>
                    <span
                      className={`pub-status ${isScheduled || isPending ? "scheduled" : ""}`}
                      style={isFailed ? { background: "oklch(0.55 0.18 24)", color: "white", flexShrink: 0 } : { flexShrink: 0 }}
                    >
                      {isFailed ? <IcInfo size={10} /> : isPending ? <IcClock size={10} /> : isScheduled ? <IcClock size={10} /> : <IcCheck size={10} />}
                      {isFailed ? "Failed" : isPending ? "Publishing…" : isScheduled ? "Scheduled" : "Live"}
                    </span>
                    {p.url ? (
                      <a
                        className="btn sm"
                        href={p.url}
                        target="_blank"
                        rel="noreferrer"
                        style={{ flexShrink: 0 }}
                        title={`Open this post on ${site.name} in a new tab`}
                      >
                        <IcExternal size={12} /> Open post
                      </a>
                    ) : null}
                    {!isPending && <MarkDeletedButton asset={asset} pub={p} />}
                    {!isPending && <RemovePubButton asset={asset} pub={p} />}
                  </div>
                );
              })}
            </div>
          </div>
          <footer className="modal-foot">
            <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
              {pendingCount > 0
                ? <>You can close this — publishing keeps going in the background.</>
                : <>Also visible in <strong>Published</strong> in the sidebar.</>}
            </div>
            <button className="btn accent" onClick={onClose}>Done</button>
          </footer>
        </div>
      </div>
    );
  }

  // ---------- Destination rows + grouped sections ----------
  // A single selectable destination row (checkbox, mark, name, connection
  // indicator). Shared by the loading flat-list and the two grouped sections.
  const renderSiteRow = (s) => {
    const isConnected = connStatus?.[s.id] === "connected";
    // Selectable while we're still loading status, or once it's confirmed
    // connected. Unconnected platforms can't be checked — the user must
    // connect them first (via the Connect button on the row).
    const selectable = connStatus == null || isConnected;
    const sel = selected.includes(s.id);
    return (
      <div
        key={s.id}
        className={`pub2-site ${sel ? "is-sel" : ""}`}
        onClick={selectable ? () => toggle(s.id) : undefined}
        style={selectable ? undefined : { cursor: "default" }}
        title={selectable ? undefined : `${s.name} isn't connected — connect it to publish here`}
      >
        <span
          className={`pub-check ${sel ? "checked" : ""}`}
          style={selectable ? undefined : { opacity: 0.3 }}
        >
          {sel && <IcCheck size={11} />}
        </span>
        <SiteMark site={s} size={24} />
        <span className="pub2-site-name">{s.name}</span>
        <span className="pub2-site-tail">
          {connStatus == null ? (
            <span className="meta" style={{ fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--text-faint)" }}>
              {s.caption.max} chr
            </span>
          ) : isConnected ? (
            <span className="pub2-ok" title={`${s.name} is connected`}><IcCheck size={15} /></span>
          ) : (
            <>
              <button
                type="button"
                className="pub2-connect-link"
                onClick={(e) => { e.stopPropagation(); openConnectFlow(s.id); }}
                disabled={openingConnect}
              >
                Connect
              </button>
              <span className="pub2-warn" title={`${s.name} isn't connected`} style={{ marginLeft: 8 }}>
                <IcInfo size={15} />
              </span>
            </>
          )}
        </span>
      </div>
    );
  };

  // Group header: label + a monospaced count badge.
  const renderSiteGroup = (title, count) => (
    <div className="pub2-group">{title}<span className="count">{count}</span></div>
  );

  const connectedSites = SITES.filter((s) => connStatus?.[s.id] === "connected");
  const notConnectedSites = SITES.filter((s) => connStatus && (connStatus[s.id] || "not_connected") !== "connected");

  // Available crop ratios, deduped across platforms. Each maps to a server
  // crop the user can publish everywhere.
  const cropOptions = (window.UNIQUE_RATIOS || []).map((r) => ({
    slug: window.shortRatioSlug ? window.shortRatioSlug(r.r) : String(r.r).replace(":", "x"),
    label: r.name,
    ratio: r.r,
    w: r.w,
    h: r.h,
  }));
  const activeCrop = cropOptions.find((c) => c.slug === cropChoice) || null;

  // True once we know at least one platform is connected. While connStatus is
  // still loading (null) this stays false, so we never let a publish fire
  // before we know there's somewhere to send it.
  const anyConnected = !!connStatus && Object.values(connStatus).some((v) => v === "connected");
  // Connection status has loaded and nothing is wired up — show the centered
  // connect prompt instead of the compose form, and disable Publish.
  const showConnectPrompt = !!connStatus && !anyConnected;

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal pub2-modal" onClick={(e) => e.stopPropagation()}>
        {(() => {
          // Unconnected platforms among the user's current selection. Drives
          // both the inline header-pill and which platform the click-to-fix
          // action opens.
          const unconnected = connStatus
            ? selected.filter((id) => (connStatus[id] || "not_connected") !== "connected")
            : [];
          return (
            <header className="pub2-head">
              <div className="pub2-head-main">
                <h2 className="pub2-title">Publish to social</h2>
                <div className="pub2-sub">
                  <span className="pub2-file" title={asset.name}>{asset.name}</span>
                  <span className="dot" />
                  <span className="nowrap">{selected.length} selected</span>
                  {unconnected.length > 0 && (
                    <>
                      <span className="dot" />
                      <button
                        type="button"
                        className="pub2-connect"
                        onClick={() => openConnectFlow(unconnected)}
                        disabled={openingConnect}
                        title={
                          unconnected.length === 1
                            ? `${SITES.find((s) => s.id === unconnected[0])?.name || "1 destination"} isn't connected yet — click to authorize Vanday`
                            : `${unconnected.length} destinations aren't connected yet — click to start authorizing`
                        }
                      >
                        <IcInfo size={13} /> {unconnected.length} to connect
                      </button>
                    </>
                  )}
                </div>
              </div>
              <button className="btn ghost sm" onClick={onClose} title="Close">
                <IcClose size={16} />
              </button>
            </header>
          );
        })()}

        <div className="pub2-body">
          <aside className="pub2-dests">
            {connStatus === null ? (
              // Still loading connection status — show a flat list so the
              // user can start selecting destinations immediately.
              <>
                {renderSiteGroup("Destinations", SITES.length)}
                {SITES.map((s) => renderSiteRow(s))}
              </>
            ) : (
              <>
                {renderSiteGroup("Connected", connectedSites.length)}
                {connectedSites.length === 0
                  ? <div className="pub2-empty">Nothing connected yet — connect a platform below.</div>
                  : connectedSites.map((s) => renderSiteRow(s))}
                {renderSiteGroup("Not connected", notConnectedSites.length)}
                {notConnectedSites.length === 0
                  ? <div className="pub2-empty">Everything's connected. Nice.</div>
                  : notConnectedSites.map((s) => renderSiteRow(s))}
              </>
            )}
          </aside>

          <section className="pub2-compose">
            {showConnectPrompt ? (
              <div
                style={{
                  display: "flex", flexDirection: "column",
                  alignItems: "center", justifyContent: "center",
                  textAlign: "center", gap: 14,
                  padding: "48px 24px", minHeight: 320, height: "100%",
                }}
              >
                <div
                  style={{
                    width: 56, height: 56, borderRadius: 14,
                    display: "flex", alignItems: "center", justifyContent: "center",
                    background: "var(--accent-soft, oklch(0.94 0.04 38))",
                    color: "var(--accent, #c8553d)",
                  }}
                >
                  <IcLink size={26} />
                </div>
                <div style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>
                  Connect an account to publish
                </div>
                <div style={{ fontSize: 13, lineHeight: 1.5, color: "var(--text-muted)", maxWidth: 360 }}>
                  No social accounts are connected yet. Connect at least one to start publishing from Vanday.
                </div>
                <button
                  className="btn accent"
                  disabled={openingConnect}
                  onClick={() => openConnectFlow(selected.length ? selected : SITES.map((s) => s.id))}
                >
                  <IcLink size={14} /> {openingConnect ? "Opening…" : "Connect an account"}
                </button>
              </div>
            ) : (
            <>
            <div className="pub2-banner">
              {isVideo ? (
                <video
                  className="pub2-banner-thumb"
                  src={asset.url}
                  muted
                  playsInline
                  preload="metadata"
                  style={{ objectFit: "cover", background: "#000" }}
                />
              ) : (
                <div
                  className="pub2-banner-thumb"
                  style={{
                    backgroundImage: `url(${asset.url})`,
                    ...(activeCrop ? { aspectRatio: `${activeCrop.w} / ${activeCrop.h}` } : {}),
                  }}
                />
              )}
              <div style={{ minWidth: 0 }}>
                <div className="pub2-banner-h">
                  <IcSparkles className="ic" size={15} />
                  {isVideo
                    ? "Best crop for each platform"
                    : activeCrop ? `${activeCrop.label} crop · ${activeCrop.ratio}` : "Best crop for each platform"}
                </div>
                <p className="pub2-banner-p">
                  {isVideo
                    ? "Vanday sends the full video and lets each platform crop it to their best aspect ratio automatically."
                    : activeCrop
                    ? "Every selected destination gets this crop, framed around the subject."
                    : "Vanday sends the full image and lets each platform crop to its best aspect — or pick a single crop below."}
                </p>
              </div>
            </div>

            {/* Crop chooser — pick one framing for all destinations, or let
                Vanday decide. Maps to the server's per-crop publish path.
                Hidden for video: Sharp can't crop video, so we always send the
                full file and let platforms auto-crop. */}
            {!isVideo && (
            <div>
              <div className="pub2-section-label">Crop to publish</div>
              <div className="pub2-crops">
                <button
                  type="button"
                  onClick={() => setCropChoice("auto")}
                  className={`pub2-crop smart ${cropChoice === "auto" ? "is-on" : ""}`}
                >
                  <IcSparkles size={14} /> Select best crop for me
                </button>
                {cropOptions.map((c) => (
                  <button
                    key={c.slug}
                    type="button"
                    onClick={() => setCropChoice(c.slug)}
                    className={`pub2-crop ${cropChoice === c.slug ? "is-on" : ""}`}
                    title={`${c.label} · ${c.ratio}`}
                  >
                    <CropGlyph w={c.w} h={c.h} />
                    {c.label}
                    <span className="ratio">{c.ratio}</span>
                  </button>
                ))}
              </div>
            </div>
            )}

            {selected.length === 0 ? (
              <div style={{
                padding: 30, textAlign: "center",
                color: "var(--text-faint)", fontSize: 13,
                border: "1px dashed var(--border)", borderRadius: 10,
              }}>
                {connStatus === null
                  ? "Loading your connected accounts…"
                  : "Select at least one destination on the left."}
              </div>
            ) : (
              <div>
                <div className="pub2-tabs">
                  {selected.map((id) => {
                    const s = SITES.find((x) => x.id === id);
                    return (
                      <button
                        key={id}
                        className={`pub2-tab ${activeTab === id ? "is-on" : ""}`}
                        onClick={() => setActiveTab(id)}
                      >
                        <SiteMark site={s} size={16} /> {s.name}
                      </button>
                    );
                  })}
                </div>

                <div className="pub2-caption-head">
                  <span className="pub2-section-label" style={{ marginBottom: 0 }}>
                    Caption for {activeSite.name}
                  </span>
                  <span className={`pub2-counter ${counterClass}`}>
                    {caption.length} / {captionMax}
                  </span>
                </div>
                <textarea
                  className="pub-textarea"
                  placeholder={
                    activeSite.id === "x"
                      ? "What's the post? (280 max)"
                      : activeSite.id === "instagram"
                      ? "Write a caption… #hashtags work here"
                      : `Write your ${activeSite.name} post…`
                  }
                  value={caption}
                  onChange={(e) =>
                    setCaptions((c) => ({ ...c, [activeTab]: e.target.value }))
                  }
                />
                <div className="pub-meta-row" style={{ marginTop: 8 }}>
                  <span>Crop: {activeSite.ratios.map((r) => r.r).join(" · ")}</span>
                </div>

                {activeTab === "pinterest" && (
                  <div style={{ marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--border)" }}>
                    <div className="pub2-section-label">Pin to board</div>
                    {pinterestBoards === null ? (
                      <div style={{ fontSize: 13, color: "var(--text-faint)" }}>Loading boards…</div>
                    ) : pinterestBoards.length === 0 ? (
                      <div style={{ fontSize: 13, color: "oklch(0.55 0.18 24)" }}>
                        {pinterestBoardsError || "No Pinterest boards found. Create one on pinterest.com first, then reload."}
                      </div>
                    ) : (
                      <select
                        className="nf-select"
                        value={pinterestBoardId}
                        onChange={(e) => setPinterestBoardId(e.target.value)}
                      >
                        {pinterestBoards.map((b) => (
                          <option key={b.id} value={b.id}>{b.name || b.id}</option>
                        ))}
                      </select>
                    )}
                  </div>
                )}
              </div>
            )}

            {/* Scheduling isn't live yet — the server publishes immediately, so
                showing a date/time picker would mislead users into thinking a
                post was queued for later when it actually went out now. Until
                real scheduling ships, the modal only offers "Post now". */}
            <div className="pub-schedule">
              <IcCalendar size={16} style={{ color: "var(--text-muted)" }} />
              <span style={{ fontWeight: 600 }}>Posts now</span>
              <span style={{ fontSize: 12, color: "var(--text-faint)" }}>
                Scheduling is coming soon.
              </span>
            </div>
            </>
            )}
          </section>
        </div>

        <footer className="modal-foot">
          <div style={{ fontSize: 12, color: "var(--text-muted)" }}>
            Posts go only to your workspace's own connected accounts — no one outside your workspace can publish to them.
          </div>
          <div style={{ display: "flex", gap: 8 }}>
            <button className="btn" onClick={onClose}>Cancel</button>
            <button
              className="btn accent"
              disabled={selected.length === 0 || publishing || !anyConnected}
              title={!anyConnected ? "Connect a social account first" : undefined}
              onClick={doPublish}
            >
              {publishing ? (
                <>Publishing…</>
              ) : scheduleMode === "now" ? (
                <><IcShare size={14} /> Publish to {selected.length}</>
              ) : (
                <><IcClock size={14} /> Schedule {selected.length} {selected.length === 1 ? "post" : "posts"}</>
              )}
            </button>
          </div>
        </footer>
      </div>
    </div>
  );
}

// ----- Published view (a sidebar destination in the library) -----
function PublishedView({ onOpenAsset, onNav, onBack }) {
  VandayServer.useServerRev(); // re-render when publications change
  const [activeSites, setActiveSites] = React.useState([]); // empty = all
  const [query, setQuery] = React.useState("");
  const [status, setStatus] = React.useState("all"); // all | live | scheduled

  // Whether this plan can post to socials at all. Beta has publishing ON, so
  // the upgrade path below never shows in beta — it's here for when paid
  // tiers gate publishing.
  const limits = (typeof window.useVandayLimits === "function") ? window.useVandayLimits() : null;
  const activePlanLabel = { free_beta: "Free Beta", starter: "Starter", growth: "Growth", pro: "Pro" }[limits?.planName] || "Free Beta";
  const features = (typeof window.useVandayFeatures === "function") ? window.useVandayFeatures() : null;
  // Plan must allow publishing AND the member's role must permit it (admins and
  // editors only — viewers and contributors can't push to socials).
  const canPublish = (limits ? limits.publishingEnabled !== false : true)
    && (features ? features.canPublish : true);

  // Per-platform connection state from Upload-Post (null until loaded), so the
  // empty state can prompt the user to connect an account when none are wired.
  const [connStatus, setConnStatus] = React.useState(null);
  const [openingConnect, setOpeningConnect] = React.useState(false);
  const authHeaders = React.useCallback(async () => {
    try {
      const t = window.Clerk?.session ? await window.Clerk.session.getToken() : null;
      return t ? { Authorization: `Bearer ${t}` } : {};
    } catch { return {}; }
  }, []);
  const loadConnections = React.useCallback(async () => {
    try {
      const r = await fetch("/api/social/connections", { headers: await authHeaders() });
      const data = await r.json();
      const map = {};
      for (const [pid, info] of Object.entries(data?.platforms || {})) {
        map[pid] = info?.status || "unknown";
      }
      setConnStatus(map);
    } catch { setConnStatus({}); }
  }, [authHeaders]);
  React.useEffect(() => { loadConnections(); }, [loadConnections]);
  React.useEffect(() => {
    const onFocus = () => loadConnections();
    window.addEventListener("focus", onFocus);
    return () => window.removeEventListener("focus", onFocus);
  }, [loadConnections]);
  const anyConnected = !!connStatus && Object.values(connStatus).some((v) => v === "connected");

  // Open the Upload-Post hosted connect page for every platform at once, then
  // come back to Vanday (the focus listener refreshes status on return).
  const openConnectFlow = React.useCallback(async () => {
    setOpeningConnect(true);
    try {
      const redirectUrl = `${window.location.origin}/?back_from=connect`;
      const r = await fetch("/api/social/connect-link", {
        method: "POST",
        headers: { "Content-Type": "application/json", ...(await authHeaders()) },
        body: JSON.stringify({ platforms: SITES.map((s) => s.id), redirectUrl }),
      });
      const body = await r.json();
      if (!r.ok || !body.accessUrl) {
        window.alert(body.error === "social_at_capacity"
          ? (body.message || "Social publishing is temporarily at capacity while we add more seats. Please check back shortly.")
          : "Couldn't generate the connect link: " + (body.error || `HTTP ${r.status}`));
        setOpeningConnect(false);
        return;
      }
      window.location.href = body.accessUrl;
    } catch {
      window.alert("Couldn't reach the server. Try again in a moment.");
      setOpeningConnect(false);
    }
  }, [authHeaders]);

  const published = ASSETS
    .filter((a) => a.publications && a.publications.length)
    .flatMap((a) =>
      a.publications.map((p) => ({ asset: a, pub: p, site: SITES.find((s) => s.id === p.site) }))
    )
    // Social channels only — non-social destinations (Shopify, etc.) live under
    // their own filter, not in "Published".
    .filter((row) => SITES.some((s) => s.id === row.pub.site))
    .filter((row) => (activeSites.length ? activeSites.includes(row.pub.site) : true))
    .filter((row) => (status === "all" ? true : row.pub.status === status))
    .filter((row) =>
      query
        ? (row.pub.caption || "").toLowerCase().includes(query.toLowerCase()) ||
          row.asset.name.toLowerCase().includes(query.toLowerCase()) ||
          row.asset.tags.some((t) => t.toLowerCase().includes(query.toLowerCase()))
        : true
    );

  const toggleSite = (id) =>
    setActiveSites((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));

  // Distinguish "nothing posted ever" from "nothing matches the filters."
  const hasAnyPosts = ASSETS.some((a) => a.publications && a.publications.length);
  const filtersActive = activeSites.length > 0 || status !== "all" || (!!query && query.trim().length > 0);
  const clearFilters = () => { setActiveSites([]); setStatus("all"); setQuery(""); };

  return (
    <main className="main">
      <header className="topbar">
        <div className="crumbs">
          <span>{window.WorkspaceName ? <WorkspaceName /> : "Library"}</span>
          <span className="sep">/</span>
          <span
            style={{ cursor: onBack ? "pointer" : "default" }}
            onClick={() => onBack && onBack()}
          >
            Library
          </span>
          <span className="sep">/</span>
          <span className="here">Published</span>
        </div>
        <div className="topbar-right">
          <div className="search">
            <IcSearch size={14} />
            <input
              placeholder="Search published content…"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
            />
          </div>
          <span className="trial-pill" title={activePlanLabel + " plan"}><span className="dot" /> {activePlanLabel}</span>
        </div>
      </header>

      <section className="lib-header">
        <div>
          <h1 className="lib-title">Published</h1>
          <div className="lib-sub">
            <span>{published.length} posts</span>
            <span className="dot" />
            <span>{ASSETS.filter((a) => a.publications && a.publications.length).length} assets</span>
            <span className="dot" />
            <span>across {SITES.length} channels</span>
          </div>
        </div>
      </section>

      <section className="lib-tools" style={{ flexWrap: "wrap", gap: 8 }}>
        <span style={{ fontSize: 12, color: "var(--text-faint)", marginRight: 6 }}>Site</span>
        <span
          className={`site-chip ${activeSites.length === 0 ? "active" : ""}`}
          onClick={() => setActiveSites([])}
        >
          All
        </span>
        {SITES.map((s) => (
          <span
            key={s.id}
            className={`site-chip ${activeSites.includes(s.id) ? "active" : ""}`}
            onClick={() => toggleSite(s.id)}
          >
            <SiteMark site={s} size={16} />
            {s.name}
          </span>
        ))}
        <span style={{ width: 1, height: 18, background: "var(--border)", margin: "0 4px" }} />
        <span
          className={`chip ${status === "all" ? "active" : ""}`}
          onClick={() => setStatus("all")}
        >
          All status
        </span>
        <span
          className={`chip ${status === "live" ? "active" : ""}`}
          onClick={() => setStatus("live")}
        >
          Live
        </span>
        <span
          className={`chip ${status === "scheduled" ? "active" : ""}`}
          onClick={() => setStatus("scheduled")}
        >
          Scheduled
        </span>
      </section>

      <section style={{ padding: "8px 32px 60px" }} className="published-table">
        {published.length === 0 ? (
          <PublishedEmptyState
            canPublish={canPublish}
            connStatus={connStatus}
            anyConnected={anyConnected}
            hasAnyPosts={hasAnyPosts}
            filtersActive={filtersActive}
            onClearFilters={clearFilters}
            onConnect={openConnectFlow}
            openingConnect={openingConnect}
            onNav={onNav}
            onBack={onBack}
          />
        ) : (
        <table className="table" style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14, overflow: "hidden" }}>
          <thead>
            <tr>
              <th style={{ paddingLeft: 18 }}>Asset</th>
              <th>Site</th>
              <th>Caption</th>
              <th>Posted</th>
              <th>Status</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {published.map((row, i) => (
              <tr key={i}>
                <td style={{ paddingLeft: 18 }}>
                  <button
                    onClick={() => onOpenAsset(row.asset)}
                    style={{
                      display: "flex", alignItems: "center", gap: 12,
                      background: "transparent", border: 0, padding: 0, cursor: "pointer", textAlign: "left",
                    }}
                  >
                    <span
                      className="thumb-mini"
                      style={{ backgroundImage: `url(${row.asset.url})` }}
                    />
                    <span>
                      <div style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>{row.asset.name}</div>
                      <div style={{ fontSize: 11.5, color: "var(--text-faint)", marginTop: 2 }}>{row.asset.uploader}</div>
                    </span>
                  </button>
                </td>
                <td>
                  <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
                    <SiteMark site={row.site} size={18} />
                    {row.site.name}
                  </span>
                </td>
                <td style={{ maxWidth: 360, color: "var(--text-muted)" }}>
                  <div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                    {row.pub.caption}
                  </div>
                </td>
                <td style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", fontSize: 12 }}>
                  {row.pub.postedAt}
                </td>
                <td>
                  <span className={`pub-status ${row.pub.status === "scheduled" || row.pub.status === "pending" ? "scheduled" : ""}`}>
                    {row.pub.status === "scheduled" || row.pub.status === "pending" ? <IcClock size={10} /> : <IcCheck size={10} />}
                    {row.pub.status === "pending" ? "Publishing…" : row.pub.status === "scheduled" ? "Scheduled" : "Live"}
                  </span>
                </td>
                <td style={{ textAlign: "right", paddingRight: 18 }}>
                  {row.pub.url ? (
                    <a className="btn sm" href={row.pub.url} target="_blank" rel="noreferrer">
                      <IcExternal size={13} /> Open post
                    </a>
                  ) : (
                    <span style={{ fontSize: 11.5, color: "var(--text-faint)" }}>Will publish on schedule</span>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        )}
      </section>
    </main>
  );
}

// Empty state for the Published view. Mirrors the centered icon-tile style
// used by the library empty states. Branches:
//   • filters active but nothing matches → clear-filters prompt
//   • plan cannot publish → upgrade prompt (never shown in beta)
//   • no social accounts connected → connect prompt
//   • connected but nothing posted yet → publish-from-library prompt
function PublishedEmptyState({
  canPublish, connStatus, anyConnected, hasAnyPosts, filtersActive,
  onClearFilters, onConnect, openingConnect, onNav, onBack,
}) {
  const wrap = {
    display: "flex", flexDirection: "column", alignItems: "center",
    gap: 14, maxWidth: 380, margin: "0 auto", textAlign: "center",
    padding: "80px 24px",
  };
  const tile = {
    width: 56, height: 56, borderRadius: 14,
    background: "var(--accent-soft, oklch(0.94 0.04 38))",
    color: "var(--accent, #c8553d)", display: "grid", placeItems: "center",
  };
  const title = { color: "var(--text)", display: "block", fontSize: 15, marginBottom: 4, fontWeight: 600 };
  const body = { color: "var(--text-muted)", fontSize: 13, lineHeight: 1.5 };

  // 1) Filters hiding everything (there ARE posts, just none match).
  if (filtersActive && hasAnyPosts) {
    return (
      <div style={wrap}>
        <div style={tile}><IcFilter size={24} /></div>
        <div>
          <strong style={title}>No posts match these filters</strong>
          <div style={body}>Try a different site or status, or clear the filters to see everything you've published.</div>
        </div>
        <button className="btn" onClick={onClearFilters}><IcClose size={12} /> Clear filters</button>
      </div>
    );
  }

  // 2) Plan does not allow publishing — offer an upgrade. (Beta has publishing
  //    enabled, so this branch is dormant until paid tiers gate it.)
  if (!canPublish) {
    return (
      <div style={wrap}>
        <div style={tile}><IcSparkles size={24} /></div>
        <div>
          <strong style={title}>Publishing is not on your plan</strong>
          <div style={body}>Upgrade to post photos and video straight to Instagram, Facebook, TikTok and more — and track every post here.</div>
        </div>
        <button className="btn primary" onClick={() => onNav && onNav("settings")}><IcSparkles size={14} /> Upgrade plan</button>
      </div>
    );
  }

  // 3) Connections still loading — keep it quiet to avoid flashing the wrong CTA.
  if (connStatus === null) {
    return (
      <div style={wrap}>
        <div style={tile}><IcSend size={24} /></div>
        <div><strong style={title}>Nothing published yet</strong>
          <div style={body}>Checking your connected social accounts…</div>
        </div>
      </div>
    );
  }

  // 4) No social accounts connected — prompt to connect.
  if (!anyConnected) {
    return (
      <div style={wrap}>
        <div style={tile}><IcLink size={24} /></div>
        <div>
          <strong style={title}>Connect a social account</strong>
          <div style={body}>Link Instagram, Facebook, TikTok and more to start posting from Vanday. Once connected, every post shows up here.</div>
        </div>
        <button className="btn primary" onClick={onConnect} disabled={openingConnect}>
          <IcLink size={14} /> {openingConnect ? "Opening…" : "Connect an account"}
        </button>
      </div>
    );
  }

  // 5) Connected, just nothing posted yet — point them at the library.
  return (
    <div style={wrap}>
      <div style={tile}><IcSend size={24} /></div>
      <div>
        <strong style={title}>Nothing published yet</strong>
        <div style={body}>Open an asset from your library and hit Publish to post it to your connected accounts. Your posts will appear here.</div>
      </div>
      <button className="btn primary" onClick={() => (onBack ? onBack() : onNav && onNav("library"))}>
        <IcGrid size={14} /> Go to library
      </button>
    </div>
  );
}

Object.assign(window, { CropsGrid, PublicationList, PublishModal, PublishedView });
