quorlasocial/dashboard/web/src/pages/Screenshots.jsx
metatroncubeswdev df3dbb63c4 Visual reports and client Screenshots tab
- Reports: mc: blocks (score, KPIs, reach, funnel, followers, formats,
  rhythm, top posts, actions, screenshots, callouts) drawn as SVG from
  stored account data, frozen per report; same page in PDF and preview
- Audit/weekly-report runs refresh account numbers first
- Screenshots tab: monthly shot list, uploads, examples; audit reads them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:48:32 -04:00

143 lines
7.6 KiB
JavaScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Copy, ImageUp, Star, Trash2 } from "lucide-react";
import { api } from "../api.js";
import { useAuth } from "../App.jsx";
import { Badge, Button, Dialog, ErrorNote, Panel, Spinner, timeAgo } from "../ui.jsx";
const monthLabel = (m) => new Date(`${m}-15`).toLocaleDateString("en-CA", { month: "long", year: "numeric" });
/** Client page tab: the monthly screenshot checklist the team fills and the audit reads. */
export default function Screenshots({ slug, clientName }) {
const { isAdmin } = useAuth();
const [month, setMonth] = useState(null);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const [view, setView] = useState(null); // { title, src }
const [copied, setCopied] = useState(false);
const load = useCallback(() => api(`/api/clients/${slug}/screenshots${month ? `?month=${month}` : ""}`).then((d) => { setData(d); setError(null); }).catch(setError), [slug, month]);
useEffect(() => { load(); }, [load]);
const act = async (id, fn) => {
setBusy(id); setError(null);
try { setData(await fn()); } catch (e) { setError(e); }
setBusy(null);
};
const q = `?month=${data?.month}`;
const upload = (shot, file) => act(shot.id, async () => {
const res = await fetch(`/api/clients/${slug}/screenshots/${shot.id}${q}`, { method: "POST", credentials: "same-origin", headers: { "X-Filename": encodeURIComponent(file.name), "Content-Type": "application/octet-stream" }, body: file });
const out = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(out.error || `Upload failed (${res.status})`);
return out;
});
const copyChecklist = async () => {
const lines = data.shots.map((s, i) => `${i + 1}. ${s.platform}, ${s.label}${s.required ? "" : " (if it exists)"}: ${s.how}`);
await navigator.clipboard.writeText(`Screenshots for ${clientName}, ${monthLabel(data.month)}\nUpload them in Quorla → ${clientName} → Screenshots.\n\n${lines.join("\n")}`);
setCopied(true); setTimeout(() => setCopied(false), 2000);
};
if (!data) return error ? <ErrorNote error={error} /> : <Spinner />;
const required = data.shots.filter((s) => s.required);
const done = required.filter((s) => s.file).length;
return (
<div className="space-y-4">
<Panel
title="Monthly screenshots"
subtitle="Take these in a computer browser before the monthly audit (Windows: Win+Shift+S). The audit reads them for what the numbers can't show: bio, highlights, how the feed looks, buttons and reviews."
action={
<div className="flex items-center gap-2">
<label htmlFor="ss-month" className="sr-only">Month</label>
<select id="ss-month" className="input h-8 w-auto" value={data.month} onChange={(e) => setMonth(e.target.value)}>
{data.months.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
</select>
<Button size="sm" icon={Copy} onClick={copyChecklist}>{copied ? "Copied" : "Copy checklist"}</Button>
</div>
}
>
<p className="text-[13px] text-ink-2">
<span className="num font-semibold text-ink">{done} of {required.length}</span> required screenshots uploaded for {monthLabel(data.month)}.
{done === required.length && " Ready for the audit."}
</p>
</Panel>
<ErrorNote error={error} />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.shots.map((s) => (
<ShotCard key={s.id} shot={s} slug={slug} q={q} busy={busy === s.id} isAdmin={isAdmin}
onUpload={(f) => upload(s, f)}
onRemove={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}${q}`, { method: "DELETE" }))}
onExample={() => act(s.id, () => api(`/api/clients/${slug}/screenshots/${s.id}/example${q}`, { method: "POST" }))}
onView={setView} />
))}
</div>
<Dialog open={!!view} onClose={() => setView(null)} title={view?.title} wide>
{view && <img src={view.src} alt={view.title} className="mx-auto max-h-[70vh] w-auto rounded-lg border border-line" />}
</Dialog>
</div>
);
}
function ShotCard({ shot, slug, q, busy, isAdmin, onUpload, onRemove, onExample, onView }) {
const input = useRef(null);
const [drag, setDrag] = useState(false);
const title = `${shot.platform} · ${shot.label}`;
const stamp = shot.file?.at ? `&v=${encodeURIComponent(shot.file.at)}` : "";
const src = `/api/clients/${slug}/screenshots/${shot.id}/image${q}${stamp}`;
const exampleSrc = `/api/screenshot-examples/${shot.id}`;
const pick = (files) => files?.[0] && onUpload(files[0]);
return (
<section className="rounded-xl border border-line bg-white flex flex-col">
<header className="px-4 pt-3.5 pb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11.5px] font-medium text-muted uppercase tracking-wide">{shot.platform}</p>
<h3 className="text-[14px] font-semibold text-ink">{shot.label}</h3>
</div>
{shot.file ? <Badge tone="ok" dot>Uploaded</Badge> : shot.required ? <Badge tone="warn">Required</Badge> : <Badge>Optional</Badge>}
</header>
<p className="px-4 text-[12.5px] text-ink-2 leading-relaxed">{shot.how}</p>
<div className="p-4 mt-auto">
{shot.file ? (
<button type="button" onClick={() => onView({ title, src })} className="block w-full h-56 rounded-lg border border-line bg-canvas overflow-hidden">
<img src={src} alt={`${title} screenshot`} className="h-full w-full object-contain" loading="lazy" />
</button>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
onDragLeave={() => setDrag(false)}
onDrop={(e) => { e.preventDefault(); setDrag(false); pick(e.dataTransfer.files); }}
className={`h-56 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-center px-3 ${drag ? "border-brand bg-brand/5" : "border-line bg-canvas"}`}
>
{busy ? <Spinner label="Uploading" /> : (
<>
<ImageUp className="size-6 text-faint" aria-hidden="true" />
<p className="text-[12.5px] text-muted">Drop the screenshot here</p>
<Button size="sm" onClick={() => input.current?.click()}>Choose file</Button>
</>
)}
</div>
)}
<input ref={input} type="file" accept="image/png,image/jpeg,image/webp" className="sr-only" aria-label={`Upload ${title}`} onChange={(e) => { pick(e.target.files); e.target.value = ""; }} />
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted">
{shot.file && <span>{shot.file.by ? `${shot.file.by}, ` : ""}{timeAgo(shot.file.at)}</span>}
{shot.example && <button type="button" className="underline hover:text-ink" onClick={() => onView({ title: `Example: ${title}`, src: exampleSrc })}>See example</button>}
{shot.file && (
<span className="ml-auto flex items-center gap-1">
<Button size="sm" loading={busy} onClick={() => input.current?.click()}>Replace</Button>
{isAdmin && <Button size="sm" icon={Star} title="Show this shot as the example for every client" onClick={onExample}>Use as example</Button>}
<button type="button" onClick={onRemove} className="p-1.5 rounded hover:bg-bad-soft text-muted hover:text-bad" aria-label={`Remove ${title}`}><Trash2 className="size-4" /></button>
</span>
)}
</div>
</div>
</section>
);
}