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 ? : ; 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 || {}).filter(([k]) => !p.waiting?.[k]).map(([k, v]) =>
    {k}: {v}
    )} {Object.entries(p.waiting || {}).map(([k, until]) =>
    {k}: daily limit reached. Goes out automatically after {new Date(until).toLocaleString("en-CA", { weekday: "short", hour: "numeric", minute: "2-digit" })}.
    )} {p.status === "draft" && (p.warnings || []).map((w) =>
    {w}
    )} {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 && } {["failed", "partial"].includes(p.status) && 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 [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 ( }>
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+-/, "")} )}
)}