'use client' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' 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, public_url: settings.public_url, 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(() => { 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}