Refine admin speech management
Some checks failed
/ deploy (push) Has been cancelled

This commit is contained in:
Loyyd 2026-06-16 16:24:53 +02:00
parent 13539d6300
commit d14a81e523

View file

@ -17,6 +17,32 @@ const latestEntryDate = latestEntry?.data.published
: "No date";
const totalEvents = calendarEvents.length + recurringCalendarEvents.length;
const configuredServices = [contactFormEndpoint, plausibleDomain].filter(Boolean).length;
const speechAdminEntries = speechEntries.map((entry) => {
const categories = entry.data.categories ?? [];
const tags = entry.data.tags ?? [];
const publishedDate = entry.data.published?.slice(0, 10) ?? today;
const speechYear = entry.data.speechYear ?? entry.id.match(/\b(1\d{3}|20\d{2})\b/)?.[1] ?? new Date().getFullYear().toString();
const categoryText = categories.join(" ").toLowerCase();
const collection = entry.data.speechCollection ?? "";
const speaker = categoryText.includes("hak ja han") || collection === "mrs-hak-ja-han-moon"
? "mrs"
: categoryText.includes("rev sun myung moon") || /^\d{4}$/.test(collection) || /^\d{4}$/.test(speechYear)
? "rev"
: "other";
return {
id: entry.id,
path: `content/${entry.id}.md`,
title: entry.data.title ?? entry.id,
publishedDate,
speechYear,
speaker,
source: entry.data.source ?? "",
categories,
tags,
body: entry.body ?? "",
};
});
const adminConfig = {
analyticsDashboardUrl,
contactEndpointConfigured: Boolean(contactFormEndpoint),
@ -390,7 +416,7 @@ const adminConfig = {
.admin-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
}
@ -452,6 +478,42 @@ const adminConfig = {
padding: 18px;
}
.admin-speech-list {
display: grid;
gap: 8px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.admin-speech-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 7px;
background: var(--admin-panel-alt);
}
.admin-speech-row-title {
display: block;
overflow: hidden;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-speech-row-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 3px;
color: var(--admin-muted);
font-size: 12px;
}
.admin-form {
display: grid;
gap: 14px;
@ -684,11 +746,6 @@ const adminConfig = {
<h1>FamilyFed.ie Admin</h1>
</div>
</div>
<nav class="admin-actions" aria-label="Admin links">
<a class="admin-link-button" href="/index.html">Public Site</a>
<a class="admin-link-button" href="/events.html">Calendar</a>
<a class="admin-link-button" href="/speeches/">Speeches</a>
</nav>
</div>
</header>
@ -741,54 +798,6 @@ const adminConfig = {
</div>
</section>
<div class="admin-overview-grid">
<section class="admin-panel" aria-labelledby="overview-actions-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Work queue</p>
<h2 id="overview-actions-title">Content Tools</h2>
</div>
</div>
<div class="admin-panel-body">
<div class="admin-mini-actions">
<button class="admin-button admin-button-primary" type="button" data-admin-tab-link="calendar">Add Calendar Event</button>
<button class="admin-button" type="button" data-admin-tab-link="speeches">Add Speech</button>
<a class="admin-link-button" href="/speeches/">View Public Speeches</a>
</div>
</div>
</section>
<section class="admin-panel" aria-labelledby="overview-status-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Readiness</p>
<h2 id="overview-status-title">Operations</h2>
</div>
</div>
<div class="admin-panel-body">
<ul class="admin-status-list">
<li>
<strong>Contact form</strong>
<span class={contactFormEndpoint ? "admin-ok" : "admin-missing"}>
{contactFormEndpoint ? "Endpoint configured" : "Needs PUBLIC_CONTACT_FORM_ENDPOINT"}
</span>
</li>
<li>
<strong>Visitor analytics</strong>
<span class={plausibleDomain ? "admin-ok" : "admin-missing"}>
{plausibleDomain ? `Tracking ${plausibleDomain}` : "Needs PUBLIC_PLAUSIBLE_DOMAIN"}
</span>
</li>
</ul>
</div>
</section>
<div class="admin-utility-card">
<strong>Static admin note</strong>
<p>The dashboard can generate clean content files, but it cannot write to the repository from the browser.</p>
<button class="admin-button" type="button" data-admin-tab-link="settings">Open Settings</button>
</div>
</div>
</section>
<section class="admin-tab-panel" id="admin-panel-calendar" role="tabpanel" aria-labelledby="admin-tab-calendar" data-admin-panel="calendar" hidden>
@ -799,7 +808,7 @@ const adminConfig = {
<div class="admin-panel-title">
<p class="admin-section-label">Calendar</p>
<h2 id="event-builder-title">Add Calendar Event</h2>
<p class="admin-note">Generate a typed entry for <code>src/data/events.ts</code>.</p>
<p class="admin-note">Generate a typed calendar entry.</p>
</div>
<span class="admin-status-pill is-ok">Live preview</span>
</div>
@ -851,49 +860,55 @@ const adminConfig = {
</section>
</div>
<aside class="admin-sidebar">
<div class="admin-utility-card">
<strong>Where this goes</strong>
<p>Copy the generated snippet into <code>src/data/events.ts</code> inside <code>calendarEvents</code>, then run the build.</p>
</div>
<section class="admin-panel" aria-labelledby="calendar-reference-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Reference</p>
<h2 id="calendar-reference-title">Calendar Files</h2>
</div>
</div>
<div class="admin-panel-body">
<ul class="admin-link-list">
<li>
<strong>Events data</strong>
<span class="admin-path">src/data/events.ts</span>
</li>
<li>
<strong>Public calendar</strong>
<span class="admin-path">/events.html</span>
</li>
</ul>
</div>
</section>
</aside>
</div>
</section>
<section class="admin-tab-panel" id="admin-panel-speeches" role="tabpanel" aria-labelledby="admin-tab-speeches" data-admin-panel="speeches" hidden>
<div class="admin-workspace">
<div class="admin-builder-stack">
<section class="admin-panel" aria-labelledby="speech-list-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Speeches</p>
<h2 id="speech-list-title">Existing Speeches</h2>
<p class="admin-note">{speechAdminEntries.length} speeches available to edit.</p>
</div>
<button class="admin-button admin-button-primary" type="button" data-add-speech>Add Speech</button>
</div>
<div class="admin-panel-body">
<div class="admin-speech-list">
{speechAdminEntries.map((speech) => (
<div class="admin-speech-row">
<div>
<span class="admin-speech-row-title">{speech.title}</span>
<span class="admin-speech-row-meta">
<span>{speech.speechYear}</span>
<span>{speech.publishedDate}</span>
<span>{speech.path}</span>
</span>
</div>
<button class="admin-button" type="button" data-speech-edit={speech.id}>Edit</button>
</div>
))}
</div>
</div>
</section>
<section class="admin-panel" aria-labelledby="speech-builder-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Speeches</p>
<h2 id="speech-builder-title">Add Speech</h2>
<p class="admin-note">Generate a Markdown file for <code>content/speeches</code>.</p>
<h2 id="speech-builder-title" data-speech-editor-title>Add Speech</h2>
<p class="admin-note" data-speech-editor-note>Generate a Markdown file for <code>content/speeches</code>.</p>
</div>
<span class="admin-status-pill is-ok">Download ready</span>
</div>
<div class="admin-panel-body">
<form class="admin-form" data-speech-form>
<input type="hidden" name="originalPath" />
<input type="hidden" name="source" />
<input type="hidden" name="categories" />
<input type="hidden" name="tags" />
<div class="admin-form-grid">
<div class="admin-field">
<label for="speech-title">Title</label>
@ -937,117 +952,23 @@ const adminConfig = {
</section>
</div>
<aside class="admin-sidebar">
<div class="admin-utility-card">
<strong>Where this goes</strong>
<p>Download or copy the generated Markdown file into the matching <code>content/speeches</code> folder, then run the build.</p>
</div>
<section class="admin-panel" aria-labelledby="speech-reference-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Reference</p>
<h2 id="speech-reference-title">Speech Files</h2>
</div>
</div>
<div class="admin-panel-body">
<ul class="admin-link-list">
<li>
<strong>Speech content</strong>
<span class="admin-path">content/speeches/&lt;year&gt;/&lt;slug&gt;.md</span>
</li>
<li>
<strong>Public archive</strong>
<span class="admin-path">/speeches/</span>
</li>
</ul>
</div>
</section>
</aside>
</div>
</section>
<section class="admin-tab-panel" id="admin-panel-settings" role="tabpanel" aria-labelledby="admin-tab-settings" data-admin-panel="settings" hidden>
<div class="admin-workspace">
<div class="admin-builder-stack">
<section class="admin-panel" aria-labelledby="operations-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Status</p>
<h2 id="operations-title">Operations</h2>
</div>
</div>
<div class="admin-panel-body">
<ul class="admin-status-list">
<li>
<strong>Contact form</strong>
<span class={contactFormEndpoint ? "admin-ok" : "admin-missing"}>
{contactFormEndpoint ? "Endpoint configured" : "Needs PUBLIC_CONTACT_FORM_ENDPOINT"}
</span>
{contactSubmissionsUrl && <a class="admin-path" href={contactSubmissionsUrl}>Open contact submissions</a>}
</li>
<li>
<strong>Visitor analytics</strong>
<span class={plausibleDomain ? "admin-ok" : "admin-missing"}>
{plausibleDomain ? `Tracking ${plausibleDomain}` : "Needs PUBLIC_PLAUSIBLE_DOMAIN"}
</span>
{analyticsDashboardUrl && <a class="admin-path" href={analyticsDashboardUrl}>Open analytics dashboard</a>}
</li>
<li>
<strong>Admin hosting</strong>
<span>Serve <code>/admin/index.html</code> as the root for <code>admin.familyfed.ie</code> and protect it with the Pocket ID group <code>familyfed_admin</code>.</span>
</li>
</ul>
</div>
</section>
<section class="admin-panel" aria-labelledby="quick-files-title">
<div class="admin-panel-header">
<div class="admin-panel-title">
<p class="admin-section-label">Reference</p>
<h2 id="quick-files-title">Quick Files</h2>
</div>
</div>
<div class="admin-panel-body">
<ul class="admin-link-list">
<li>
<strong>Events data</strong>
<span class="admin-path">src/data/events.ts</span>
</li>
<li>
<strong>Speech content</strong>
<span class="admin-path">content/speeches/&lt;year&gt;/&lt;slug&gt;.md</span>
</li>
<li>
<strong>Admin notes</strong>
<span class="admin-path">docs/ADMIN.md</span>
</li>
<li>
<strong>Pocket ID auth</strong>
<span class="admin-path">docs/POCKET-ID-AUTH.md</span>
</li>
</ul>
</div>
</section>
</div>
<aside class="admin-sidebar">
<div class="admin-utility-card">
<strong>Static admin note</strong>
<p>The dashboard can generate clean content files, but it cannot write to the repository from the browser.</p>
</div>
</aside>
</div>
</section>
<section class="admin-tab-panel" id="admin-panel-settings" role="tabpanel" aria-labelledby="admin-tab-settings" data-admin-panel="settings" hidden></section>
</main>
</div>
<script is:inline define:vars={{ adminConfig }}>
<script is:inline define:vars={{ adminConfig, speechAdminEntries }}>
(() => {
const eventForm = document.querySelector("[data-event-form]");
const speechForm = document.querySelector("[data-speech-form]");
const speechDownload = document.querySelector("[data-download-speech]");
const speechEditorTitle = document.querySelector("[data-speech-editor-title]");
const speechEditorNote = document.querySelector("[data-speech-editor-note]");
const adminTabs = Array.from(document.querySelectorAll("[data-admin-tab]"));
const adminPanels = Array.from(document.querySelectorAll("[data-admin-panel]"));
const speechEntryMap = new Map(speechAdminEntries.map((speech) => [speech.id, speech]));
function activateTab(tabName, shouldFocus = false) {
const nextTab = adminTabs.find((tab) => tab.dataset.adminTab === tabName) ?? adminTabs[0];
@ -1127,6 +1048,26 @@ const adminConfig = {
return String(new FormData(form).get(name) || "").trim();
}
function setFormValue(form, name, value) {
const field = form?.elements[name];
if (field) {
field.value = value ?? "";
}
}
function parseJsonList(value) {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
} catch {
return [];
}
}
function setMessage(element, text, type = "") {
if (!element) {
return;
@ -1261,22 +1202,27 @@ const adminConfig = {
? "Speeches"
: `Speeches of Rev Sun Myung Moon ${data.speechYear}`;
const published = `${data.publishedDate}T12:00:00+00:00`;
const path = `content/speeches/${collection}/${slug}.md`;
const path = data.originalPath || `content/speeches/${collection}/${slug}.md`;
const categories = data.categories.length ? data.categories : [category];
const tags = data.tags.length
? ["tags:", ...data.tags.map((item) => ` - ${yamlString(item)}`)]
: ["tags: []"];
const markdown = [
"---",
`title: ${yamlString(data.title)}`,
'type: "speech"',
`published: ${yamlString(published)}`,
`updated: ${yamlString(published)}`,
data.source ? `source: ${yamlString(data.source)}` : null,
`speechYear: ${yamlString(data.speechYear)}`,
"categories:",
` - ${yamlString(category)}`,
"tags: []",
...categories.map((item) => ` - ${yamlString(item)}`),
...tags,
"---",
"",
data.body,
"",
].join("\n");
].filter((line) => line !== null).join("\n");
return { markdown, path, slug };
}
@ -1300,9 +1246,64 @@ const adminConfig = {
speechYear: formValue(speechForm, "speechYear"),
speaker: formValue(speechForm, "speaker"),
body: formValue(speechForm, "body"),
originalPath: formValue(speechForm, "originalPath"),
source: formValue(speechForm, "source"),
categories: parseJsonList(formValue(speechForm, "categories")),
tags: parseJsonList(formValue(speechForm, "tags")),
};
}
function setSpeechEditorMode(mode, speech = {}) {
if (speechEditorTitle) {
speechEditorTitle.textContent = mode === "edit" ? "Edit Speech" : "Add Speech";
}
if (speechEditorNote) {
speechEditorNote.textContent = mode === "edit"
? `Editing ${speech.path}. Copy or download the Markdown when finished.`
: "Generate a Markdown file for content/speeches.";
}
}
function loadSpeechEditor(speech) {
if (!speechForm) {
return;
}
setFormValue(speechForm, "title", speech.title);
setFormValue(speechForm, "publishedDate", speech.publishedDate);
setFormValue(speechForm, "speechYear", speech.speechYear);
setFormValue(speechForm, "speaker", speech.speaker);
setFormValue(speechForm, "body", speech.body);
setFormValue(speechForm, "originalPath", speech.path);
setFormValue(speechForm, "source", speech.source);
setFormValue(speechForm, "categories", JSON.stringify(speech.categories ?? []));
setFormValue(speechForm, "tags", JSON.stringify(speech.tags ?? []));
setSpeechEditorMode("edit", speech);
refreshSpeechOutput(false);
speechForm.scrollIntoView({ behavior: "smooth", block: "start" });
speechForm.elements.title?.focus();
}
function resetSpeechEditor() {
if (!speechForm) {
return;
}
speechForm.reset();
setFormValue(speechForm, "publishedDate", new Date().toISOString().slice(0, 10));
setFormValue(speechForm, "speechYear", new Date().getFullYear().toString());
setFormValue(speechForm, "speaker", "rev");
setFormValue(speechForm, "originalPath", "");
setFormValue(speechForm, "source", "");
setFormValue(speechForm, "categories", "");
setFormValue(speechForm, "tags", "");
setSpeechEditorMode("add");
refreshSpeechOutput(false);
speechForm.scrollIntoView({ behavior: "smooth", block: "start" });
speechForm.elements.title?.focus();
}
function refreshEventOutput(showSuccess = false) {
if (!eventForm) {
return false;
@ -1392,6 +1393,17 @@ const adminConfig = {
}
});
document.querySelector("[data-add-speech]")?.addEventListener("click", resetSpeechEditor);
document.querySelectorAll("[data-speech-edit]").forEach((button) => {
button.addEventListener("click", () => {
const speech = speechEntryMap.get(button.dataset.speechEdit);
if (speech) {
loadSpeechEditor(speech);
}
});
});
eventForm?.addEventListener("input", () => refreshEventOutput(false));
speechForm?.addEventListener("input", () => refreshSpeechOutput(false));