import { useCallback, useEffect, useMemo, useState } from "react";
import { Archive, CalendarPlus, Check, FolderUp, Image as ImageIcon, MessageSquareWarning, RefreshCw, Upload } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, cx, Dialog, Empty, ErrorNote, Field, Panel, Spinner, timeAgo } from "../ui.jsx";
import PlatformPicker, { defaultPlatforms, selectionFor, useClientPlatforms } from "../PlatformPicker.jsx";
const TONE = { submitted: "brand", changes: "warn", approved: "ok", scheduled: "neutral", archived: "neutral" };
const LABEL = { submitted: "To review", changes: "Changes requested", approved: "Ready to schedule", scheduled: "Scheduled", archived: "Archived" };
const FORMATS = [["IMAGE", "Single image"], ["REELS", "Reel (one video)"], ["CAROUSEL", "Carousel (2–10 files)"], ["STORY", "Story"]];
const fileUrl = (p) => `/api/library/file?path=${encodeURIComponent(p)}`;
const isVid = (p) => /\.(mp4|mov|m4v)$/i.test(p || "");
const baseName = (p) => String(p).split(/[\\/]/).pop().replace(/^\d+-\w{4}-/, "");
async function uploadFiles(client, files, onProgress) {
const paths = [];
for (const [n, file] of [...files].entries()) {
onProgress?.(`Uploading ${n + 1} of ${files.length}: ${file.name}`);
const res = await fetch("/api/library/upload", { method: "POST", credentials: "same-origin", headers: { "X-Client": client, "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `Upload failed (${file.name})`);
paths.push(j.path);
}
onProgress?.(null);
return paths;
}
export default function Library() {
const { isAdmin } = useAuth();
const [clients, setClients] = useState([]);
const [client, setClient] = useState("");
const [tab, setTab] = useState(isAdmin ? "submitted" : "changes");
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [dialog, setDialog] = useState(null); // {kind:'upload'} | {kind:'review'|'schedule'|'version', item} | {kind:'bulk'}
const [picked, setPicked] = useState([]);
const canBulk = tab === "approved" && Boolean(client);
useEffect(() => { setPicked([]); }, [client, tab]);
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
const load = useCallback(() => api(`/api/library${client ? `?client=${client}` : ""}`).then(setData).catch(setError), [client]);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => (data?.items || []).filter((i) => tab === "all" ? i.status !== "archived" : i.status === tab), [data, tab]);
const count = (s) => (data?.items || []).filter((i) => i.status === s).length;
const name = (slug) => clients.find((c) => c.slug === slug)?.name || slug;
const done = () => { setDialog(null); load(); };
return (
setClient(e.target.value)}>
All clients
{clients.map((c) => {c.name} )}
{["submitted", "changes", "approved", "scheduled", "all"].map((k) => (
setTab(k)} className={cx("h-9 px-3 rounded-md text-[13px] font-medium", tab === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>
{k === "all" ? "All" : LABEL[k]}{k !== "all" && data ? ` (${count(k)})` : ""}
))}
Refresh
{canBulk && setDialog({ kind: "bulk" })} className="ml-auto">Schedule {picked.length || ""} selected }
{tab === "approved" && !client && Pick a client to schedule several at once. }
{!data ?
: !items.length ? (
{tab === "submitted" ? "No work waiting for review." : tab === "approved" ? "No approved work waiting to be scheduled." : "No items in this view."}
) : (
{items.map((i) => (
{canBulk && (
setPicked((xs) => (xs.includes(i.id) ? xs.filter((x) => x !== i.id) : [...xs, i.id]))} aria-label={`Select ${i.title}`} />Select
)}
{LABEL[i.status]}
{i.format.toLowerCase()}{i.files.length > 1 ? ` · ${i.files.length} files` : ""}{i.version > 1 ? ` · v${i.version}` : ""}
{i.title}
{name(i.client)} · {i.uploadedBy} · {timeAgo(i.updatedAt)}{i.taskRef ? ` · task #${i.taskRef}` : ""}{i.forDate ? ` · for ${i.forDate}` : ""}
{i.captionIdea &&
{i.captionIdea}
}
{i.notes &&
Note: {i.notes}
}
{i.status === "changes" && i.reviewNote &&
{i.reviewedBy}: {i.reviewNote}
}
{isAdmin && ["submitted", "changes"].includes(i.status) && setDialog({ kind: "review", item: i })}>Review }
{i.status === "changes" && setDialog({ kind: "version", item: i })}>Upload new version }
{["approved", "scheduled"].includes(i.status) && setDialog({ kind: "schedule", item: i })}>{i.status === "scheduled" ? "Schedule again" : "Schedule"} }
{i.status !== "archived" && api(`/api/library/${i.id}/archive`, { method: "POST" }).then(load).catch(setError)}>Archive }
))}
)}
{dialog?.kind === "upload" &&
setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />}
{dialog?.kind === "version" && setDialog(null)} onDone={done} />}
{dialog?.kind === "review" && setDialog(null)} onDone={done} />}
{dialog?.kind === "bulk" && picked.includes(i.id))} onClose={() => setDialog(null)} onDone={() => { setPicked([]); done(); }} />}
{dialog?.kind === "schedule" && setDialog(null)} onDone={done} />}
);
}
function Preview({ item, big }) {
const [idx, setIdx] = useState(0);
const f = item.files[idx];
return (
{isVid(f) ?
:
}
{item.files.length > 1 && (
{item.files.map((_, n) => setIdx(n)} className={cx("size-2 rounded-full", n === idx ? "bg-white" : "bg-white/50")} />)}
)}
);
}
function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,video/quicktime" }) {
return (
{files.map((f, n) => {n + 1}. {f.name} {(f.size / 1048576).toFixed(1)} MB setFiles(files.filter((_, k) => k !== n))}>× )}
Add files
{ setFiles([...files, ...e.target.files]); e.target.value = ""; }} />
);
}
function UploadDialog({ clients, defaultClient, onClose, onDone }) {
const [f, setF] = useState({ client: defaultClient || clients[0]?.slug || "", title: "", format: "IMAGE", captionIdea: "", notes: "", taskRef: "", forDate: "", planItem: "" });
const [planned, setPlanned] = useState([]);
const [files, setFiles] = useState([]);
const [tasks, setTasks] = useState([]);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api("/api/team").then((r) => setTasks(r.tasks || [])).catch(() => {}); }, []);
useEffect(() => {
if (!f.client) return;
const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10), to = new Date(Date.now() + 60 * 86400000).toISOString().slice(0, 10);
api(`/api/calendar?client=${f.client}&from=${from}&to=${to}`).then((r) => setPlanned(r.items.filter((i) => i.kind === "plan" && ["idea", "brief", "production"].includes(i.stage)))).catch(() => setPlanned([]));
}, [f.client]);
const pickPlan = (id) => {
const p = planned.find((x) => x.id === id);
setF((s) => ({ ...s, planItem: id, ...(p ? { title: s.title || p.title, format: p.format === "TEXT" ? s.format : p.format, forDate: p.date, captionIdea: s.captionIdea || p.caption || "", taskRef: p.taskRef || s.taskRef } : {}) }));
};
useEffect(() => { if (files.length > 1 && f.format !== "CAROUSEL") set("format", "CAROUSEL"); else if (files.length === 1 && isVid(files[0].name) && f.format === "IMAGE") set("format", "REELS"); }, [files]); // eslint-disable-line
const submit = async () => {
setError(null);
try {
const paths = await uploadFiles(f.client, files, setProgress);
await api("/api/library", { method: "POST", body: { ...f, files: paths } });
onDone();
} catch (e) { setError(e); setProgress(null); }
};
return (
Cancel Submit for review >}>
{planned.length > 0 && (
pickPlan(e.target.value)}>
Not from the calendar
{planned.map((p) => {p.date} · {p.title}{p.assignee ? ` (${p.assignee})` : ""} )}
)}
{ set("client", e.target.value); set("planItem", ""); }}>{clients.map((c) => {c.name} )}
set("format", e.target.value)}>{FORMATS.map(([v, l]) => {l} )}
set("title", e.target.value)} />
set("forDate", e.target.value)} />
{progress && {progress}
}
set("taskRef", e.target.value)}>
No task
{tasks.map((t) => #{t.id} {t.name}{t.project ? ` (${t.project.name})` : ""} )}
set("notes", e.target.value)} />
);
}
function VersionDialog({ item, onClose, onDone }) {
const [files, setFiles] = useState([]);
const [progress, setProgress] = useState(null);
const [error, setError] = useState(null);
const submit = async () => {
setError(null);
try { const paths = await uploadFiles(item.client, files, setProgress); await api(`/api/library/${item.id}`, { method: "PATCH", body: { files: paths } }); onDone(); }
catch (e) { setError(e); setProgress(null); }
};
return (
Cancel Resubmit >}>
{item.reviewNote && {item.reviewedBy}: {item.reviewNote}
}
{progress && {progress}
}
);
}
function ReviewDialog({ item, onClose, onDone }) {
const [note, setNote] = useState("");
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
const decide = async (decision) => {
setBusy(decision); setError(null);
try { await api(`/api/library/${item.id}/review`, { method: "POST", body: { decision, note } }); onDone(); } catch (e) { setError(e); setBusy(null); }
};
return (
Close decide("changes")}>Request changes decide("approve")}>Approve >}>
{item.format.toLowerCase()} · v{item.version} · by {item.uploadedBy}
{item.captionIdea &&
Caption idea
{item.captionIdea}
}
{item.notes &&
}
Check: first 3 seconds hook, text readable on a phone, brand fonts/colours, no client-sensitive names, music licensed for business use.
);
}
function ScheduleDialog({ item, clientName, onClose, onDone }) {
const [f, setF] = useState({ date: item.forDate || "", time: "", caption: item.captionIdea || "", fbCaption: "", collaborators: "" });
const available = useClientPlatforms(item.client);
const [sel, setSel] = useState({ platforms: [], options: {} });
useEffect(() => { if (!available.loading) setSel({ platforms: defaultPlatforms(available.list), options: {} }); }, [available]);
const chosen = selectionFor(sel, available, item.format);
const [tz, setTz] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api(`/api/schedule/suggest?client=${item.client}`).then(setTz).catch(() => {}); }, [item.client]);
const submit = async () => {
setBusy(true); setError(null);
try {
if (chosen.problems.length) throw new Error(chosen.problems[0]);
await api(`/api/library/${item.id}/schedule`, { method: "POST", body: { platforms: chosen.platforms, options: chosen.options, localTime: `${f.date} ${f.time}`, caption: f.caption, fbCaption: f.fbCaption || undefined, collaborators: f.collaborators.split(/[\s,]+/).map((x) => x.replace(/^@/, "")).filter(Boolean) } });
onDone();
} catch (e) { setError(e); setBusy(false); }
};
return (
Cancel Add to Publishing as draft >}>
This creates a draft in Publishing. An admin still approves it there before it goes live.
{sel.platforms.includes("facebook") && }
{sel.platforms.includes("instagram") && item.format !== "STORY" && set("collaborators", e.target.value)} /> }
);
}
const BULK_PLATFORMS = { instagram: "Instagram", facebook: "Facebook", gbp: "Google Business", linkedin: "LinkedIn", threads: "Threads" };
/** Schedule several approved items: one per slot from a start date, every N days, cycling through the times. Drafts only. */
function BulkDialog({ client, items, onClose, onDone }) {
const available = useClientPlatforms(client);
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
const [f, setF] = useState({ startDate: tomorrow, everyDays: 2, times: "", platforms: [] });
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
useEffect(() => { api(`/api/schedule/suggest?client=${client}`).then((r) => set("times", r.times.join(", "))).catch(() => set("times", "10:00")); }, [client]);
const usable = (available.list || []).filter((p) => BULK_PLATFORMS[p.id]);
useEffect(() => { if (!available.loading) set("platforms", usable.filter((p) => ["instagram", "facebook"].includes(p.id)).map((p) => p.id)); }, [available.loading]); // eslint-disable-line react-hooks/exhaustive-deps
const go = async () => {
setBusy(true); setError(null);
try { setResult(await api("/api/library/bulk-schedule", { method: "POST", body: { client, ids: items.map((i) => i.id), startDate: f.startDate, everyDays: Number(f.everyDays), times: f.times.split(/[\s,]+/).filter(Boolean), platforms: f.platforms } })); }
catch (e) { setError(e); }
setBusy(false);
};
return (
Done : <>Cancel Create {items.length} draft{items.length === 1 ? "" : "s"} >}>
{result ? (
{result.created.length} draft{result.created.length === 1 ? "" : "s"} created. They're in Publishing, waiting for approval.
{result.created.map((c) => {c.localTime} · {c.title}{c.warnings?.length ? · {c.warnings.length} warning{c.warnings.length === 1 ? "" : "s"} : null} )}
{result.errors.map((e) =>
{e.title}: {e.error}
)}
) : (
)}
);
}