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>
98 lines
5.5 KiB
JavaScript
98 lines
5.5 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { EyeOff, Eye, MessageCircle, RefreshCw, Send } from "lucide-react";
|
|
import { api } from "../api.js";
|
|
import { Badge, Button, cx, Empty, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
|
|
|
|
export default function Inbox() {
|
|
const [clients, setClients] = useState([]);
|
|
const [client, setClient] = useState("");
|
|
const [days, setDays] = useState(14);
|
|
const [items, setItems] = useState(null);
|
|
const [view, setView] = useState("unreplied");
|
|
const [error, setError] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => { api("/api/overview").then((r) => { const l = r.clients.filter((c) => c.linked); setClients(l); setClient((c) => c || l[0]?.slug || ""); }).catch(setError); }, []);
|
|
const load = useCallback(() => {
|
|
if (!client) return;
|
|
setLoading(true); setError(null);
|
|
api(`/api/inbox?client=${client}&days=${days}`).then((r) => setItems(r.comments)).catch((e) => { setError(e); setItems([]); }).finally(() => setLoading(false));
|
|
}, [client, days]);
|
|
useEffect(() => { setItems(null); load(); }, [load]);
|
|
|
|
const shown = useMemo(() => (items || []).filter((c) => view === "all" || (view === "unreplied" ? !c.replied && !c.hidden : c.hidden)), [items, view]);
|
|
const open = (items || []).filter((c) => !c.replied && !c.hidden).length;
|
|
const patch = (id, p) => setItems((xs) => xs.map((x) => (x.id === id ? { ...x, ...p } : x)));
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<header className="flex items-end justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-[22px] font-extrabold">Inbox</h1>
|
|
<p className="text-muted mt-0.5">Comments on Instagram and Facebook posts. Reply within a few hours; it helps reach.</p>
|
|
</div>
|
|
<Button icon={RefreshCw} loading={loading} onClick={load}>Refresh</Button>
|
|
</header>
|
|
<div className="flex gap-2 items-center flex-wrap">
|
|
<select id="inbox-client" className="input h-9 w-auto" value={client} onChange={(e) => setClient(e.target.value)}>
|
|
{clients.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}
|
|
</select>
|
|
<select id="inbox-days" className="input h-9 w-auto" value={days} onChange={(e) => setDays(Number(e.target.value))}>
|
|
{[7, 14, 30].map((d) => <option key={d} value={d}>Last {d} days</option>)}
|
|
</select>
|
|
{[["unreplied", `Needs reply${items ? ` (${open})` : ""}`], ["all", "All"], ["hidden", "Hidden"]].map(([k, l]) => (
|
|
<button key={k} onClick={() => setView(k)} className={cx("h-9 px-3 rounded-md text-[13px] font-medium", view === k ? "bg-brand-soft text-brand-deep" : "text-ink-2 hover:bg-canvas")}>{l}</button>
|
|
))}
|
|
</div>
|
|
<ErrorNote error={error} />
|
|
<Panel>
|
|
{items === null ? <Spinner label="Reading comments…" /> : !shown.length ? (
|
|
<Empty icon={MessageCircle} title={view === "unreplied" ? "All caught up" : "No comments"}>No comments in this view for the selected period.</Empty>
|
|
) : (
|
|
<ul className="divide-y divide-line">{shown.map((c) => <Row key={c.id} c={c} client={client} onChange={(p) => patch(c.id, p)} />)}</ul>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ c, client, onChange }) {
|
|
const [text, setText] = useState("");
|
|
const [busy, setBusy] = useState(null);
|
|
const [error, setError] = useState(null);
|
|
const send = async () => {
|
|
setBusy("reply"); setError(null);
|
|
try { await api("/api/inbox/reply", { method: "POST", body: { client, platform: c.platform, commentId: c.id, message: text } }); onChange({ replied: true, myReply: text }); setText(""); } catch (e) { setError(e); }
|
|
setBusy(null);
|
|
};
|
|
const hide = async () => {
|
|
setBusy("hide"); setError(null);
|
|
try { await api("/api/inbox/hide", { method: "POST", body: { client, platform: c.platform, commentId: c.id, hide: !c.hidden } }); onChange({ hidden: !c.hidden }); } catch (e) { setError(e); }
|
|
setBusy(null);
|
|
};
|
|
return (
|
|
<li className="px-4 py-3.5">
|
|
<div className="flex items-center gap-2 flex-wrap text-[12.5px]">
|
|
<span className="font-semibold text-[13.5px]">{c.platform === "instagram" ? "@" : ""}{c.from}</span>
|
|
<Badge tone={c.platform === "instagram" ? "brand" : "neutral"}>{c.platform}</Badge>
|
|
{c.replied && <Badge tone="ok" dot>replied</Badge>}
|
|
{c.hidden && <Badge tone="warn" dot>hidden</Badge>}
|
|
<span className="text-muted">{timeAgo(c.at)}</span>
|
|
{c.post?.permalink && <a href={c.post.permalink} target="_blank" rel="noreferrer" className="text-brand truncate max-w-[340px]">on “{c.post.caption || "post"}”</a>}
|
|
</div>
|
|
<p className="text-[13.5px] mt-1.5 whitespace-pre-line">{c.text}</p>
|
|
{c.myReply && <p className="text-[13px] text-ink-2 mt-1 pl-3 border-l-2 border-line">You: {c.myReply}</p>}
|
|
{!c.replied && !c.hidden && (
|
|
<div className="flex gap-2 mt-2.5">
|
|
<input className="input flex-1 h-9" placeholder="Write a reply…" value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === "Enter" && text.trim() && send()} />
|
|
<Button variant="primary" size="sm" icon={Send} loading={busy === "reply"} disabled={!text.trim()} onClick={send}>Reply</Button>
|
|
</div>
|
|
)}
|
|
<div className="mt-2 flex gap-2">
|
|
<Button variant="ghost" size="sm" icon={c.hidden ? Eye : EyeOff} loading={busy === "hide"} onClick={hide}>{c.hidden ? "Unhide" : "Hide (spam)"}</Button>
|
|
</div>
|
|
<ErrorNote error={error} className="mt-2" />
|
|
</li>
|
|
);
|
|
}
|