metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
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>
2026-10-19 13:02:29 -04:00

196 lines
12 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";
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>
);
}