import { useCallback, useEffect, useMemo, useState } from "react";
import { ExternalLink, ImageOff, RefreshCw, Send } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Empty, ErrorNote, fmt, fmtDate, Spinner, Stat, StatCell, StatRow, timeAgo } from "../ui.jsx";
const PLATFORMS = {
instagram: { name: "Instagram", color: "#d62976" },
facebook: { name: "Facebook", color: "#1877f2" },
youtube: { name: "YouTube", color: "#ff0000" },
tiktok: { name: "TikTok", color: "#010101" },
gbp: { name: "Google Business", color: "#4285f4" },
linkedin: { name: "LinkedIn", color: "#0a66c2" },
pinterest: { name: "Pinterest", color: "#e60023" },
threads: { name: "Threads", color: "#000000" },
reddit: { name: "Reddit", color: "#ff4500" },
};
const COLS = [["reach", "Reach"], ["views", "Views"], ["likes", "Likes"], ["comments", "Comments"], ["saves", "Saves"], ["shares", "Shares"]];
const SORTS = { newest: "Newest first", interactions: "Most interactions", reach: "Most reach", views: "Most views" };
const PAGE = 50;
const isVideo = (m) => /\.(mp4|mov|m4v|webm)$/i.test(String(m || ""));
function Thumb({ item }) {
const [broken, setBroken] = useState(false);
const local = item.media ? `/api/schedule/media?path=${encodeURIComponent(item.media)}` : null;
const p = PLATFORMS[item.platform] || { name: item.platform, color: "#6b6e78" };
const box = "size-14 rounded-md shrink-0 overflow-hidden bg-canvas border border-line";
if (!broken && item.thumb) return setBroken(true)} />;
if (local && isVideo(item.media)) return ;
if (local) return ;
return (
{broken ? : p.name[0]}
);
}
export default function Published() {
const { isAdmin } = useAuth();
const [clients, setClients] = useState([]);
const [f, setF] = useState({ client: "", platform: "", days: 30, sort: "newest" });
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [shown, setShown] = useState(PAGE);
const [refreshing, setRefreshing] = useState(false);
const [best, setBest] = useState(null);
const set = (k, v) => { setF((s) => ({ ...s, [k]: v })); setShown(PAGE); };
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(() => {}); }, []);
const load = useCallback(() => {
const q = new URLSearchParams({ days: String(f.days) });
if (f.client) q.set("client", f.client);
if (f.platform) q.set("platform", f.platform);
setError(null);
return api(`/api/published?${q}`).then(setData).catch(setError);
}, [f.client, f.platform, f.days]);
useEffect(() => { load(); }, [load]);
useEffect(() => { setBest(null); if (f.client) api(`/api/schedule/suggest?client=${f.client}`).then(setBest).catch(() => {}); }, [f.client]);
const rows = useMemo(() => {
const list = [...(data?.items || [])];
const key = f.sort === "interactions" ? (i) => i.interactions ?? -1 : f.sort === "reach" ? (i) => i.metrics.reach ?? -1 : f.sort === "views" ? (i) => i.metrics.views ?? -1 : null;
if (key) list.sort((a, b) => key(b) - key(a));
return list;
}, [data, f.sort]);
const refreshStats = async () => {
setRefreshing(true);
try { await api("/api/published/refresh", { method: "POST" }); await load(); } catch (e) { setError(e); }
setRefreshing(false);
};
const t = data?.totals;
const platformsInData = useMemo(() => [...new Set((data?.items || []).map((i) => i.platform))], [data]);
const oldestSnap = data ? Object.values(data.snapshotsAt).filter(Boolean).sort()[0] : null;
return (
set("client", e.target.value)}>
All clients
{clients.map((c) => {c.name} )}
set("platform", e.target.value)}>
All platforms
{Object.entries(PLATFORMS).filter(([id]) => !data || platformsInData.includes(id) || f.platform === id).map(([id, p]) => {p.name} )}
{[7, 30, 90].map((d) => (
set("days", d)} aria-pressed={f.days === d}
className={cx("h-8 px-3 rounded text-[13px] font-medium", f.days === d ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
{d} days
))}
set("sort", e.target.value)}>
{Object.entries(SORTS).map(([k, l]) => {l} )}
{t && (
)}
{best && (
Best times to post
{best.best?.slots?.length ? best.best.slots.map((s) => (
{s.day} {s.time} +{s.lift}% reach ({s.posts} posts)
)) : Not enough Instagram posts with reach yet (needs 8; has {best.best?.basedOn ?? 0}). Using general times: {best.times.join(", ")}. }
{best.best?.slots?.length > 0 && From the last 4 months of Instagram posts, {best.timezone.replace("_", " ")} time. An early signal, not a rule. }
)}
{!data && !error ?
: data && !rows.length ? (
Try a longer period or another client. Instagram and Facebook posts appear after the next numbers refresh.
) : data && (
Post
Platform
{COLS.map(([k, l]) => {l} )}
Interactions
Open
{rows.slice(0, shown).map((i) => {
const p = PLATFORMS[i.platform] || { name: i.platform, color: "#6b6e78" };
return (
{i.caption || No caption }
{i.clientName}
{i.format}
{fmtDate(i.at, { month: "short", day: "numeric", year: "numeric" })}
{i.viaQuorla && via Quorla }
{i.note &&
{i.note}
}
{p.name}
{COLS.map(([k]) => {fmt(i.metrics[k])} )}
{fmt(i.interactions)}
{i.permalink && }
);
})}
{rows.length > shown && (
setShown((n) => n + PAGE)}>Show {Math.min(PAGE, rows.length - shown)} more of {fmt(rows.length - shown)}
)}
)}
Instagram and Facebook numbers refresh nightly with each client's snapshot. Reach and saves aren't available for Facebook posts.
YouTube counts refresh nightly{data?.statsAt ? ` (last ${timeAgo(data.statsAt)})` : ""}. TikTok and Google Business Profile don't share per-post numbers with Quorla yet.
);
}