925 lines
40 KiB
TypeScript
925 lines
40 KiB
TypeScript
"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>What’s new in your library</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" value={settings.public_url} readOnly />
|
||
<small>
|
||
Inherited from <Link href="/admin/general">Hosting & 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>
|
||
);
|
||
}
|