- Approved tab with a client selected: tick items, 'Schedule N selected' - Start date, one post every 1/2/3/4/7 days, times used in turn (pre-filled with the client's best times) - Items with their own planned date keep it; creates drafts only, each still needs approval - Platforms needing per-post details (YouTube, TikTok, Pinterest, Reddit) stay one at a time Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
25 KiB
JavaScript
333 lines
25 KiB
JavaScript
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 (
|
||
<div className="space-y-5">
|
||
<header className="flex items-end justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-[22px] font-extrabold">Content library</h1>
|
||
<p className="text-muted mt-0.5">Designers and editors upload finished work here. Approved work is scheduled from here or picked up by the agent for the weekly plan.</p>
|
||
</div>
|
||
<Button variant="primary" icon={FolderUp} onClick={() => setDialog({ kind: "upload" })}>Upload work</Button>
|
||
</header>
|
||
<div className="flex gap-2 items-center flex-wrap">
|
||
<select id="lib-client" className="input h-9 w-auto" value={client} onChange={(e) => setClient(e.target.value)}>
|
||
<option value="">All clients</option>
|
||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||
</select>
|
||
{["submitted", "changes", "approved", "scheduled", "all"].map((k) => (
|
||
<button key={k} onClick={() => 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)})` : ""}
|
||
</button>
|
||
))}
|
||
<Button variant="ghost" size="sm" icon={RefreshCw} onClick={load}>Refresh</Button>
|
||
{canBulk && <Button size="sm" variant="primary" icon={CalendarPlus} disabled={!picked.length} onClick={() => setDialog({ kind: "bulk" })} className="ml-auto">Schedule {picked.length || ""} selected</Button>}
|
||
{tab === "approved" && !client && <span className="text-[12px] text-muted ml-auto">Pick a client to schedule several at once.</span>}
|
||
</div>
|
||
<ErrorNote error={error} />
|
||
{!data ? <Spinner /> : !items.length ? (
|
||
<Panel><Empty icon={ImageIcon} title="Nothing here">{tab === "submitted" ? "No work waiting for review." : tab === "approved" ? "No approved work waiting to be scheduled." : "No items in this view."}</Empty></Panel>
|
||
) : (
|
||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||
{items.map((i) => (
|
||
<div key={i.id} className={cx("panel overflow-hidden flex flex-col relative", picked.includes(i.id) && "ring-2 ring-brand")}>
|
||
{canBulk && (
|
||
<label className="absolute top-2 left-2 z-10 bg-white/95 rounded px-2 py-1 text-[12px] flex items-center gap-1.5 shadow-sm">
|
||
<input type="checkbox" checked={picked.includes(i.id)} onChange={() => setPicked((xs) => (xs.includes(i.id) ? xs.filter((x) => x !== i.id) : [...xs, i.id]))} aria-label={`Select ${i.title}`} />Select
|
||
</label>
|
||
)}
|
||
<Preview item={i} />
|
||
<div className="p-3.5 flex-1 flex flex-col gap-1.5">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Badge tone={TONE[i.status]} dot>{LABEL[i.status]}</Badge>
|
||
<span className="text-[12px] text-muted">{i.format.toLowerCase()}{i.files.length > 1 ? ` · ${i.files.length} files` : ""}{i.version > 1 ? ` · v${i.version}` : ""}</span>
|
||
</div>
|
||
<div className="font-semibold text-[13.5px] leading-snug">{i.title}</div>
|
||
<div className="text-[12px] text-muted">{name(i.client)} · {i.uploadedBy} · {timeAgo(i.updatedAt)}{i.taskRef ? ` · task #${i.taskRef}` : ""}{i.forDate ? ` · for ${i.forDate}` : ""}</div>
|
||
{i.captionIdea && <p className="text-[12.5px] text-ink-2 line-clamp-2">{i.captionIdea}</p>}
|
||
{i.notes && <p className="text-[12px] text-muted line-clamp-2">Note: {i.notes}</p>}
|
||
{i.status === "changes" && i.reviewNote && <p className="text-[12.5px] text-warn bg-warn-soft rounded px-2 py-1">{i.reviewedBy}: {i.reviewNote}</p>}
|
||
<div className="mt-auto pt-2 flex gap-2 flex-wrap">
|
||
{isAdmin && ["submitted", "changes"].includes(i.status) && <Button size="sm" variant="primary" icon={Check} onClick={() => setDialog({ kind: "review", item: i })}>Review</Button>}
|
||
{i.status === "changes" && <Button size="sm" icon={Upload} onClick={() => setDialog({ kind: "version", item: i })}>Upload new version</Button>}
|
||
{["approved", "scheduled"].includes(i.status) && <Button size="sm" variant={i.status === "approved" ? "primary" : "secondary"} icon={CalendarPlus} onClick={() => setDialog({ kind: "schedule", item: i })}>{i.status === "scheduled" ? "Schedule again" : "Schedule"}</Button>}
|
||
{i.status !== "archived" && <Button size="sm" variant="ghost" icon={Archive} onClick={() => api(`/api/library/${i.id}/archive`, { method: "POST" }).then(load).catch(setError)}>Archive</Button>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{dialog?.kind === "upload" && <UploadDialog clients={clients} defaultClient={client} onClose={() => setDialog(null)} onDone={() => { setTab("submitted"); done(); }} />}
|
||
{dialog?.kind === "version" && <VersionDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
|
||
{dialog?.kind === "review" && <ReviewDialog item={dialog.item} onClose={() => setDialog(null)} onDone={done} />}
|
||
{dialog?.kind === "bulk" && <BulkDialog client={client} items={(data?.items || []).filter((i) => picked.includes(i.id))} onClose={() => setDialog(null)} onDone={() => { setPicked([]); done(); }} />}
|
||
{dialog?.kind === "schedule" && <ScheduleDialog item={dialog.item} clientName={name(dialog.item.client)} onClose={() => setDialog(null)} onDone={done} />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Preview({ item, big }) {
|
||
const [idx, setIdx] = useState(0);
|
||
const f = item.files[idx];
|
||
return (
|
||
<div className={cx("relative bg-canvas border-b border-line", big ? "aspect-[4/5] max-h-[420px]" : "aspect-[4/5]")}>
|
||
{isVid(f) ? <video src={fileUrl(f)} className="size-full object-contain bg-black" controls={big} muted={!big} preload="metadata" /> : <img src={fileUrl(f)} alt="" className="size-full object-cover" loading="lazy" />}
|
||
{item.files.length > 1 && (
|
||
<div className="absolute bottom-2 inset-x-0 flex justify-center gap-1">
|
||
{item.files.map((_, n) => <button key={n} aria-label={`File ${n + 1}`} onClick={() => setIdx(n)} className={cx("size-2 rounded-full", n === idx ? "bg-white" : "bg-white/50")} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilePicker({ files, setFiles, accept = "image/jpeg,image/png,video/mp4,video/quicktime" }) {
|
||
return (
|
||
<div className="flex flex-wrap gap-2 items-center">
|
||
{files.map((f, n) => <span key={n} className="text-[12px] bg-canvas border border-line rounded px-2 py-1">{n + 1}. {f.name} <span className="text-faint">{(f.size / 1048576).toFixed(1)} MB</span> <button className="text-bad ml-1" onClick={() => setFiles(files.filter((_, k) => k !== n))}>×</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" /> Add files
|
||
<input type="file" className="hidden" multiple accept={accept} onChange={(e) => { setFiles([...files, ...e.target.files]); e.target.value = ""; }} />
|
||
</label>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open onClose={onClose} title="Upload finished work" wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={!!progress} disabled={!f.client || !files.length} onClick={submit}>Submit for review</Button></>}>
|
||
{planned.length > 0 && (
|
||
<Field label="For calendar item (optional)" hint="Links the upload to its card on the content calendar and fills in the details.">
|
||
<select id="up-plan" className="input" value={f.planItem} onChange={(e) => pickPlan(e.target.value)}>
|
||
<option value="">Not from the calendar</option>
|
||
{planned.map((p) => <option key={p.id} value={p.id}>{p.date} · {p.title}{p.assignee ? ` (${p.assignee})` : ""}</option>)}
|
||
</select>
|
||
</Field>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Field label="Client"><select className="input" value={f.client} onChange={(e) => { set("client", e.target.value); set("planItem", ""); }}>{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}</select></Field>
|
||
<Field label="Format"><select className="input" value={f.format} onChange={(e) => set("format", e.target.value)}>{FORMATS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></Field>
|
||
<Field label="Title" hint="What is it? e.g. Diwali sweets box reel"><input className="input" value={f.title} onChange={(e) => set("title", e.target.value)} /></Field>
|
||
<Field label="Planned for (optional)"><input type="date" className="input" value={f.forDate} onChange={(e) => set("forDate", e.target.value)} /></Field>
|
||
</div>
|
||
<Field label="Files" hint="Final exports only. Images 1080×1350 (feed) or 1080×1920 (Story/Reel). Carousel: add files in order.">
|
||
<FilePicker files={files} setFiles={setFiles} />
|
||
</Field>
|
||
{progress && <p className="text-[12.5px] text-muted">{progress}</p>}
|
||
<Field label="Odoo task (optional)" hint="Links the upload to the task: a note is added and it moves to Review.">
|
||
<select className="input" value={f.taskRef} onChange={(e) => set("taskRef", e.target.value)}>
|
||
<option value="">No task</option>
|
||
{tasks.map((t) => <option key={t.id} value={t.id}>#{t.id} {t.name}{t.project ? ` (${t.project.name})` : ""}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Caption idea (optional)"><textarea className="input h-20" value={f.captionIdea} onChange={(e) => set("captionIdea", e.target.value)} /></Field>
|
||
<Field label="Notes for the reviewer (optional)" hint="Music used, trend it follows, anything to check."><input className="input" value={f.notes} onChange={(e) => set("notes", e.target.value)} /></Field>
|
||
<ErrorNote error={error} />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open onClose={onClose} title={`New version: ${item.title}`} footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={!!progress} disabled={!files.length} onClick={submit}>Resubmit</Button></>}>
|
||
{item.reviewNote && <p className="text-[13px] text-warn bg-warn-soft rounded px-3 py-2 flex gap-2"><MessageSquareWarning className="size-4 shrink-0 mt-0.5" />{item.reviewedBy}: {item.reviewNote}</p>}
|
||
<Field label="Replacement files" hint={`Same format (${item.format.toLowerCase()}); these replace all current files.`}><FilePicker files={files} setFiles={setFiles} /></Field>
|
||
{progress && <p className="text-[12.5px] text-muted">{progress}</p>}
|
||
<ErrorNote error={error} />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open onClose={onClose} title={`Review: ${item.title}`} wide footer={<><Button onClick={onClose}>Close</Button><Button variant="danger" loading={busy === "changes"} disabled={!note.trim()} onClick={() => decide("changes")}>Request changes</Button><Button variant="primary" icon={Check} loading={busy === "approve"} onClick={() => decide("approve")}>Approve</Button></>}>
|
||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4">
|
||
<div className="rounded-md overflow-hidden border border-line"><Preview item={item} big /></div>
|
||
<div className="space-y-2 text-[13px]">
|
||
<div><b>{item.format.toLowerCase()}</b> · v{item.version} · by {item.uploadedBy}</div>
|
||
{item.captionIdea && <div><div className="text-muted text-[12px]">Caption idea</div><p className="whitespace-pre-line">{item.captionIdea}</p></div>}
|
||
{item.notes && <div><div className="text-muted text-[12px]">Notes</div><p>{item.notes}</p></div>}
|
||
<div className="text-[12px] text-muted">Check: first 3 seconds hook, text readable on a phone, brand fonts/colours, no client-sensitive names, music licensed for business use.</div>
|
||
<Field label="Feedback (required to request changes)"><textarea className="input h-24" value={note} onChange={(e) => setNote(e.target.value)} placeholder="e.g. Cut the intro to 1 second, make the price bigger" /></Field>
|
||
</div>
|
||
</div>
|
||
<ErrorNote error={error} />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open onClose={onClose} title={`Schedule for ${clientName}`} wide footer={<><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.date || !f.time || !chosen.platforms.length} onClick={submit}>Add to Publishing as draft</Button></>}>
|
||
<p className="text-[12.5px] text-muted">This creates a draft in Publishing. An admin still approves it there before it goes live.</p>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<Field label={`When (${tz?.timezone?.replace("_", " ") || "client time"})`} hint={tz ? `Good times: ${tz.times.join(", ")}` : null}>
|
||
<div className="flex gap-2"><input type="date" className="input flex-1" value={f.date} onChange={(e) => set("date", e.target.value)} /><input type="time" className="input w-[120px]" value={f.time} onChange={(e) => set("time", e.target.value)} /></div>
|
||
</Field>
|
||
</div>
|
||
<Field label="Caption"><textarea className="input h-28" value={f.caption} onChange={(e) => set("caption", e.target.value)} /></Field>
|
||
<Field label="Post to"><PlatformPicker client={item.client} type={item.format} caption={f.caption} value={sel} onChange={setSel} available={available} media={item.files} /></Field>
|
||
{sel.platforms.includes("facebook") && <Field label="Facebook caption (optional)"><textarea className="input h-16" value={f.fbCaption} onChange={(e) => set("fbCaption", e.target.value)} /></Field>}
|
||
{sel.platforms.includes("instagram") && item.format !== "STORY" && <Field label="Collab invites (optional)"><input className="input" placeholder="@username" value={f.collaborators} onChange={(e) => set("collaborators", e.target.value)} /></Field>}
|
||
<ErrorNote error={error} />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Dialog open onClose={result ? onDone : onClose} title={`Schedule ${items.length} item${items.length === 1 ? "" : "s"}`} wide
|
||
footer={result ? <Button variant="primary" onClick={onDone}>Done</Button> : <><Button onClick={onClose}>Cancel</Button><Button variant="primary" loading={busy} disabled={!f.platforms.length || !f.startDate} onClick={go}>Create {items.length} draft{items.length === 1 ? "" : "s"}</Button></>}>
|
||
{result ? (
|
||
<div className="space-y-2 text-[13px]">
|
||
<p><b>{result.created.length}</b> draft{result.created.length === 1 ? "" : "s"} created. They're in Publishing, waiting for approval.</p>
|
||
<ul className="space-y-1">{result.created.map((c) => <li key={c.id}>{c.localTime} · {c.title}{c.warnings?.length ? <span className="text-warn"> · {c.warnings.length} warning{c.warnings.length === 1 ? "" : "s"}</span> : null}</li>)}</ul>
|
||
{result.errors.map((e) => <div key={e.id} className="text-bad">{e.title}: {e.error}</div>)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
<div className="grid sm:grid-cols-3 gap-3">
|
||
<Field label="Start on"><input id="bulk-start" type="date" className="input" value={f.startDate} onChange={(e) => set("startDate", e.target.value)} /></Field>
|
||
<Field label="One post every"><select id="bulk-every" className="input" value={f.everyDays} onChange={(e) => set("everyDays", e.target.value)}>{[1, 2, 3, 4, 7].map((n) => <option key={n} value={n}>{n === 1 ? "day" : n === 7 ? "week" : `${n} days`}</option>)}</select></Field>
|
||
<Field label="At (client time)" hint="Comma-separated; used in turn. Pre-filled with this client's best times."><input id="bulk-times" className="input" value={f.times} onChange={(e) => set("times", e.target.value)} /></Field>
|
||
</div>
|
||
<Field label="Post to" hint="Platforms that need extra details per post (YouTube title, TikTok audience) are scheduled one at a time instead.">
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[13px]">
|
||
{usable.length ? usable.map((p) => (
|
||
<label key={p.id} htmlFor={`bulk-pl-${p.id}`} className="flex items-center gap-1.5"><input id={`bulk-pl-${p.id}`} type="checkbox" checked={f.platforms.includes(p.id)} onChange={() => set("platforms", f.platforms.includes(p.id) ? f.platforms.filter((x) => x !== p.id) : [...f.platforms, p.id])} />{BULK_PLATFORMS[p.id]}</label>
|
||
)) : <span className="text-muted">No connected platforms for this client.</span>}
|
||
</div>
|
||
</Field>
|
||
<p className="text-[12.5px] text-muted">Items with their own planned date keep it. Each draft uses the item's caption idea; edit captions in Publishing before approving.</p>
|
||
<ErrorNote error={error} />
|
||
</div>
|
||
)}
|
||
</Dialog>
|
||
);
|
||
}
|