import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { ArrowRightLeft, Check, CircleAlert, Copy, ExternalLink, Plug, RefreshCw, Settings2, Unplug } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, ErrorNote, Field, fmt, Panel, Spinner, timeAgo } from "../ui.jsx";
const FORMAT_LABEL = { TEXT: "text", IMAGE: "image", CAROUSEL: "multi-image", REELS: "video", STORY: "story" };
/** "3 of 15 posts used · resets 9:12 PM" (YouTube: units per day, shared by all clients). */
function QuotaLine({ q }) {
if (!q) return null;
const reset = new Date(q.resetAt).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" });
const full = q.remaining <= 0;
return (
{fmt(q.used)} of {fmt(q.limit)} {q.unit} used {q.window === "pt-day" ? "today (all clients)" : "in the last 24 h"}
{(q.used > 0 || full) && <> · {full ? "full, posts wait until" : "frees up"} {reset}>}
);
}
function CopyText({ text }) {
const [done, setDone] = useState(false);
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
return (
{text}
{done ? : }
);
}
/** Admin: enter a platform's developer-app credentials (stored encrypted on the server). */
function AppDialog({ platform, onClose, onSaved }) {
const [f, setF] = useState({ clientId: platform.app.clientId || "", clientSecret: "", scopes: (platform.app.scopes || []).join(" ") });
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
const save = async (e) => {
e.preventDefault(); setBusy("save"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "PUT", body: f }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
const remove = async () => {
setBusy("remove"); setError(null);
try { await api(`/api/platforms/${platform.id}/app`, { method: "DELETE" }); onSaved(); } catch (err) { setError(err); setBusy(null); }
};
return (
{platform.app.configured && platform.app.source === "settings" && Remove app keys }
Cancel
Save
>}>
{platform.setup.steps.map((s) => {s} )}
Open the {platform.name} developer console
{platform.setup.note &&
{platform.setup.note}
}
{platform.app.source === "env" &&
Currently using keys from the server's .env. Saving here overrides them.
}
);
}
function PlatformCard({ platform, conn, client, clients, isAdmin, mediaLinks, onSetup, onMoved, reload, setError, quota }) {
const [busy, setBusy] = useState(null);
const [moving, setMoving] = useState(false);
const [targets, setTargets] = useState(null);
const needsMediaLinks = ["tiktok", "threads", "gbp"].includes(platform.id) && !mediaLinks;
const connect = async () => {
setBusy("connect"); setError(null);
try { const { url } = await api(`/api/oauth/${platform.id}/start?client=${encodeURIComponent(client)}`); window.location.assign(url); }
catch (e) { setError(e); setBusy(null); }
};
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); await reload(); } catch (e) { setError(e); } setBusy(null); };
const loadTargets = useCallback(() => {
if (!conn || !platform.targetLabel) return;
api(`/api/connections/${conn.id}/targets`).then((r) => setTargets(r.targets)).catch(() => setTargets([]));
}, [conn, platform.targetLabel]);
useEffect(() => { loadTargets(); }, [loadTargets]);
const status = !platform.app.configured ? { tone: "neutral", text: "Not set up" }
: !conn ? { tone: "neutral", text: "Not connected" }
: conn.status === "connected" ? { tone: "ok", text: "Connected" }
: { tone: "bad", text: conn.status === "reconnect" ? "Reconnect needed" : "Error" };
return (
{platform.name[0]}
{platform.name} {status.text}
Posts: {platform.formats.map((f) => FORMAT_LABEL[f]).join(", ")}
{conn && (
{conn.account?.avatar &&
}
{conn.account?.name}
{conn.account?.username &&
@{String(conn.account.username).replace(/^@+/, "")} }
{conn.account?.followers != null && <>{fmt(conn.account.followers)} {platform.id === "youtube" ? "subscribers" : platform.id === "reddit" ? "karma" : "followers"} · >}
Connected {timeAgo(conn.connectedAt)} by {conn.connectedBy}
{conn.account?.url && <> ·
View profile >}
{conn.lastError &&
{conn.lastError}
}
)}
{conn && platform.targetLabel && (
{targets === null ? Loading…
: (
run("target", () => api(`/api/connections/${conn.id}`, { method: "PATCH", body: { targetId: e.target.value } }))}>
{targets.length ? "Choose…" : "Nothing found for this account"}
{targets.map((t) => {t.name} )}
)}
)}
{isAdmin && conn && moving && (
{ const to = e.target.value; if (!to) return; setBusy("move"); setError(null);
api(`/api/connections/${conn.id}`, { method: "PATCH", body: { client: to } })
.then(() => { setMoving(false); onMoved(to); }).catch((er) => setError(er)).finally(() => setBusy(null)); }}>
Choose a client…
{clients.filter((c) => c.slug !== client).map((c) => {c.name} )}
)}
{needsMediaLinks &&
Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).
}
{isAdmin && !platform.app.configured && Set up }
{isAdmin && platform.app.configured && !conn && Connect }
{isAdmin && conn && Reconnect }
{isAdmin && conn && run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check }
{isAdmin && conn && setMoving(!moving)}>{moving ? "Cancel move" : "Move"} }
{isAdmin && conn && run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect }
{isAdmin && platform.app.configured && }
{!isAdmin && !conn && An admin connects this. }
);
}
export default function Connections() {
const { isAdmin } = useAuth();
const [params, setParams] = useSearchParams();
const [clients, setClients] = useState(null);
const [platforms, setPlatforms] = useState(null);
const [conns, setConns] = useState([]);
const [error, setError] = useState(null);
const [setup, setSetup] = useState(null);
const [quota, setQuota] = useState({});
const client = params.get("client") || "";
const notice = params.get("connected");
const oauthError = params.get("error");
const loadPlatforms = useCallback(() => api("/api/platforms").then(setPlatforms).catch(setError), []);
const loadConns = useCallback(() => (client ? Promise.all([
api(`/api/connections?client=${encodeURIComponent(client)}`).then((r) => setConns(r.connections)),
api(`/api/quota?client=${encodeURIComponent(client)}`).then((r) => setQuota(r.quota)).catch(() => setQuota({})),
]).catch(setError) : Promise.resolve()), [client]);
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); loadPlatforms(); }, [loadPlatforms]);
useEffect(() => { loadConns(); }, [loadConns]);
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
const current = useMemo(() => clients?.find((c) => c.slug === client), [clients, client]);
const pickClient = (slug) => setParams({ client: slug });
const dismiss = () => setParams({ client });
if (!clients || !platforms) return error ? : ;
return (
{notice && (
{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.
Dismiss
)}
{oauthError && (
{oauthError}
Dismiss
)}
Instagram: {current?.igUsername ? `@${current.igUsername}` : not linked }
Facebook: {current?.pageId ? "Page linked" : current?.igOnly ? none (Instagram only) : not linked }
{!current?.linked && Link it from the client's page. }
{(quota.instagram || quota.facebook) && (
{quota.instagram &&
Instagram
}
{quota.facebook &&
Facebook
}
)}
{platforms.platforms.map((p) => (
c.platform === p.id)} client={client} clients={clients} isAdmin={isAdmin}
mediaLinks={platforms.mediaLinks} onSetup={() => setSetup(p)} onMoved={pickClient} reload={loadConns} setError={setError} quota={quota[p.id]} />
))}
{setup &&
setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
);
}