"use client"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import AdminShell from "../../ui/AdminShell"; import { authFetch, getApiBase } from "../../lib/auth"; import "../../email-recaps/recaps.css"; import "./newsletters.css"; type Settings = { enabled: boolean; weekday: number; hour: number; limit_titles: number; public_url: string; intro: string; revision: number; next_send_at?: number | null; last_error?: string; }; type Title = { id: string; title: string; type: "movie" | "series"; year: number | null; has_artwork: boolean; items: { id: string; season: number | null; number: number | null }[]; selected: boolean; featured: boolean; }; type Edition = { id: string; subject: string; intro: string; revision: number; state: string; origin: string; send_at: number | null; created_at: number; content: { titles: Title[]; total_titles: number; period_start: string; period_end: string }; }; type Summary = Omit & { titles: number; period_start: string; period_end: string }; type Delivery = { id: string; subject: string; kind: string; email: string; username: string | null; state: string; attempts: number; updated_at: number; next_attempt_at: number; detail: string; }; type Overview = { settings: Settings; ready: boolean; detail: string; editions: Summary[]; deliveries: Delivery[]; total: number; subscribers: number; }; type Preview = { id: string; revision: number; subject: string; body_html: string; body_text: string }; const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; const dateLabel = (value?: number | null) => value ? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC` : "Not scheduled"; const labels: Record = { draft: "Draft", scheduled: "Scheduled", queued: "Queued", complete: "Finished", skipped: "Skipped", preparing: "Preparing email", sending: "Sending", sent: "Accepted by mail server", retry: "Retry scheduled", failed: "Failed", unknown: "Needs review", cancelled: "Cancelled", }; const editableFields = (edition: Edition) => ({ subject: edition.subject, intro: edition.intro, titles: edition.content.titles.map(({ id, selected, featured }) => ({ id, selected, featured })), }); const scheduleFields = (settings: Settings) => ({ enabled: settings.enabled, weekday: settings.weekday, hour: settings.hour, limit_titles: settings.limit_titles, intro: settings.intro, revision: settings.revision, }); function Poster({ title }: { title: Title }) { const [failed, setFailed] = useState(false); return (
{title.has_artwork && !failed && ( setFailed(true)} /> )}
); } export default function NewslettersAdminPage() { const router = useRouter(); const [data, setData] = useState(null); const [settings, setSettings] = useState(null); const [tab, setTab] = useState<"editions" | "schedule" | "history">("editions"); const [edition, setEdition] = useState(null); const [saved, setSaved] = useState(null); const [preview, setPreview] = useState(null); const [mode, setMode] = useState<"html" | "text">("html"); const [days, setDays] = useState(7); const [sendAt, setSendAt] = useState(""); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(""); const [offset, setOffset] = useState(0); const [refresh, setRefresh] = useState(0); const testRequest = useRef<{ key: string; id: string } | null>(null); const initialized = useRef(false); const actionController = useRef(null); const parse = useCallback( async (response: Response) => { if (response.status === 401) { router.replace("/login?next=%2Fadmin%2Fnewsletters"); throw new Error("Sign in to continue."); } if (response.status === 403) { router.replace("/"); throw new Error("Administrator access is required."); } const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error( typeof result.detail === "string" ? result.detail : "Could not complete this action. Please try again.", ); return result; }, [router], ); useEffect(() => { void refresh; const abort = new AbortController(); void authFetch(`${getApiBase()}/admin/newsletters?offset=${offset}`, { signal: abort.signal }) .then(parse) .then((result: Overview) => { if (abort.signal.aborted) return; setData(result); if (!initialized.current) { setSettings(result.settings); initialized.current = true; } }) .catch((err: Error) => { if (!abort.signal.aborted) setError(err.message); }); return () => abort.abort(); }, [offset, refresh, parse]); useEffect(() => { if ( !data?.editions.some((row) => ["scheduled", "queued"].includes(row.state)) && !data?.deliveries.some((row) => ["queued", "preparing", "sending", "retry"].includes(row.state)) ) return; const timer = window.setInterval(() => setRefresh((value) => value + 1), 10000); return () => window.clearInterval(timer); }, [data]); useEffect(() => () => actionController.current?.abort(), []); const dirty = !!edition && !!saved && JSON.stringify(editableFields(edition)) !== JSON.stringify(editableFields(saved)); const settingsDirty = !!settings && !!data && JSON.stringify(scheduleFields(settings)) !== JSON.stringify(scheduleFields(data.settings)); const selected = edition?.content.titles.filter((title) => title.selected) || []; const featured = selected.filter((title) => title.featured).length; const isDraft = edition?.state === "draft"; const validPreview = !!edition && !!preview && preview.id === edition.id && preview.revision === edition.revision && !dirty; const hasContent = selected.length > 0 || !!edition?.intro.trim(); const remember = (row: Edition) => { setEdition(row); setSaved(row); setPreview(null); setSendAt(""); testRequest.current = null; }; const action = async ( name: string, path: string, method: string, payload: unknown, done: (result: T) => void, ) => { if (busy) return; const abort = new AbortController(); actionController.current = abort; setBusy(name); setError(""); setNotice(""); try { const result = await parse( await authFetch(`${getApiBase()}/admin/newsletters${path}`, { method, signal: abort.signal, headers: { "Content-Type": "application/json" }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), }), ); if (!abort.signal.aborted) { done(result as T); setRefresh((value) => value + 1); } } catch (err) { if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not complete this action."); } finally { if (!abort.signal.aborted) setBusy(""); } }; const changeTitle = (id: string, field: "selected" | "featured", value: boolean) => { if (!edition) return; setEdition({ ...edition, content: { ...edition.content, titles: edition.content.titles.map((title) => title.id !== id ? title : { ...title, [field]: value, ...(field === "selected" && !value ? { featured: false } : {}) }, ), }, }); setPreview(null); }; const saveDraft = (event: FormEvent) => { event.preventDefault(); if (edition) void action( "save", `/editions/${edition.id}`, "PUT", { revision: edition.revision, ...editableFields(edition) }, (row: Edition) => { remember(row); setNotice("Draft saved. Preview this version before sending."); }, ); }; const publish = (scheduled: boolean) => { if (!edition || !validPreview || !hasContent) return; void action( "publish", `/editions/${edition.id}/publish`, "POST", { revision: edition.revision, send_at: scheduled ? `${sendAt}:00Z` : null }, (row: Edition) => { remember(row); setNotice(`Edition scheduled for ${dateLabel(row.send_at)}. The saved content is now fixed.`); }, ); }; const sendTest = () => { if (!edition || !validPreview) return; const key = `${edition.id}:${edition.revision}`; if (testRequest.current?.key !== key) testRequest.current = { key, id: crypto.randomUUID() }; void action( "test", `/editions/${edition.id}/test`, "POST", { revision: edition.revision, request_id: testRequest.current.id }, (result: { message: string }) => { setNotice(result.message); testRequest.current = null; setOffset(0); }, ); }; return ( Email settings ↗ } >
{error && (

{error}

)} {notice && (

{notice}

)} {!data && !error &&

Loading newsletters…

} {!data && error && ( )} {data && settings && ( <>
{data.settings.enabled ? "Weekly sending is on" : "Weekly sending is paused"}

{data.settings.enabled ? `Next edition ${dateLabel(data.settings.next_send_at)}` : "Create a one-off edition or set a weekly rhythm."}

{data.subscribers} confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}
{!data.ready && (

{data.detail}{" "} Email settings ↗ {" "} ·{" "} Jellyfin settings ↗

)} {tab === "editions" && ( <>
A fresh edition

What’s new on Grizzlyflix

Collect arrivals from Jellyfin, choose your picks and add a note to your community.

{data.editions.length ? (
{data.editions.map((row) => ( ))}
) : (

Something good to watch

Your first edition starts with the latest additions to your library. Collect a draft to begin.

)} {dirty && (

Save or discard the current changes before opening another edition.

)}
{edition && (
{isDraft ? "Make it yours" : "Saved edition"}

{isDraft ? "Edit your newsletter" : edition.subject}

Arrivals from {edition.content.period_start.slice(0, 10)} to{" "} {edition.content.period_end.slice(0, 10)} (UTC). TV additions are grouped by show.

{labels[edition.state] || edition.state}