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}
); } /** 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" && } }>
    {platform.setup.steps.map((s) =>
  1. {s}
  2. )}
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}{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…
: ( )}
)} {isAdmin && conn && moving && ( )} {needsMediaLinks &&
Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).
}
{isAdmin && !platform.app.configured && } {isAdmin && platform.app.configured && !conn && } {isAdmin && conn && } {isAdmin && conn && } {isAdmin && conn && } {isAdmin && conn && } {isAdmin && platform.app.configured &&
); } 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 (

Connections

Each client's accounts on every platform. Connect once; posts then go through Publishing with the usual approval.

{notice && (
{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.
)} {oauthError && (
{oauthError}
)}
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(); }} />}
); }