Dashboard (content library, publishing queue with approvals, inbox, team management, AI runs), Meta connector, branded PDF reports, agent skills, and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS, backup and path-relocation scripts). Client data, live data and secrets are excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
12 KiB
JavaScript
196 lines
12 KiB
JavaScript
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";
|
||
|
||
const STATUS = { draft: "neutral", approved: "brand", publishing: "brand", published: "ok", partial: "warn", failed: "bad", cancelled: "neutral" };
|
||
const TYPES = [["IMAGE", "Single image"], ["REELS", "Reel"], ["CAROUSEL", "Carousel (2–10)"], ["STORY", "Story"], ["TEXT", "Text (Facebook only)"]];
|
||
|
||
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 || {}).map(([k, v]) => <div key={k} className="text-[12px] text-bad mt-1">{k}: {v}</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 linked = clients.filter((c) => c.linked);
|
||
const [f, setF] = useState({ client: linked[0]?.slug || "", platforms: ["instagram", "facebook"], type: "IMAGE", media: [], caption: "", fbCaption: "", collaborators: "", date: "", time: "" });
|
||
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 {
|
||
await api("/api/schedule", { method: "POST", body: {
|
||
client: f.client, platforms: f.platforms, 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);
|
||
};
|
||
const togglePlat = (p) => set("platforms", f.platforms.includes(p) ? f.platforms.filter((x) => x !== p) : [...f.platforms, p]);
|
||
|
||
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} 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)}>
|
||
{linked.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="Post to">
|
||
<div className="flex gap-4 h-9 items-center text-[13px]">
|
||
{["instagram", "facebook"].map((p) => <label key={p} className="flex items-center gap-1.5 capitalize"><input type="checkbox" checked={f.platforms.includes(p)} onChange={() => togglePlat(p)} /> {p}</label>)}
|
||
</div>
|
||
</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>
|
||
{f.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>
|
||
)}
|
||
{f.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>
|
||
);
|
||
}
|