- Quorla logo, navy/coral colours and page titles in the app (PDF reports keep agency branding) - Connections: Move button reassigns an account to another client without reconnecting - Fix double @ on YouTube handles - HANDOFF.md with current status and next steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
250 lines
16 KiB
JavaScript
250 lines
16 KiB
JavaScript
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 (
|
|
<div className={cx("text-[12px]", full ? "text-warn" : "text-muted")}>
|
|
{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}</>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CopyText({ text }) {
|
|
const [done, setDone] = useState(false);
|
|
const copy = () => navigator.clipboard?.writeText(text).then(() => { setDone(true); setTimeout(() => setDone(false), 1400); }).catch(() => {});
|
|
return (
|
|
<div className="flex items-center gap-2 rounded-md border border-line bg-canvas px-2.5 py-1.5">
|
|
<code className="font-mono text-[12.5px] break-all flex-1 min-w-0">{text}</code>
|
|
<button type="button" onClick={copy} className="p-1 rounded hover:bg-white text-muted shrink-0" aria-label="Copy">{done ? <Check className="size-4 text-ok" /> : <Copy className="size-4" />}</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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 (
|
|
<Dialog open onClose={onClose} wide title={`Set up ${platform.name}`}
|
|
footer={<>
|
|
{platform.app.configured && platform.app.source === "settings" && <Button variant="danger" className="mr-auto" loading={busy === "remove"} onClick={remove}>Remove app keys</Button>}
|
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
|
<Button variant="primary" type="submit" form="app-form" loading={busy === "save"}>Save</Button>
|
|
</>}>
|
|
<div className="space-y-4 text-[13.5px]">
|
|
<ol className="list-decimal pl-5 space-y-1.5 text-ink-2">
|
|
{platform.setup.steps.map((s) => <li key={s}>{s}</li>)}
|
|
</ol>
|
|
<a href={platform.setup.console} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-brand-deep font-medium">Open the {platform.name} developer console <ExternalLink className="size-3.5" /></a>
|
|
<Field label="Redirect URI (paste this into the developer console)"><CopyText text={platform.redirectUri} /></Field>
|
|
{platform.setup.note && <div className="rounded-md border border-[#f1dcb8] bg-warn-soft text-warn px-3 py-2 text-[13px]">{platform.setup.note}</div>}
|
|
<form id="app-form" onSubmit={save} className="grid grid-cols-2 gap-3">
|
|
<Field label={platform.id === "tiktok" ? "Client key" : "Client ID / App ID"}><input id="app-client-id" className="input" value={f.clientId} onChange={set("clientId")} required autoComplete="off" /></Field>
|
|
<Field label="Client secret" hint={platform.app.configured ? "Leave empty to keep the stored secret." : "Stored encrypted. Never shown again."}>
|
|
<input id="app-client-secret" className="input" type="password" value={f.clientSecret} onChange={set("clientSecret")} required={!platform.app.configured} autoComplete="new-password" />
|
|
</Field>
|
|
<div className="col-span-2">
|
|
<Field label="Scopes (optional)" hint={`Leave empty for the defaults: ${platform.defaultScopes.join(" ")}`}>
|
|
<input id="app-scopes" className="input" value={f.scopes} onChange={set("scopes")} placeholder={platform.defaultScopes.join(" ")} />
|
|
</Field>
|
|
</div>
|
|
</form>
|
|
{platform.app.source === "env" && <p className="text-muted text-[12.5px]">Currently using keys from the server's .env. Saving here overrides them.</p>}
|
|
<ErrorNote error={error} />
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="panel p-4 flex flex-col gap-3 min-w-0">
|
|
<div className="flex items-start gap-3">
|
|
<span className="size-9 rounded-lg grid place-items-center text-white font-bold text-[13px] shrink-0" style={{ background: platform.color }} aria-hidden="true">{platform.name[0]}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap"><span className="font-semibold">{platform.name}</span><Badge tone={status.tone} dot>{status.text}</Badge></div>
|
|
<div className="text-[12px] text-muted mt-0.5">Posts: {platform.formats.map((f) => FORMAT_LABEL[f]).join(", ")}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{conn && (
|
|
<div className="text-[13px] space-y-1">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{conn.account?.avatar && <img src={conn.account.avatar} alt="" className="size-6 rounded-full shrink-0" />}
|
|
<span className="font-medium truncate">{conn.account?.name}</span>
|
|
{conn.account?.username && <span className="text-muted truncate">@{String(conn.account.username).replace(/^@+/, "")}</span>}
|
|
</div>
|
|
<div className="text-[12px] text-muted">
|
|
{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 && <> · <a className="text-brand-deep" href={conn.account.url} target="_blank" rel="noreferrer">View profile</a></>}
|
|
</div>
|
|
<QuotaLine q={quota} />
|
|
{conn.lastError && <div className="text-[12px] text-bad">{conn.lastError}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{conn && platform.targetLabel && (
|
|
<Field label={platform.targetLabel}>
|
|
{targets === null ? <div className="text-[12.5px] text-muted">Loading…</div> : (
|
|
<select id={`target-${platform.id}`} className="input" value={conn.target?.id || ""} disabled={!isAdmin || busy === "target"}
|
|
onChange={(e) => run("target", () => api(`/api/connections/${conn.id}`, { method: "PATCH", body: { targetId: e.target.value } }))}>
|
|
<option value="" disabled>{targets.length ? "Choose…" : "Nothing found for this account"}</option>
|
|
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
</select>
|
|
)}
|
|
</Field>
|
|
)}
|
|
|
|
{isAdmin && conn && moving && (
|
|
<Field label="Move this account to">
|
|
<select id={`move-${platform.id}`} className="input" defaultValue="" disabled={busy === "move"}
|
|
onChange={(e) => { 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)); }}>
|
|
<option value="" disabled>Choose a client…</option>
|
|
{clients.filter((c) => c.slug !== client).map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
</select>
|
|
</Field>
|
|
)}
|
|
|
|
{needsMediaLinks && <div className="text-[12px] text-warn">Image and video posts need PUBLIC_URL set on the server (e.g. https://app.quorlasocial.com).</div>}
|
|
|
|
<div className="flex flex-wrap gap-2 mt-auto pt-1">
|
|
{isAdmin && !platform.app.configured && <Button size="sm" variant="primary" icon={Settings2} onClick={onSetup}>Set up</Button>}
|
|
{isAdmin && platform.app.configured && !conn && <Button size="sm" variant="primary" icon={Plug} loading={busy === "connect"} onClick={connect}>Connect</Button>}
|
|
{isAdmin && conn && <Button size="sm" icon={Plug} loading={busy === "connect"} onClick={connect}>Reconnect</Button>}
|
|
{isAdmin && conn && <Button size="sm" variant="ghost" icon={RefreshCw} loading={busy === "check"} onClick={() => run("check", () => api(`/api/connections/${conn.id}/check`, { method: "POST" }))}>Check</Button>}
|
|
{isAdmin && conn && <Button size="sm" variant="ghost" icon={ArrowRightLeft} onClick={() => setMoving(!moving)}>{moving ? "Cancel move" : "Move"}</Button>}
|
|
{isAdmin && conn && <Button size="sm" variant="ghost" icon={Unplug} loading={busy === "remove"} onClick={() => run("remove", () => api(`/api/connections/${conn.id}`, { method: "DELETE" }))}>Disconnect</Button>}
|
|
{isAdmin && platform.app.configured && <Button size="sm" variant="ghost" icon={Settings2} onClick={onSetup} aria-label={`${platform.name} app settings`} />}
|
|
{!isAdmin && !conn && <span className="text-[12.5px] text-muted">An admin connects this.</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 ? <ErrorNote error={error} /> : <Spinner />;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<header className="flex items-end justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h1 className="text-[22px] font-extrabold">Connections</h1>
|
|
<p className="text-muted mt-0.5">Each client's accounts on every platform. Connect once; posts then go through Publishing with the usual approval.</p>
|
|
</div>
|
|
<select id="conn-client" className="input h-9 w-auto" value={client} onChange={(e) => pickClient(e.target.value)} aria-label="Client">
|
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
</select>
|
|
</header>
|
|
|
|
{notice && (
|
|
<div className="rounded-md border border-[#cfe6d7] bg-ok-soft text-ok px-3 py-2 text-[13px] flex justify-between gap-3">
|
|
<span>{platforms.platforms.find((p) => p.id === notice)?.name || notice} is connected for {current?.name}.</span>
|
|
<button type="button" className="underline" onClick={dismiss}>Dismiss</button>
|
|
</div>
|
|
)}
|
|
{oauthError && (
|
|
<div className="rounded-md border border-[#f2cdc8] bg-bad-soft text-bad px-3 py-2 text-[13px] flex justify-between gap-3">
|
|
<span className="flex gap-2"><CircleAlert className="size-4 shrink-0 mt-0.5" />{oauthError}</span>
|
|
<button type="button" className="underline shrink-0" onClick={dismiss}>Dismiss</button>
|
|
</div>
|
|
)}
|
|
<ErrorNote error={error} />
|
|
|
|
<Panel title="Facebook & Instagram" subtitle="Connected through the agency's Meta system user, not here.">
|
|
<div className="px-4 py-3 text-[13px] flex flex-wrap gap-x-6 gap-y-1">
|
|
<span><b>Instagram:</b> {current?.igUsername ? `@${current.igUsername}` : <span className="text-muted">not linked</span>}</span>
|
|
<span><b>Facebook:</b> {current?.pageId ? "Page linked" : current?.igOnly ? <span className="text-muted">none (Instagram only)</span> : <span className="text-muted">not linked</span>}</span>
|
|
{!current?.linked && <span className="text-muted">Link it from the client's page.</span>}
|
|
</div>
|
|
{(quota.instagram || quota.facebook) && (
|
|
<div className="px-4 pb-3 grid gap-0.5">
|
|
{quota.instagram && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Instagram</span><QuotaLine q={quota.instagram} /></div>}
|
|
{quota.facebook && <div className="flex gap-2 items-baseline"><span className="text-[12px] font-medium w-[70px]">Facebook</span><QuotaLine q={quota.facebook} /></div>}
|
|
</div>
|
|
)}
|
|
</Panel>
|
|
|
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
|
|
{platforms.platforms.map((p) => (
|
|
<PlatformCard key={`${p.id}-${client}`} platform={p} conn={conns.find((c) => 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]} />
|
|
))}
|
|
</div>
|
|
|
|
{setup && <AppDialog platform={setup} onClose={() => setSetup(null)} onSaved={() => { setSetup(null); loadPlatforms(); }} />}
|
|
</div>
|
|
);
|
|
}
|