Content calendar: plan, production and the publishing queue per client
- Plan items (idea → brief → production → ready) with brief, caption, platforms, assignee, pillar - Calendar merges plan items, library items with a target date and queued/published posts - Month and week views, drag a card to another day (or change the date in the card), add from any day - MCP tools content_plan, plan_add, plan_update; weekly-plan skill now fills the calendar Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
999e9fbcec
commit
e304626986
@ -12,4 +12,9 @@ For each client requested:
|
||||
- At least one post that tests something new; say what it's testing.
|
||||
5. For every post that needs creative work, write a brief using `playbooks/team-briefs.md`.
|
||||
6. Save to `clients/<slug>/calendar/YYYY-Www.md` (ISO week of the week being planned).
|
||||
7. Give the user a short summary and ask for approval before anything goes to the team or the client.
|
||||
7. Put the week on the dashboard's Content calendar: first call `content_plan` for that week so nothing is doubled
|
||||
(approved library items and queued posts already show there), then `plan_add` one item per new post with date,
|
||||
optional time, a short title, format, platforms, status `brief` (or `idea` if it still needs a concept), the brief
|
||||
and the draft caption. Items with finished, approved library work should be scheduled with `schedule_from_library`
|
||||
instead of added to the plan.
|
||||
8. Give the user a short summary and ask for approval before anything goes to the team or the client.
|
||||
|
||||
122
connector/src/plan.js
Normal file
122
connector/src/plan.js
Normal file
@ -0,0 +1,122 @@
|
||||
// Content plan: what each client will post and when, before it exists as finished work.
|
||||
// Flow per item: idea → brief → production → ready. Once scheduled it lives in the queue (schedule.js);
|
||||
// the calendar view merges plan items, library items with a date, and queued/published posts.
|
||||
// Shared by the dashboard (calendar UI) and the MCP connector (weekly-plan skill adds items).
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { PROJECT_ROOT } from "./env.js";
|
||||
import * as sched from "./schedule.js";
|
||||
import * as lib from "./library.js";
|
||||
|
||||
const FILE = process.env.QUORLA_PLAN_FILE || path.join(PROJECT_ROOT, "data", "plan.json");
|
||||
const CLIENTS = path.join(PROJECT_ROOT, "clients");
|
||||
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
||||
|
||||
const read = () => { try { return JSON.parse(fs.readFileSync(FILE, "utf8")); } catch { return []; } };
|
||||
const write = (items) => { const tmp = FILE + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(items, null, 2)); fs.renameSync(tmp, FILE); };
|
||||
const err = (msg, status = 400) => Object.assign(new Error(msg), { status });
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
export const STAGES = ["idea", "brief", "production", "ready"];
|
||||
export const FORMATS = ["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"];
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function checkClient(slug) {
|
||||
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(slug || "")) || !fs.existsSync(path.join(CLIENTS, slug))) throw err("Unknown client.", 404);
|
||||
return slug;
|
||||
}
|
||||
function clean(patch) {
|
||||
const out = {};
|
||||
if ("title" in patch) { out.title = String(patch.title || "").trim().slice(0, 140); if (!out.title) throw err("Give the item a title."); }
|
||||
if ("date" in patch) { if (!DATE_RE.test(String(patch.date))) throw err("Date must be YYYY-MM-DD."); out.date = patch.date; }
|
||||
if ("time" in patch) { const t = String(patch.time || ""); if (t && !/^\d{2}:\d{2}$/.test(t)) throw err("Time must be HH:MM."); out.time = t; }
|
||||
if ("status" in patch) { if (!STAGES.includes(patch.status)) throw err(`Status must be one of: ${STAGES.join(", ")}.`); out.status = patch.status; }
|
||||
if ("format" in patch) { if (!FORMATS.includes(patch.format)) throw err(`Format must be one of: ${FORMATS.join(", ")}.`); out.format = patch.format; }
|
||||
if ("platforms" in patch) out.platforms = (patch.platforms || []).map(String).slice(0, 10);
|
||||
for (const k of ["brief", "caption", "pillar", "assignee", "notes"]) if (k in patch) out[k] = String(patch[k] ?? "").slice(0, 5000);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function listItems({ client, from, to } = {}) {
|
||||
return read().filter((i) => (!client || i.client === client) && (!from || i.date >= from) && (!to || i.date <= to));
|
||||
}
|
||||
|
||||
export function createItem(data, by = "team") {
|
||||
const client = checkClient(data.client);
|
||||
const fields = clean({ title: data.title, date: data.date, status: data.status || "idea", format: data.format || "IMAGE", platforms: data.platforms || [], time: data.time || "", brief: data.brief || "", caption: data.caption || "", pillar: data.pillar || "", assignee: data.assignee || "", notes: data.notes || "" });
|
||||
const item = { id: crypto.randomUUID().slice(0, 8), client, ...fields, createdBy: by, createdAt: now(), updatedAt: now() };
|
||||
const items = read(); items.push(item); write(items);
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateItem(id, patch, by = "team") {
|
||||
const items = read(); const i = items.find((x) => x.id === id);
|
||||
if (!i) throw err("Plan item not found.", 404);
|
||||
Object.assign(i, clean(patch), { updatedBy: by, updatedAt: now() });
|
||||
write(items);
|
||||
return i;
|
||||
}
|
||||
|
||||
export function removeItem(id) {
|
||||
const items = read(); const n = items.length;
|
||||
const kept = items.filter((x) => x.id !== id);
|
||||
if (kept.length === n) throw err("Plan item not found.", 404);
|
||||
write(kept);
|
||||
}
|
||||
|
||||
/** Local calendar date (YYYY-MM-DD) and time of an instant in a time zone. */
|
||||
function localParts(iso, tz) {
|
||||
const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })
|
||||
.formatToParts(new Date(iso)).reduce((o, x) => ((o[x.type] = x.value), o), {});
|
||||
return { date: `${p.year}-${p.month}-${p.day}`, time: `${p.hour === "24" ? "00" : p.hour}:${p.minute}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything on a client's calendar between two dates (inclusive, client's local dates):
|
||||
* plan items (kind "plan"), library items with a target date ("library"), and queued/published posts ("post").
|
||||
* Each entry has a unified `stage`: idea | brief | production | ready | scheduled | published | failed.
|
||||
*/
|
||||
export function calendar({ client, from, to }) {
|
||||
checkClient(client);
|
||||
if (!DATE_RE.test(from) || !DATE_RE.test(to)) throw err("from and to must be YYYY-MM-DD.");
|
||||
const tz = sched.clientInfo(client).timezone;
|
||||
const out = listItems({ client, from, to }).map((i) => ({ ...i, kind: "plan", stage: i.status, editable: true }));
|
||||
|
||||
for (const l of lib.listItems({ client })) {
|
||||
if (!l.forDate || l.forDate < from || l.forDate > to || ["scheduled", "archived"].includes(l.status)) continue;
|
||||
out.push({
|
||||
id: l.id, kind: "library", client, title: l.title, date: l.forDate, time: "", format: l.format, platforms: [],
|
||||
stage: l.status === "approved" ? "ready" : "production", detail: l.status === "changes" ? "Changes requested" : l.status === "submitted" ? "Waiting for review" : "Approved in the library",
|
||||
caption: l.captionIdea || "", files: l.files || [], editable: true,
|
||||
});
|
||||
}
|
||||
for (const p of sched.listPosts({ client })) {
|
||||
if (p.status === "cancelled") continue;
|
||||
const at = localParts(p.publishAt, tz);
|
||||
if (at.date < from || at.date > to) continue;
|
||||
const stage = p.status === "published" || p.status === "partial" ? "published" : p.status === "failed" ? "failed" : "scheduled";
|
||||
out.push({
|
||||
id: p.id, kind: "post", client, title: (p.options?.youtube?.title || p.caption || "").split("\n")[0].slice(0, 90) || "Untitled post",
|
||||
date: at.date, time: at.time, format: p.type, platforms: p.platforms, stage,
|
||||
detail: p.status === "draft" ? "Draft, waiting for approval" : p.status === "approved" ? "Approved, will publish" : p.status === "partial" ? "Partly published" : null,
|
||||
caption: p.caption || "", files: p.media || [], editable: ["draft", "failed"].includes(p.status), postStatus: p.status,
|
||||
});
|
||||
}
|
||||
const order = { published: 0, failed: 1, scheduled: 2, ready: 3, production: 4, brief: 5, idea: 6 };
|
||||
return { timezone: tz, items: out.sort((a, b) => a.date.localeCompare(b.date) || (a.time || "99").localeCompare(b.time || "99") || order[a.stage] - order[b.stage]) };
|
||||
}
|
||||
|
||||
/** Move anything on the calendar to another day (keeps the time). Posts can only move while they're drafts. */
|
||||
export function moveEntry(kind, id, date, by = "team") {
|
||||
if (!DATE_RE.test(String(date))) throw err("Date must be YYYY-MM-DD.");
|
||||
if (kind === "plan") return updateItem(id, { date }, by);
|
||||
if (kind === "library") return lib.updateItem(id, { forDate: date }, by);
|
||||
if (kind === "post") {
|
||||
const p = sched.getPost(id);
|
||||
if (!["draft", "failed"].includes(p.status)) throw err("Only drafts can be moved. Cancel the approval first.");
|
||||
const { time } = localParts(p.publishAt, sched.clientInfo(p.client).timezone);
|
||||
return sched.updatePost(id, { localTime: `${date} ${time}` }, by);
|
||||
}
|
||||
throw err("Unknown calendar item.");
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { PROJECT_ROOT } from "./env.js";
|
||||
import * as meta from "./meta.js";
|
||||
import * as sched from "./schedule.js";
|
||||
import * as lib from "./library.js";
|
||||
import * as plan from "./plan.js";
|
||||
import * as extra from "./platforms/index.js";
|
||||
|
||||
const server = new McpServer({ name: "social-manager", version: "0.1.0" });
|
||||
@ -184,4 +185,24 @@ server.tool("schedule_from_library", "Turn an APPROVED library item into a DRAFT
|
||||
caption: z.string().optional(), fb_caption: z.string().optional(), collaborators: z.array(z.string()).default([]), options },
|
||||
wrap(async (a) => lib.scheduleItem(a.id, { platforms: a.platforms, localTime: a.local_time, caption: a.caption, fbCaption: a.fb_caption, collaborators: a.collaborators, options: a.options }, "agent")));
|
||||
|
||||
server.tool("content_plan", "A client's content calendar between two dates: plan items (idea/brief/production/ready), library items with a target date, and queued/published posts. Check this before planning so nothing is doubled.",
|
||||
{ client: z.string(), from: z.string().describe("YYYY-MM-DD"), to: z.string().describe("YYYY-MM-DD") },
|
||||
wrap(async ({ client, from, to }) => plan.calendar({ client, from, to })));
|
||||
|
||||
server.tool("plan_add", "Add items to a client's content calendar (the weekly-plan skill uses this). Each item is a planned post, not a scheduled one: it starts as an idea or brief for the team to produce. Never publishes.",
|
||||
{ client: z.string(), items: z.array(z.object({
|
||||
date: z.string().describe("YYYY-MM-DD in the client's time zone"), time: z.string().optional().describe("HH:MM, optional"),
|
||||
title: z.string().describe("Short working title shown on the calendar card"),
|
||||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).default("IMAGE"),
|
||||
platforms: z.array(z.string()).default([]), status: z.enum(["idea", "brief", "production", "ready"]).default("brief"),
|
||||
brief: z.string().default("").describe("Creative brief for the designer/editor"), caption: z.string().default("").describe("Draft caption"),
|
||||
pillar: z.string().default(""), assignee: z.string().default(""),
|
||||
})).min(1).max(30) },
|
||||
wrap(async ({ client, items }) => items.map((i) => plan.createItem({ client, ...i }, "agent"))));
|
||||
|
||||
server.tool("plan_update", "Update a content-plan item (status, date, brief, caption, assignee...).",
|
||||
{ id: z.string(), patch: z.object({ date: z.string().optional(), time: z.string().optional(), title: z.string().optional(), status: z.enum(["idea", "brief", "production", "ready"]).optional(),
|
||||
format: z.enum(["IMAGE", "REELS", "CAROUSEL", "STORY", "TEXT"]).optional(), platforms: z.array(z.string()).optional(), brief: z.string().optional(), caption: z.string().optional(), assignee: z.string().optional() }) },
|
||||
wrap(async ({ id, patch }) => plan.updateItem(id, patch, "agent")));
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
|
||||
37
connector/test/plan.test.mjs
Normal file
37
connector/test/plan.test.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
// Content plan: create, validate, show on the calendar, move between days, remove. Uses temp files only.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "quorla-plan-"));
|
||||
process.env.QUORLA_PLAN_FILE = path.join(TMP, "plan.json");
|
||||
process.env.QUORLA_SCHEDULE_DIR = path.join(TMP, "schedule");
|
||||
|
||||
const plan = await import("../src/plan.js");
|
||||
const { PROJECT_ROOT } = await import("../src/env.js");
|
||||
const client = fs.readdirSync(path.join(PROJECT_ROOT, "clients")).find((d) => !d.startsWith("_") && fs.existsSync(path.join(PROJECT_ROOT, "clients", d, "client.json")));
|
||||
|
||||
test("plan items: create, validate, calendar, move, remove", { skip: !client && "no client folder" }, () => {
|
||||
const a = plan.createItem({ client, title: "Behind the counter reel", date: "2031-03-04", format: "REELS", status: "brief", brief: "15s, morning prep" }, "t");
|
||||
assert.equal(a.status, "brief");
|
||||
assert.throws(() => plan.createItem({ client, title: "", date: "2031-03-04" }), /title/);
|
||||
assert.throws(() => plan.createItem({ client, title: "x", date: "4 March" }), /YYYY-MM-DD/);
|
||||
assert.throws(() => plan.updateItem(a.id, { status: "published" }), /Status must be/);
|
||||
assert.throws(() => plan.createItem({ client: "no-such-client", title: "x", date: "2031-03-04" }), /Unknown client/);
|
||||
|
||||
const cal = plan.calendar({ client, from: "2031-03-01", to: "2031-03-31" });
|
||||
const mine = cal.items.find((i) => i.id === a.id);
|
||||
assert.equal(mine.kind, "plan");
|
||||
assert.equal(mine.stage, "brief");
|
||||
|
||||
plan.moveEntry("plan", a.id, "2031-03-09");
|
||||
assert.equal(plan.calendar({ client, from: "2031-03-09", to: "2031-03-09" }).items.find((i) => i.id === a.id).date, "2031-03-09");
|
||||
assert.equal(plan.calendar({ client, from: "2031-03-01", to: "2031-03-08" }).items.some((i) => i.id === a.id), false);
|
||||
|
||||
plan.updateItem(a.id, { status: "ready", assignee: "Elena" });
|
||||
assert.equal(plan.listItems({ client }).find((i) => i.id === a.id).assignee, "Elena");
|
||||
plan.removeItem(a.id);
|
||||
assert.equal(plan.listItems({ client }).some((i) => i.id === a.id), false);
|
||||
});
|
||||
@ -15,6 +15,7 @@ import * as docs from "./docs.js";
|
||||
import * as meta from "../../connector/src/meta.js";
|
||||
import * as sched from "../../connector/src/schedule.js";
|
||||
import * as lib from "../../connector/src/library.js";
|
||||
import * as plan from "../../connector/src/plan.js";
|
||||
import * as extra from "../../connector/src/platforms/index.js";
|
||||
import { publicBaseUrl, resolveMediaToken } from "../../connector/src/platforms/media.js";
|
||||
import { DATA_DIR as _DD } from "./config.js";
|
||||
@ -289,6 +290,13 @@ app.get("/api/schedule/media", h(async (req, res) => {
|
||||
}));
|
||||
|
||||
|
||||
// ---------- content calendar: plan items + library items with a date + queued/published posts ----------
|
||||
app.get("/api/calendar", h(async (req) => plan.calendar({ client: String(req.query.client || ""), from: String(req.query.from || ""), to: String(req.query.to || "") })));
|
||||
app.post("/api/calendar/move", h(async (req) => ({ item: plan.moveEntry(String(req.body?.kind || ""), String(req.body?.id || ""), String(req.body?.date || ""), who(req)) })));
|
||||
app.post("/api/plan", h(async (req) => ({ item: plan.createItem(req.body || {}, who(req)) })));
|
||||
app.patch("/api/plan/:id", h(async (req) => ({ item: plan.updateItem(req.params.id, req.body || {}, who(req)) })));
|
||||
app.delete("/api/plan/:id", h(async (req) => { plan.removeItem(req.params.id); return { ok: true }; }));
|
||||
|
||||
// ---------- published posts and their numbers ----------
|
||||
app.get("/api/published", h(async (req) => published.feed({ client: req.query.client ? (clients.safeSlug(req.query.client), String(req.query.client)) : undefined, platform: req.query.platform ? String(req.query.platform) : undefined, days: Number(req.query.days) || 30 })));
|
||||
app.post("/api/published/refresh", h(async () => published.refreshStats()));
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { BrowserRouter, NavLink, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3 } from "lucide-react";
|
||||
import { LayoutGrid, Users2, Sparkles, Settings2, LogOut, CalendarClock, MessageCircle, FolderUp, Plug, BarChart3, CalendarDays } from "lucide-react";
|
||||
import { api } from "./api.js";
|
||||
import { cx, Spinner } from "./ui.jsx";
|
||||
import Login from "./pages/Login.jsx";
|
||||
@ -11,6 +11,7 @@ import Runs from "./pages/Runs.jsx";
|
||||
import Settings from "./pages/Settings.jsx";
|
||||
import Publishing from "./pages/Publishing.jsx";
|
||||
import Published from "./pages/Published.jsx";
|
||||
import Calendar from "./pages/Calendar.jsx";
|
||||
import Inbox from "./pages/Inbox.jsx";
|
||||
import Library from "./pages/Library.jsx";
|
||||
import Connections from "./pages/Connections.jsx";
|
||||
@ -20,6 +21,7 @@ export const useAuth = () => useContext(AuthCtx);
|
||||
|
||||
const NAV = [
|
||||
{ to: "/", label: "Clients", icon: LayoutGrid, end: true },
|
||||
{ to: "/calendar", label: "Content calendar", icon: CalendarDays },
|
||||
{ to: "/library", label: "Content library", icon: FolderUp },
|
||||
{ to: "/publishing", label: "Publishing", icon: CalendarClock },
|
||||
{ to: "/published", label: "Published", icon: BarChart3 },
|
||||
@ -119,6 +121,7 @@ export default function App() {
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/publishing" element={<Publishing />} />
|
||||
<Route path="/published" element={<Published />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/inbox" element={<Inbox />} />
|
||||
<Route path="/connections" element={<Connections />} />
|
||||
<Route path="/team" element={<Team />} />
|
||||
|
||||
264
dashboard/web/src/pages/Calendar.jsx
Normal file
264
dashboard/web/src/pages/Calendar.jsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "../api.js";
|
||||
import { Badge, Button, cx, Dialog, ErrorNote, Field, Spinner } from "../ui.jsx";
|
||||
|
||||
// Stage → colour and label. Stages before "scheduled" are the team's plan; the rest come from the queue.
|
||||
const STAGES = {
|
||||
idea: { label: "Idea", color: "#9a9da6" },
|
||||
brief: { label: "Brief", color: "#6c63b5" },
|
||||
production: { label: "In production", color: "#a86a12" },
|
||||
ready: { label: "Ready", color: "#2f7d4f" },
|
||||
scheduled: { label: "Scheduled", color: "#3b3778" },
|
||||
published: { label: "Published", color: "#c2461a" },
|
||||
failed: { label: "Failed", color: "#b4372b" },
|
||||
};
|
||||
const PLAN_STAGES = ["idea", "brief", "production", "ready"];
|
||||
const FORMATS = { IMAGE: "Image", REELS: "Reel / video", CAROUSEL: "Carousel", STORY: "Story", TEXT: "Text" };
|
||||
const PLATFORMS = { instagram: "Instagram", facebook: "Facebook", tiktok: "TikTok", youtube: "YouTube", gbp: "Google Business", linkedin: "LinkedIn", pinterest: "Pinterest", threads: "Threads" };
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const parse = (s) => { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); };
|
||||
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
||||
const mondayOf = (d) => addDays(d, -((d.getDay() + 6) % 7));
|
||||
const todayIso = () => iso(new Date());
|
||||
|
||||
function range(view, anchor) {
|
||||
if (view === "week") { const start = mondayOf(anchor); return { start, days: 7 }; }
|
||||
const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const start = mondayOf(first);
|
||||
const last = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0);
|
||||
const days = Math.ceil((last - start) / 86400000 / 7 + 1 / 7) * 7;
|
||||
return { start, days: Math.max(35, days) };
|
||||
}
|
||||
|
||||
function Card({ item, onOpen, compact }) {
|
||||
const st = STAGES[item.stage] || STAGES.idea;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
draggable={item.editable}
|
||||
onDragStart={(e) => { e.dataTransfer.setData("text/plain", JSON.stringify({ kind: item.kind, id: item.id })); e.dataTransfer.effectAllowed = "move"; }}
|
||||
onClick={() => onOpen(item)}
|
||||
className={cx("w-full text-left rounded-md bg-white border border-line hover:border-line-strong transition-colors px-2 py-1.5 border-l-[3px] focus-visible:outline-2", item.editable && "cursor-grab active:cursor-grabbing")}
|
||||
style={{ borderLeftColor: st.color }}
|
||||
title={`${st.label}: ${item.title}`}
|
||||
>
|
||||
<div className={cx("text-[12.5px] font-medium text-ink", compact ? "truncate" : "line-clamp-2")}>{item.title}</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{item.time && <span className="num">{item.time} · </span>}{FORMATS[item.format] || item.format} · {st.label}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDialog({ item, client, onClose, onSaved }) {
|
||||
const isNew = !item.id;
|
||||
const isPlan = isNew || item.kind === "plan";
|
||||
const [f, setF] = useState({
|
||||
title: item.title || "", date: item.date, time: item.time || "", status: item.stage && PLAN_STAGES.includes(item.stage) ? item.stage : "idea",
|
||||
format: item.format || "IMAGE", platforms: item.platforms || [], brief: item.brief || "", caption: item.caption || "", assignee: item.assignee || "", pillar: item.pillar || "",
|
||||
});
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const set = (k, v) => setF((s) => ({ ...s, [k]: v }));
|
||||
const run = async (what, fn) => { setBusy(what); setError(null); try { await fn(); onSaved(); } catch (e) { setError(e); setBusy(null); } };
|
||||
|
||||
const save = () => run("save", () => (isPlan
|
||||
? (isNew ? api("/api/plan", { method: "POST", body: { client, ...f } }) : api(`/api/plan/${item.id}`, { method: "PATCH", body: f }))
|
||||
: api("/api/calendar/move", { method: "POST", body: { kind: item.kind, id: item.id, date: f.date } })));
|
||||
const remove = () => run("remove", () => api(`/api/plan/${item.id}`, { method: "DELETE" }));
|
||||
const st = STAGES[item.stage];
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} wide title={isNew ? "Add to the plan" : item.title}
|
||||
footer={<>
|
||||
{isPlan && !isNew && <Button variant="ghost" icon={Trash2} loading={busy === "remove"} onClick={remove} className="mr-auto">Remove</Button>}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
{(isPlan || item.editable) && <Button variant="primary" loading={busy === "save"} onClick={save} disabled={isPlan && !f.title.trim()}>{isNew ? "Add" : "Save"}</Button>}
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
{!isPlan && (
|
||||
<div className="rounded-md bg-canvas border border-line p-3 text-[13px] space-y-1">
|
||||
<div className="flex items-center gap-2"><Badge tone="neutral" dot>{st?.label}</Badge>{item.detail && <span className="text-muted">{item.detail}</span>}</div>
|
||||
{item.caption && <p className="text-ink-2 whitespace-pre-line line-clamp-6">{item.caption}</p>}
|
||||
<p className="text-muted">
|
||||
{item.kind === "library" ? <>Edit the work itself in the <Link className="text-brand-deep underline" to={`/library?client=${client}`}>Content library</Link>.</>
|
||||
: <>Edit or approve it in <Link className="text-brand-deep underline" to="/publishing">Publishing</Link>.</>}
|
||||
{!item.editable && " Approved and published posts can't be moved here."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isPlan && (
|
||||
<Field label="Title">
|
||||
<input id="plan-title" className="input" value={f.title} maxLength={140} onChange={(e) => set("title", e.target.value)} placeholder="e.g. Saturday brunch reel" autoFocus={isNew} />
|
||||
</Field>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Field label="Date">
|
||||
<input id="plan-date" type="date" className="input" value={f.date} disabled={!isPlan && !item.editable} onChange={(e) => set("date", e.target.value)} />
|
||||
</Field>
|
||||
{isPlan && <>
|
||||
<Field label="Time (optional)"><input id="plan-time" type="time" className="input" value={f.time} onChange={(e) => set("time", e.target.value)} /></Field>
|
||||
<Field label="Stage">
|
||||
<select id="plan-status" className="input" value={f.status} onChange={(e) => set("status", e.target.value)}>
|
||||
{PLAN_STAGES.map((s) => <option key={s} value={s}>{STAGES[s].label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Format">
|
||||
<select id="plan-format" className="input" value={f.format} onChange={(e) => set("format", e.target.value)}>
|
||||
{Object.entries(FORMATS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</>}
|
||||
</div>
|
||||
{isPlan && <>
|
||||
<Field label="Platforms">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5 text-[13px]">
|
||||
{Object.entries(PLATFORMS).map(([id, name]) => (
|
||||
<label key={id} htmlFor={`plan-pl-${id}`} className="flex items-center gap-1.5">
|
||||
<input id={`plan-pl-${id}`} type="checkbox" checked={f.platforms.includes(id)} onChange={() => set("platforms", f.platforms.includes(id) ? f.platforms.filter((x) => x !== id) : [...f.platforms, id])} /> {name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Creative brief" hint="What the designer or editor needs: hook, shots, text on screen, length.">
|
||||
<textarea id="plan-brief" className="input h-24 py-2" value={f.brief} onChange={(e) => set("brief", e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Draft caption">
|
||||
<textarea id="plan-caption" className="input h-20 py-2" value={f.caption} onChange={(e) => set("caption", e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Assigned to"><input id="plan-assignee" className="input" value={f.assignee} onChange={(e) => set("assignee", e.target.value)} placeholder="Designer or editor" /></Field>
|
||||
<Field label="Content pillar"><input id="plan-pillar" className="input" value={f.pillar} onChange={(e) => set("pillar", e.target.value)} placeholder="e.g. Behind the scenes" /></Field>
|
||||
</div>
|
||||
</>}
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Calendar() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [clients, setClients] = useState(null);
|
||||
const [view, setView] = useState(params.get("view") === "week" ? "week" : "month");
|
||||
const [anchor, setAnchor] = useState(() => (params.get("date") ? parse(params.get("date")) : new Date()));
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [open, setOpen] = useState(null);
|
||||
const [dropDay, setDropDay] = useState(null);
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const client = params.get("client") || "";
|
||||
|
||||
useEffect(() => { api("/api/overview").then((r) => setClients(r.clients)).catch(setError); }, []);
|
||||
useEffect(() => { if (!client && clients?.length) setParams({ client: clients[0].slug }, { replace: true }); }, [client, clients, setParams]);
|
||||
|
||||
const { start, days } = useMemo(() => range(view, anchor), [view, anchor]);
|
||||
const from = iso(start), to = iso(addDays(start, days - 1));
|
||||
const load = useCallback(() => {
|
||||
if (!client) return Promise.resolve();
|
||||
return api(`/api/calendar?client=${encodeURIComponent(client)}&from=${from}&to=${to}`).then(setData).catch(setError);
|
||||
}, [client, from, to]);
|
||||
useEffect(() => { setData(null); load(); }, [load]);
|
||||
|
||||
const byDay = useMemo(() => {
|
||||
const m = {};
|
||||
for (const i of data?.items || []) (m[i.date] ||= []).push(i);
|
||||
return m;
|
||||
}, [data]);
|
||||
|
||||
const step = (n) => setAnchor((a) => (view === "week" ? addDays(a, 7 * n) : new Date(a.getFullYear(), a.getMonth() + n, 1)));
|
||||
const move = async (payload, date) => {
|
||||
setError(null);
|
||||
try { await api("/api/calendar/move", { method: "POST", body: { ...payload, date } }); await load(); } catch (e) { setError(e); }
|
||||
};
|
||||
const counts = useMemo(() => Object.fromEntries(Object.keys(STAGES).map((s) => [s, (data?.items || []).filter((i) => i.stage === s).length])), [data]);
|
||||
const title = view === "week"
|
||||
? `${start.toLocaleDateString("en-CA", { month: "short", day: "numeric" })} – ${addDays(start, 6).toLocaleDateString("en-CA", { month: "short", day: "numeric", year: "numeric" })}`
|
||||
: anchor.toLocaleDateString("en-CA", { month: "long", year: "numeric" });
|
||||
const month = anchor.getMonth();
|
||||
const today = todayIso();
|
||||
|
||||
if (!clients) return error ? <ErrorNote error={error} /> : <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-extrabold">Content calendar</h1>
|
||||
<p className="text-muted mt-0.5">Plan, production and the publishing queue in one view{data?.timezone ? ` · ${data.timezone.replace("_", " ")} time` : ""}. Drag a card to move it, or open it to change the date.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<select id="cal-client" aria-label="Client" className="input h-9 w-auto" value={client} onChange={(e) => setParams({ client: e.target.value })}>
|
||||
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
||||
</select>
|
||||
<Button variant="primary" icon={Plus} onClick={() => setOpen({ date: today < from || today > to ? from : today })}>Add to plan</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" icon={ChevronLeft} onClick={() => step(-1)} aria-label={view === "week" ? "Previous week" : "Previous month"} />
|
||||
<Button size="sm" onClick={() => setAnchor(new Date())}>Today</Button>
|
||||
<Button size="sm" icon={ChevronRight} onClick={() => step(1)} aria-label={view === "week" ? "Next week" : "Next month"} />
|
||||
<h2 className="text-[16px] font-bold ml-1">{title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted" aria-label="Stages">
|
||||
{Object.entries(STAGES).filter(([s]) => s !== "failed" || counts.failed).map(([s, v]) => (
|
||||
<li key={s} className="flex items-center gap-1.5"><span className="size-2.5 rounded-sm" style={{ background: v.color }} aria-hidden="true" />{v.label}{data ? <span className="num text-faint">{counts[s]}</span> : null}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="inline-flex rounded-md border border-line-strong bg-white p-0.5" role="group" aria-label="View">
|
||||
{["month", "week"].map((v) => (
|
||||
<button key={v} type="button" aria-pressed={view === v} onClick={() => setView(v)}
|
||||
className={cx("h-8 px-3 rounded text-[13px] font-medium capitalize", view === v ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorNote error={error} />
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="grid grid-cols-7 border-b border-line bg-[#fafbfc]">
|
||||
{DAYS.map((d) => <div key={d} className="eyebrow px-2 py-2">{d}</div>)}
|
||||
</div>
|
||||
{!data ? <Spinner /> : (
|
||||
<div className="grid grid-cols-7">
|
||||
{Array.from({ length: days }, (_, n) => {
|
||||
const d = addDays(start, n);
|
||||
const key = iso(d);
|
||||
const items = byDay[key] || [];
|
||||
const limit = view === "week" || expanded[key] ? items.length : 3;
|
||||
const outside = view === "month" && d.getMonth() !== month;
|
||||
return (
|
||||
<div key={key}
|
||||
onDragOver={(e) => { e.preventDefault(); setDropDay(key); }}
|
||||
onDragLeave={() => setDropDay((x) => (x === key ? null : x))}
|
||||
onDrop={(e) => { e.preventDefault(); setDropDay(null); try { move(JSON.parse(e.dataTransfer.getData("text/plain")), key); } catch { /* not ours */ } }}
|
||||
className={cx("group border-b border-r border-line p-1.5 flex flex-col gap-1", view === "week" ? "min-h-[420px]" : "min-h-[124px]",
|
||||
outside && "bg-[#fafbfc]", dropDay === key && "bg-brand-soft ring-2 ring-inset ring-brand/40")}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cx("text-[12px] num font-semibold size-6 grid place-items-center rounded-full", key === today ? "bg-brand-deep text-white" : outside ? "text-faint" : "text-ink-2")}>{d.getDate()}</span>
|
||||
<button type="button" onClick={() => setOpen({ date: key })} aria-label={`Add to the plan on ${d.toLocaleDateString("en-CA", { month: "long", day: "numeric" })}`}
|
||||
className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 p-1 rounded hover:bg-canvas text-muted"><Plus className="size-3.5" /></button>
|
||||
</div>
|
||||
{items.slice(0, limit).map((i) => <Card key={`${i.kind}-${i.id}`} item={i} onOpen={setOpen} compact={view === "month"} />)}
|
||||
{items.length > limit && (
|
||||
<button type="button" className="text-[11.5px] text-brand-deep hover:underline text-left px-1" onClick={() => setExpanded((x) => ({ ...x, [key]: true }))}>+{items.length - limit} more</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && <ItemDialog item={open} client={client} onClose={() => setOpen(null)} onSaved={() => { setOpen(null); load(); }} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user