Files
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

925 lines
40 KiB
TypeScript
Raw Permalink 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 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<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,
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(() => {
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 <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" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Watch links use the public
playback URL in <Link href="/admin/jellyfin">Jellyfin settings</Link>.
</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>
);
}