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 ? : ; const drafts = data.posts.filter((p) => p.status === "draft").length; const approved = data.posts.filter((p) => p.status === "approved").length; return (

Publishing

Posts go out at each client's local time. {drafts} waiting for approval · {approved} approved.

{!data.publishing &&
Publishing is switched off. Drafts can be prepared, but nothing will be approved or posted until ALLOW_PUBLISH=true is set in .env.
}
{["upcoming", "published", "all"].map((k) => ( ))}
{!posts.length ? ( Create a post, or ask the agent to schedule the week's plan. Every post needs an admin's approval before it publishes. ) : (
    {posts.map((p) => (
  • {p.clientName} {p.status} {p.type.toLowerCase()} · {p.platforms.join(" + ")}
    {p.localTime} ({p.timezone.split("/").pop().replace("_", " ")} time)

    {p.caption || No caption}

    {p.collaborators?.length > 0 &&
    Collab: {p.collaborators.map((c) => "@" + c).join(", ")}
    } {Object.entries(p.errors || {}).map(([k, v]) =>
    {k}: {v}
    )} {Object.entries(p.results || {}).filter(([, v]) => v?.permalink).map(([k, v]) => View on {k})} {p.results?.facebook?.scheduledOnFacebook && !p.results.facebook.cancelled &&
    Facebook part is scheduled in Meta Planner.
    }
    by {p.createdBy}{p.approvedBy ? ` · approved by ${p.approvedBy}` : ""}
    {p.status === "draft" && isAdmin && } {!["published", "cancelled", "publishing"].includes(p.status) && }
  • ))}
)}
{composing && setComposing(false)} onSaved={() => { setComposing(false); load(); }} />}
); } 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 (
{!src ? : isVid ?
); } 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 ( }>
{["instagram", "facebook"].map((p) => )}
set("date", e.target.value)} /> set("time", e.target.value)} />
{f.type !== "TEXT" && (
{f.media.map((m, i) => {i + 1}. {m.split(/[\\/]/).pop().replace(/^\d+-/, "")} )}
)}