Add Grizzlyflix newsletters with curated editions and weekly delivery
Magent CI/CD / verify (push) Successful in 10m54s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m14s

This commit is contained in:
2026-09-09 23:42:52 +12:00
parent e014baadc3
commit b0f8c89db7
22 changed files with 2223 additions and 21 deletions
@@ -0,0 +1,34 @@
.newsletter-admin { min-width: 0; }
.newsletter-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.newsletter-tabs button { background: transparent; border: 1px solid var(--ops-line); color: var(--ops-muted); padding: 12px 16px; font-size: 13px; text-transform: none; }
.newsletter-tabs button[aria-pressed=true] { background: #c7bdff14; border-color: #c7bdff60; color: #d5cdff; }
.newsletter-create { display: flex; align-items: flex-end; gap: 12px; flex-shrink: 0; }
.newsletter-create .recap-month-label { margin: 0; }
.newsletter-editions { display: grid; gap: 10px; margin-top: 24px; max-height: 430px; overflow-y: auto; }
.newsletter-edition { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; text-align: left; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: transparent; color: var(--ops-text); text-transform: none; }
.newsletter-edition > span:first-child { min-width: 0; }
.newsletter-edition.is-active { border-color: #c7bdff70; background: #c7bdff09; }
.newsletter-edition strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
.newsletter-edition small { display: block; font-size: 11px; line-height: 1.8; color: var(--ops-muted); margin-top: 7px; }
.newsletter-editor .recap-schedule-form { margin-bottom: 22px; }
.newsletter-admin textarea { border: 1px solid var(--ops-line); border-radius: 8px; padding: 12px; width: 100%; min-width: 0; resize: vertical; font: 13px/1.7 Inter, sans-serif; color: var(--ops-text); }
.newsletter-optional { font-size: 11px; color: var(--ops-faint); }
.newsletter-selection-heading { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.newsletter-selection-heading h3 { margin: 0; }
.newsletter-selection-heading span { color: #bcb3eb; font-size: 12px; }
.newsletter-titles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; max-height: 640px; overflow-y: auto; padding: 1px; }
.newsletter-title { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--ops-line); border-radius: 10px; min-width: 0; background: #ffffff02; }
.newsletter-title.is-selected { border-color: #c7bdff60; background: #c7bdff08; }
.newsletter-poster { position: relative; flex: 0 0 68px; width: 68px; height: 102px; display: grid; place-items: center; overflow: hidden; border-radius: 6px; background: #353039; color: #c7bdff; font-size: 10px; }
.newsletter-poster img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.newsletter-title-copy { min-width: 0; }
.newsletter-title h4 { margin: 0; font-size: 13px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
.newsletter-title p { margin: 6px 0; font-size: 11px; }
.newsletter-title .recap-checkbox { padding: 5px 0; gap: 8px; font-size: 11px; }
.newsletter-title .recap-checkbox input { flex: 0 0 16px; width: 16px; height: 16px; }
.newsletter-editor .recap-preview, .newsletter-send { border-top: 1px solid var(--ops-line); padding-top: 24px; margin-top: 24px; }
.newsletter-send > .recap-month-label { max-width: 350px; }
.newsletter-send input { min-width: 0; width: 100%; min-height: 44px; padding: 10px; border: 1px solid var(--ops-line); border-radius: 8px; font: 13px Inter, sans-serif; }
.newsletter-cancel { margin-top: 24px; color: var(--ops-muted); }
@media (max-width: 1200px) { .newsletter-titles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .newsletter-create { flex-direction: column; align-items: stretch; } }
@media (max-width: 700px) { .newsletter-titles { grid-template-columns: 1fr; } .newsletter-create { width: 100%; } .newsletter-edition { align-items: flex-start; flex-direction: column; gap: 10px; } .newsletter-tabs button { padding: 10px 12px; font-size: 12px; } }
+146
View File
@@ -0,0 +1,146 @@
'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>
}