metatroncubeswdev fc2179b343 Track platform quotas; wait and retry instead of failing
- quota.js: per-platform limits (YouTube 10,000 units/day Pacific per project,
  Instagram 100 / Threads 250 / TikTok 15 posts per 24 h per account, ...),
  usage log, .env overrides (QUOTA_<PLATFORM>)
- Publishing checks the budget first and records usage; when a platform is
  out of quota (or answers quota exceeded / 429) the post stays approved and
  the queue retries after the reset, without re-posting finished platforms
- Drafts that exceed a day's limit get a warning
- Connections page shows used/limit and when it frees up
- Tests for windows (incl. DST), shared vs per-account limits and the
  wait-and-retry queue flow; docs for quotas and the Google app setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:56:54 -04:00

197 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from "react";
import { CalendarClock, Check, Image as ImageIcon, Plus, Upload, X } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel / video"], ["CAROUSEL", "Carousel / multi-image"], ["STORY", "Story (Instagram, Facebook)"], ["TEXT", "Text only (no Instagram)"]];
export default function Publishing() {
const { isAdmin } = useAuth();
const [data, setData] = useState(null);
const [clients, setClients] = useState([]);
const [error, setError] = useState(null);
const [filter, setFilter] = useState("");
const [show, setShow] = useState("upcoming");
const [composing, setComposing] = useState(false);
const [busy, setBusy] = useState(null);
const load = useCallback(() => api("/api/schedule").then(setData).catch(setError), []);
useEffect(() => { load(); api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, [load]);
useEffect(() => { const t = setInterval(load, 60000); return () => clearInterval(t); }, [load]);
const posts = useMemo(() => (data?.posts || []).filter((p) =>
(!filter || p.client === filter) &&
(show === "all" || (show === "upcoming" ? ["draft", "approved", "publishing", "failed", "partial"].includes(p.status) : p.status === "published"))
), [data, filter, show]);
const act = async (p, what) => {
setBusy(p.id + what); setError(null);
try { await api(`/api/schedule/${p.id}/${what}`, { method: "POST" }); await load(); } catch (e) { setError(e); }
setBusy(null);
};
if (!data) return error ? <ErrorNote error={error} /> : <Spinner />;
const drafts = data.posts.filter((p) => p.status === "draft").length;
const approved = data.posts.filter((p) => p.status === "approved").length;
return (
<div className="space-y-5">
<header className="flex items-end justify-between gap-4">
<div>
<h1 className="text-[22px] font-extrabold">Publishing</h1>
<p className="text-muted mt-0.5">Posts go out at each client's local time. {drafts} waiting for approval · {approved} approved.</p>
</div>
<Button variant="primary" icon={Plus} onClick={() => setComposing(true)}>New post</Button>
</header>
{!data.publishing && <div className="rounded-md border border-[#f1dcb8] bg-warn-soft text-warn px-3 py-2 text-[13px]">Publishing is switched off. Drafts can be prepared, but nothing will be approved or posted until ALLOW_PUBLISH=true is set in .env.</div>}
<ErrorNote error={error} />
<div className="flex gap-2 items-center">
<select className="input h-9 w-auto" value={filter} onChange={(e) => setFilter(e.target.value)} id="pub-client">
<option value="">All clients</option>
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
{["upcoming", "published", "all"].map((k) => (
<button key={k} onClick={() => setShow(k)} className={cx("h-9 px-3 rounded-md text-[13px] font-medium capitalize", show === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{k}</button>
))}
</div>
<Panel>
{!posts.length ? (
<Empty icon={CalendarClock} title="Nothing here yet">Create a post, or ask the agent to schedule the week's plan. Every post needs an admin's approval before it publishes.</Empty>
) : (
<ul className="divide-y divide-line">
{posts.map((p) => (
<li key={p.id} className="px-4 py-3.5 flex gap-4 items-start">
<Thumb post={p} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-[13.5px]">{p.clientName}</span>
<Badge tone={STATUS[p.status]} dot>{p.status}</Badge>
<span className="text-[12px] text-muted">{p.type.toLowerCase()} · {p.platforms.join(" + ")}</span>
</div>
<div className="text-[12.5px] text-ink-2 mt-0.5"><b>{p.localTime}</b> <span className="text-muted">({p.timezone.split("/").pop().replace("_", " ")} time)</span></div>
<p className="text-[13px] text-ink-2 mt-1.5 line-clamp-2 whitespace-pre-line">{p.caption || <i className="text-muted">No caption</i>}</p>
{p.collaborators?.length > 0 && <div className="text-[12px] text-muted mt-1">Collab: {p.collaborators.map((c) => "@" + c).join(", ")}</div>}
{Object.entries(p.errors || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</div>)}
{Object.entries(p.waiting || {}).map(([k, until]) => <div key={k} className="text-[12px] text-warn mt-1">{k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.</div>)}
{p.status === "draft" && (p.warnings || []).map((w) => <div key={w} className="text-[12px] text-warn mt-1">{w}</div>)}
{Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => <a key={k} href={v.permalink} target="_blank" rel="noreferrer" className="text-[12px] text-brand mr-3">View on {k}</a>)}
{p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled && <div className="text-[12px] text-muted mt-1">Facebook part is scheduled in Meta Planner.</div>}
<div className="text-[11.5px] text-faint mt-1">by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}</div>
</div>
<div className="flex gap-2 shrink-0">
{p.status === "draft" && isAdmin && <Button size="sm" variant="primary" icon={Check} loading={busy === p.id + "approve"} disabled={!data.publishing} onClick={() => act(p, "approve")}>Approve</Button>}
{!["published", "cancelled", "publishing"].includes(p.status) && <Button size="sm" variant="danger" icon={X} loading={busy === p.id + "cancel"} onClick={() => act(p, "cancel")}>Cancel</Button>}
</div>
</li>
))}
</ul>
)}
</Panel>
{composing && <Compose clients={clients} onClose={() => setComposing(false)} onSaved={() => { setComposing(false); load(); }} />}
</div>
);
}
function Thumb({ post }) {
const m = post.media?.[0];
const isVid = /\.(mp4|mov|m4v)$/i.test(m || "");
const src = !m ? null : /^https?:/.test(m) ? m : `/api/schedule/media?path=${encodeURIComponent(m)}`;
return (
<div className="size-16 rounded-md bg-canvas border border-line overflow-hidden grid place-items-center shrink-0">
{!src ? <ImageIcon className="size-5 text-faint" /> : isVid ? <video src={src} className="size-full object-cover" muted /> : <img src={src} alt="" className="size-full object-cover" />}
</div>
);
}
function Compose({ clients, onClose, onSaved }) {
const [f, setF] = useState({ client: clients[0]?.slug || "", type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
const available = useClientPlatforms(f.client);
const [sel, setSel] = useState({ platforms: [], options: {} });
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
const [tz, setTz] = useState(null);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { if (f.client) api(`/api/schedule/suggest?client=${f.client}`).then(setTz).catch(() => setTz(null)); }, [f.client]);
const upload = async (files) => {
setUploading(true); setError(null);
try {
for (const file of files) {
const res = await fetch("/api/schedule/upload", { method: "POST", credentials: "same-origin", headers: { "X-Filename": file.name, "Content-Type": "application/octet-stream" }, body: file });
const j = await res.json();
if (!res.ok) throw new Error(j.error || "Upload failed");
setF((s) => ({ ...s, media: [...s.media, j.path] }));
}
} catch (e) { setError(e); }
setUploading(false);
};
const save = async () => {
setSaving(true); setError(null);
try {
const { platforms, options } = selectionFor(sel, available, f.type);
await api("/api/schedule", { method: "POST", body: {
client: f.client, platforms, options, type: f.type, media: f.media, caption: f.caption, fbCaption: f.fbCaption || undefined,
collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean), localTime: `${f.date} ${f.time}`,
} });
onSaved();
} catch (e) { setError(e); }
setSaving(false);
};
return (
<Dialog open onClose={onClose} title="New post" wide footer={<><Button onClick={onClose}>Close</Button><Button variant="primary" loading={saving} disabled={!f.client || !f.date || !f.time || !selectionFor(sel, available, f.type).platforms.length} onClick={save}>Save as draft</Button></>}>
<div className="grid grid-cols-2 gap-4">
<Field label="Client">
<select id="c-client" className="input w-full" value={f.client} onChange={(e) => set("client", e.target.value)}>
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
</select>
</Field>
<Field label="Format">
<select id="c-type" className="input w-full" value={f.type} onChange={(e) => set("type", e.target.value)}>
{TYPES.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</Field>
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
<div className="flex gap-2">
<input id="c-date" type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} />
<input id="c-time" type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} />
</div>
</Field>
</div>
{f.type !== "TEXT" && (
<Field label="Media" hint="JPG/PNG for images, MP4/MOV for Reels and video stories. Carousel: 2–10 files in order.">
<div className="flex flex-wrap gap-2 items-center">
{f.media.map((m, i) => <span key={m} className="text-[12px] bg-canvas border border-line rounded px-2 py-1">{i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "")} <button className="text-bad ml-1" onClick={() => set("media", f.media.filter((x) => x !== m))}>×</button></span>)}
<label className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-line-strong text-[13px] cursor-pointer hover:bg-canvas">
<Upload className="size-3.5" /> {uploading ? "Uploading…" : "Add files"}
<input id="c-files" type="file" className="hidden" multiple accept="image/jpeg,image/png,video/mp4,video/quicktime" onChange={(e) => upload([...e.target.files])} />
</label>
</div>
</Field>
)}
<Field label="Caption" hint="Plain text works best for search. Hashtags at the end.">
<textarea id="c-caption" className="input w-full h-28 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
</Field>
<Field label="Post to"><PlatformPicker client={f.client} type={f.type} caption={f.caption} value={sel} onChange={setSel} available={available} /></Field>
{sel.platforms.includes("facebook") && (
<Field label="Facebook caption (optional)" hint="Leave empty to reuse the Instagram caption.">
<textarea id="c-fbcaption" className="input w-full h-20 py-2" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} />
</Field>
)}
{sel.platforms.includes("instagram") && ["IMAGE", "REELS", "CAROUSEL"].includes(f.type) && (
<Field label="Collab invites (optional)" hint="Instagram usernames. They must accept the invite for the post to appear on their profile.">
<input id="c-collab" className="input w-full" placeholder="@username, @username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} />
</Field>
)}
<ErrorNote error={error} />
</Dialog>
);
}