Add Grizzlyflix newsletters with curated editions and weekly delivery
This commit is contained in:
@@ -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; } }
|
||||
@@ -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>What’s 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 recipient’s 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>
|
||||
}
|
||||
Reference in New Issue
Block a user