- Publishing: admins can retry a failed or partly published post; parts that already posted are skipped - API errors now include the platform's error code, with plain fixes for common TikTok ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
13 KiB
JavaScript
198 lines
13 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { CalendarClock, Check, Image as ImageIcon, Plus, RotateCcw, 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>}
|
||
{["failed", "partial"].includes(p.status) && isAdmin && <Button size="sm" icon={RotateCcw} loading={busy === p.id + "retry"} disabled={!data.publishing} onClick={() => act(p, "retry")}>Retry failed</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>
|
||
);
|
||
}
|