feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,924 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user