Files
Magent/frontend/app/admin/newsletters/page.tsx
T
Assclaw b0f8c89db7
Magent CI/CD / verify (push) Successful in 10m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m14s
Add Grizzlyflix newsletters with curated editions and weekly delivery
2026-09-09 23:42:52 +12:00

147 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Edition, 'content'> & { 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<string, string> = { 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 <div className="newsletter-poster"><span aria-hidden="true">{title.type === 'series' ? 'TV' : 'MOVIE'}</span>{title.has_artwork && !failed && <img src={`${getApiBase()}/admin/newsletters/artwork/${title.id}`} alt="" loading="lazy" onError={() => setFailed(true)} />}</div>
}
export default function NewslettersAdminPage() {
const router = useRouter()
const [data, setData] = useState<Overview | null>(null)
const [settings, setSettings] = useState<Settings | null>(null)
const [tab, setTab] = useState<'editions' | 'schedule' | 'history'>('editions')
const [edition, setEdition] = useState<Edition | null>(null)
const [saved, setSaved] = useState<Edition | null>(null)
const [preview, setPreview] = useState<Preview | null>(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<AbortController | null>(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 <T,>(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 <AdminShell title="Newsletters" subtitle="New arrivals, fresh episodes and a little inspiration for the next watch." actions={<a className="ghost-button" href="/admin/notifications">Email settings </a>}>
<div className="recap-admin newsletter-admin">
{error && <p className="error-banner" role="alert">{error}</p>}
{notice && <p className="status-banner" role="status">{notice}</p>}
{!data && !error && <p role="status">Loading newsletters</p>}
{!data && error && <button type="button" className="ghost-button" onClick={() => { setError(''); setRefresh((value) => value + 1) }}>Try again</button>}
{data && settings && <>
<div className="recap-overview-strip"><div><span className={`recap-pill ${data.settings.enabled ? 'is-enabled' : ''}`}>{data.settings.enabled ? 'Weekly sending is on' : 'Weekly sending is paused'}</span><p>{data.settings.enabled ? `Next edition ${dateLabel(data.settings.next_send_at)}` : 'Create a one-off edition or set a weekly rhythm.'}</p></div><div className="recap-subscriber-count"><strong>{data.subscribers}</strong><span>confirmed {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</span></div></div>
<nav className="newsletter-tabs" aria-label="Newsletter sections">{(['editions', 'schedule', 'history'] as const).map((value) => <button type="button" key={value} aria-pressed={tab === value} onClick={() => setTab(value)}>{({ editions: 'Editions', schedule: 'Weekly schedule', history: 'Delivery history' })[value]}</button>)}</nav>
{!data.ready && <p className="recap-setup-note">{data.detail} <a className="recap-text-link" href="/admin/notifications">Email settings </a> · <a className="recap-text-link" href="/admin/jellyfin">Jellyfin settings </a></p>}
{tab === 'editions' && <>
<section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">A fresh edition</span><h2>Whats new on Grizzlyflix</h2><p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p></div><div className="newsletter-create"><label className="recap-month-label" htmlFor="arrival-period">Arrival period<select id="arrival-period" value={days} disabled={!!busy || dirty} onChange={(event) => setDays(Number(event.target.value))}>{[7, 14, 30].map((value) => <option key={value} value={value}>Last {value} days</option>)}</select></label><button type="button" className="account-primary" disabled={!!busy || dirty} onClick={() => void action('create', '/drafts', 'POST', { days }, (row: Edition) => { remember(row); setNotice('Arrivals collected. Choose the titles you want to include.') })}>{busy === 'create' ? 'Collecting arrivals…' : 'Create draft'}</button></div></div>
{data.editions.length ? <div className="newsletter-editions">{data.editions.map((row) => <button type="button" className={`newsletter-edition ${edition?.id === row.id ? 'is-active' : ''}`} key={row.id} disabled={!!busy || dirty} onClick={() => void action('open', `/editions/${row.id}`, 'GET', undefined, remember)}><span><strong>{row.subject}</strong><small>{row.titles} {row.titles === 1 ? 'title' : 'titles'} · {row.origin === 'weekly' ? 'Weekly edition' : 'Custom edition'} · {dateLabel(row.send_at || row.created_at)}</small></span><span className="recap-pill">{labels[row.state] || row.state}</span></button>)}</div> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Something good to watch</h3><p>Your first edition starts with the latest additions to your library. Collect a draft to begin.</p></div>}
{dirty && <p className="recap-muted">Save or discard the current changes before opening another edition.</p>}
</section>
{edition && <section className="admin-panel recap-panel newsletter-editor"><div className="recap-section-heading"><div><span className="recap-eyebrow">{isDraft ? 'Make it yours' : 'Saved edition'}</span><h2>{isDraft ? 'Edit your newsletter' : edition.subject}</h2><p>Arrivals from {edition.content.period_start.slice(0, 10)} to {edition.content.period_end.slice(0, 10)} (UTC). TV additions are grouped by show.</p></div><span className="recap-pill">{labels[edition.state] || edition.state}</span></div>
<form className="recap-schedule-form" onSubmit={saveDraft}><label htmlFor="newsletter-subject">Email subject<input id="newsletter-subject" maxLength={150} required value={edition.subject} disabled={!!busy || !isDraft} onChange={(event) => { setEdition({ ...edition, subject: event.target.value }); setPreview(null) }} /></label><label htmlFor="newsletter-intro">Announcement <span className="newsletter-optional">Optional</span><textarea id="newsletter-intro" rows={4} maxLength={2000} placeholder="A welcome, a weekend recommendation, or a quick update…" disabled={!!busy || !isDraft} value={edition.intro} onChange={(event) => { setEdition({ ...edition, intro: event.target.value }); setPreview(null) }} /><small>Plain text, shared with every subscriber receiving this edition.</small></label>
<div className="newsletter-selection-heading"><h3>Choose the lineup</h3><span>{selected.length}/24 included · {featured}/3 featured</span></div>
{edition.content.total_titles > edition.content.titles.length && <p className="recap-muted">Showing the {edition.content.titles.length} newest titles of {edition.content.total_titles} found in this period.</p>}
{edition.content.titles.length ? <div className="newsletter-titles">{edition.content.titles.map((title) => <article className={`newsletter-title ${title.selected ? 'is-selected' : ''}`} key={title.id}><Poster title={title} /><div className="newsletter-title-copy"><h4>{title.title}</h4><p>{title.type === 'movie' ? `Movie${title.year ? ` · ${title.year}` : ''}` : `${title.items.length} new ${title.items.length === 1 ? 'episode' : 'episodes'}`}</p><label className="recap-checkbox"><input type="checkbox" aria-label={`Include ${title.title}`} checked={title.selected} disabled={!!busy || !isDraft || (!title.selected && selected.length >= 24)} onChange={(event) => changeTitle(title.id, 'selected', event.target.checked)} /><span>Include</span></label><label className="recap-checkbox"><input type="checkbox" aria-label={`Feature ${title.title}`} checked={title.featured} disabled={!!busy || !isDraft || !title.selected || (!title.featured && featured >= 3)} onChange={(event) => changeTitle(title.id, 'featured', event.target.checked)} /><span>Featured pick</span></label></div></article>)}</div> : <p>No new titles were found in this period. You can still create an announcement edition.</p>}
<div className="recap-actions">{isDraft && <button type="submit" className="account-primary" disabled={!!busy || !dirty}>{busy === 'save' ? 'Saving…' : 'Save draft'}</button>}<button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('reload', `/editions/${edition.id}`, 'GET', undefined, remember)}>{dirty ? 'Discard changes' : 'Reload edition'}</button><button type="button" className="account-secondary" disabled={!!busy || dirty || settingsDirty || edition.state === 'cancelled'} onClick={() => void action('preview', `/editions/${edition.id}/preview`, 'POST', { revision: edition.revision }, (result: Preview) => { setPreview(result); setMode('html') })}>{busy === 'preview' ? 'Preparing preview…' : 'Preview edition'}</button></div>
</form>
<p className="recap-muted">Each recipients email includes only titles available to their linked Jellyfin account. Posters are included in the email.</p>
{dirty && <p className="recap-muted">Save the draft to preview and send this version.</p>}
{settingsDirty && <p className="recap-muted">Save or reload your weekly settings before previewing or sending.</p>}
{validPreview && preview && <div className="recap-preview"><div className="recap-section-heading"><div><span className="recap-eyebrow">Email preview</span><h3>{preview.subject}</h3><p>This shows the full selection. Your test uses your own library access.</p></div><div className="recap-mode-buttons"><button type="button" aria-pressed={mode === 'html'} onClick={() => setMode('html')}>Email design</button><button type="button" aria-pressed={mode === 'text'} onClick={() => setMode('text')}>Plain text</button></div></div>{mode === 'html' ? <iframe title="Newsletter email preview" sandbox="" referrerPolicy="no-referrer" srcDoc={preview.body_html} /> : <pre className="recap-plain-preview">{preview.body_text}</pre>}</div>}
{edition.state !== 'cancelled' && <div className="newsletter-send"><h3>{isDraft ? 'Ready for the inbox?' : 'Delivery controls'}</h3><p>A test goes to your own confirmed newsletter email. <a href="/profile#newsletters">Manage your subscription </a></p><div className="recap-actions"><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty} onClick={sendTest}>{busy === 'test' ? 'Queuing test…' : 'Send newsletter test to me'}</button></div>{isDraft ? <><label className="recap-month-label" htmlFor="newsletter-send-time">Schedule for (UTC)<input id="newsletter-send-time" type="datetime-local" value={sendAt} disabled={!!busy} onChange={(event) => setSendAt(event.target.value)} /></label><div className="recap-actions"><button type="button" className="account-primary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !sendAt} onClick={() => publish(true)}>Schedule edition</button><button type="button" className="account-secondary" disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !data.subscribers} onClick={() => publish(false)}>Send now to {data.subscribers} {data.subscribers === 1 ? 'subscriber' : 'subscribers'}</button></div><p className="recap-muted">Preview the saved edition before sending. Scheduling fixes the content for this edition. Users must be subscribed by its send time.</p></> : <p>{dateLabel(edition.send_at)} · Check Delivery history for individual results.</p>}{['draft', 'scheduled', 'queued'].includes(edition.state) && <button type="button" className="ghost-button newsletter-cancel" disabled={!!busy || dirty} onClick={() => void action('cancel', `/editions/${edition.id}/cancel`, 'POST', {}, (row: Edition) => { remember(row); setNotice('Edition cancelled. Pending emails have been stopped.') })}>Cancel edition</button>}</div>}
</section>}
</>}
{tab === 'schedule' && <section className="admin-panel recap-panel"><span className="recap-eyebrow">Set the rhythm</span><h2>A weekly discovery</h2><p>Automatically collect the previous seven days of arrivals and send an edition to confirmed subscribers. Weeks without new arrivals are skipped.</p><form className="recap-schedule-form" onSubmit={(event) => { event.preventDefault(); void action('settings', '', 'PUT', scheduleFields(settings), (result: Settings) => { setSettings(result); setData({ ...data, settings: result }); setPreview(null); setNotice(result.enabled ? `Weekly settings saved. Next edition ${dateLabel(result.next_send_at)}.` : 'Settings saved. Automatic weekly editions are paused.') }) }}><label htmlFor="newsletter-public-url">Public Magent address<input id="newsletter-public-url" type="url" maxLength={500} placeholder="https://magent.example.com" required={settings.enabled} value={settings.public_url} disabled={!!busy} onChange={(event) => setSettings({ ...settings, public_url: event.target.value })} /><small>Used for confirmation links and email preferences in this environment.</small></label><div className="recap-schedule-fields"><label htmlFor="newsletter-weekday">Send day<select id="newsletter-weekday" value={settings.weekday} disabled={!!busy} onChange={(event) => setSettings({ ...settings, weekday: Number(event.target.value) })}>{daysOfWeek.map((day, index) => <option value={index} key={day}>{day}</option>)}</select></label><label htmlFor="newsletter-hour">Send time (UTC)<select id="newsletter-hour" value={settings.hour} disabled={!!busy} onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, hour) => <option key={hour} value={hour}>{String(hour).padStart(2, '0')}:00 UTC</option>)}</select></label></div><label htmlFor="newsletter-limit">Titles per weekly edition<select id="newsletter-limit" value={settings.limit_titles} disabled={!!busy} onChange={(event) => setSettings({ ...settings, limit_titles: Number(event.target.value) })}>{Array.from({ length: 24 }, (_, index) => <option key={index + 1} value={index + 1}>{index + 1}</option>)}</select><small>Newest titles first. Multiple episodes count as one show.</small></label><label htmlFor="newsletter-default-intro">Default announcement<textarea id="newsletter-default-intro" rows={4} maxLength={2000} value={settings.intro} disabled={!!busy} onChange={(event) => setSettings({ ...settings, intro: event.target.value })} /><small>Appears in future weekly editions and newly created drafts.</small></label><label className="recap-checkbox"><input type="checkbox" checked={settings.enabled} disabled={!!busy} onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })} /><span>Enable automatic weekly newsletters</span></label><p className="recap-muted">The schedule starts at the next future send time, in UTC. Pausing stops pending automatic editions. Custom editions keep their individual schedules.</p><div className="recap-actions"><button type="submit" className="account-primary" disabled={!!busy || !settingsDirty}>{busy === 'settings' ? 'Saving…' : 'Save weekly settings'}</button><button type="button" className="account-secondary" disabled={!!busy} onClick={() => void action('settings-reload', '', 'GET', undefined, (result: Overview) => { setData(result); setSettings(result.settings); setPreview(null) })}>Reload settings</button></div></form>{data.settings.last_error && <p className="recap-setup-note">{data.settings.last_error}</p>}</section>}
{tab === 'history' && <section className="admin-panel recap-panel"><div className="recap-section-heading"><div><span className="recap-eyebrow">From queue to inbox</span><h2>Delivery history</h2><p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p></div><button type="button" className="ghost-button" disabled={!!busy} onClick={() => setRefresh((value) => value + 1)}>Refresh history</button></div>{data.deliveries.length ? <><div className="recap-history-scroll"><table className="recap-history"><thead><tr><th scope="col">Recipient</th><th scope="col">Edition</th><th scope="col">Delivery</th><th scope="col">Updated</th></tr></thead><tbody>{data.deliveries.map((row) => <tr key={row.id}><td><strong>{row.username || 'Removed account'}</strong><small>{row.email}</small></td><td>{row.subject}<small>{row.kind === 'test' ? 'Test email' : 'Newsletter'}</small></td><td><span className={`recap-pill ${row.state === 'sent' ? 'is-enabled' : ['failed', 'unknown'].includes(row.state) ? 'is-attention' : ''}`}>{labels[row.state] || row.state}</span><small>{row.attempts} {row.attempts === 1 ? 'attempt' : 'attempts'} · {row.detail || 'Waiting for the next worker check.'}</small>{row.state === 'retry' && <small>Next attempt {dateLabel(row.next_attempt_at)}</small>}{row.state === 'unknown' && <small>Automatic retries are stopped to avoid a duplicate email.</small>}</td><td>{dateLabel(row.updated_at)}</td></tr>)}</tbody></table></div><div className="recap-pagination"><span>{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}</span><div className="recap-actions"><button type="button" className="ghost-button" disabled={!offset} onClick={() => setOffset(Math.max(0, offset - 50))}>Previous</button><button type="button" className="ghost-button" disabled={offset + 50 >= data.total} onClick={() => setOffset(offset + 50)}>Next</button></div></div></> : <div className="recap-empty"><span aria-hidden="true"></span><h3>Your first edition starts here</h3><p>Preview a draft and send yourself a test. Delivery results will appear here.</p></div>}</section>}
</>}
</div>
</AdminShell>
}