familyfedie-website/src/pages/admin/index.astro
Loyyd 9a94f725c7
Some checks failed
/ deploy (push) Failing after 48s
Improve admin dashboard usability
2026-07-10 10:49:12 +02:00

1376 lines
60 KiB
Text

---
import "../../styles/admin.css";
import { getArchiveEntries } from "../../lib/archive";
import { speechAdminEntryFromArchiveEntry } from "../../lib/admin-speeches";
import { calendarEvents, recurringCalendarEvents } from "../../data/events";
const entries = await getArchiveEntries();
const blogEntries = entries.filter((entry) => entry.data.type === "blog");
const speechEntries = entries.filter((entry) => entry.data.type === "speech");
const contactFormEndpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
const contactSubmissionsUrl = import.meta.env.PUBLIC_CONTACT_SUBMISSIONS_URL ?? "";
const analyticsDashboardUrl = import.meta.env.PUBLIC_ANALYTICS_DASHBOARD_URL ?? "";
const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN ?? "";
const today = new Date().toISOString().slice(0, 10);
const latestEntry = entries[0];
const latestEntryTitle = latestEntry?.data.title ?? latestEntry?.id ?? "No content yet";
const latestEntryType = latestEntry?.data.type ?? "content";
const latestEntryDateIso = latestEntry?.data.published?.slice(0, 10) ?? "";
const latestEntryDate = latestEntry?.data.published
? new Intl.DateTimeFormat("en-IE", { day: "numeric", month: "short", year: "numeric" }).format(new Date(latestEntry.data.published))
: "No date";
const totalEvents = calendarEvents.length + recurringCalendarEvents.length;
const contactConfigured = Boolean(contactFormEndpoint);
const analyticsConfigured = Boolean(plausibleDomain);
const configuredServices = [contactConfigured, analyticsConfigured].filter(Boolean).length;
const speechAdminEntries = speechEntries.map((entry) => speechAdminEntryFromArchiveEntry(entry, today));
const speakerLabels = {
rev: "Rev. Sun Myung Moon",
mrs: "Dr. Hak Ja Han Moon",
other: "Other speaker",
};
const adminConfig = {
analyticsDashboardUrl,
contactEndpointConfigured: contactConfigured,
contactSubmissionsUrl,
plausibleDomain,
};
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex,nofollow" />
<meta name="theme-color" content="#18303a" />
<title>Overview · FamilyFed.ie Admin</title>
<link rel="icon" href="/assets/icons/familyfed-favicon.png" type="image/png" />
</head>
<body>
<a class="admin-skip-link" href="#admin-content">Skip to content</a>
<div class="admin-app">
<aside class="admin-sidebar" aria-label="Admin navigation">
<a class="admin-brand" href="/" aria-label="FamilyFed.ie home">
<span class="admin-brand-mark">
<img src="/assets/icons/familyfed-logo.webp" alt="" />
</span>
<span class="admin-brand-copy">
<strong>FamilyFed.ie</strong>
<span>Admin workspace</span>
</span>
</a>
<nav class="admin-nav" aria-label="Admin sections" role="tablist">
<p class="admin-nav-label">Workspace</p>
<button
class="admin-nav-button"
id="admin-tab-overview"
type="button"
role="tab"
aria-selected="true"
aria-controls="admin-panel-overview"
data-admin-tab="overview"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5"></rect>
<rect x="14" y="3" width="7" height="7" rx="1.5"></rect>
<rect x="3" y="14" width="7" height="7" rx="1.5"></rect>
<rect x="14" y="14" width="7" height="7" rx="1.5"></rect>
</svg>
<span>Overview</span>
</button>
<button
class="admin-nav-button"
id="admin-tab-calendar"
type="button"
role="tab"
aria-selected="false"
aria-controls="admin-panel-calendar"
data-admin-tab="calendar"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2"></rect>
<path d="M16 3v4M8 3v4M3 10h18"></path>
<path d="M8 14h3M13 14h3M8 17h3"></path>
</svg>
<span>Events</span>
</button>
<button
class="admin-nav-button"
id="admin-tab-speeches"
type="button"
role="tab"
aria-selected="false"
aria-controls="admin-panel-speeches"
data-admin-tab="speeches"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M5 3h10l4 4v14H5z"></path>
<path d="M15 3v5h5M8 12h8M8 16h8"></path>
</svg>
<span>Speeches</span>
</button>
<button
class="admin-nav-button"
id="admin-tab-settings"
type="button"
role="tab"
aria-selected="false"
aria-controls="admin-panel-settings"
data-admin-tab="settings"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.6v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"></path>
</svg>
<span>Settings</span>
</button>
</nav>
<div class="admin-sidebar-note">
<strong>Static content tools</strong>
<p>This workspace creates snippets and files. Add them to the repository to publish changes.</p>
</div>
</aside>
<div class="admin-page">
<header class="admin-topbar">
<div class="admin-topbar-copy">
<p data-admin-section-description>Website dashboard</p>
<h1 data-admin-section-title>Overview</h1>
</div>
<div class="admin-topbar-actions">
<span class={`admin-status-pill ${configuredServices === 2 ? "is-ready" : "is-warning"}`}>
{configuredServices === 2 ? "Services ready" : `${configuredServices}/2 services ready`}
</span>
<a class="admin-link-button" href="/" target="_blank" rel="noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M14 5h5v5M19 5l-8 8"></path>
<path d="M19 13v6H5V5h6"></path>
</svg>
View website
</a>
</div>
</header>
<main class="admin-main" id="admin-content">
<section
class="admin-tab-panel"
id="admin-panel-overview"
role="tabpanel"
aria-labelledby="admin-tab-overview"
data-admin-panel="overview"
>
<section class="admin-overview-banner" aria-labelledby="admin-dashboard-title">
<div class="admin-banner-copy">
<h2 id="admin-dashboard-title">Everything you need to manage the site.</h2>
<p>Create calendar updates, find and edit speeches, and check which connected services are ready.</p>
</div>
<div class="admin-banner-actions" aria-label="Quick actions">
<button class="admin-button admin-button-primary" type="button" data-admin-tab-link="calendar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
<path d="M12 5v14M5 12h14"></path>
</svg>
Add event
</button>
<button class="admin-button" type="button" data-admin-tab-link="speeches" data-add-speech>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
<path d="M12 5v14M5 12h14"></path>
</svg>
Add speech
</button>
<a class="admin-link-button" href="/" target="_blank" rel="noreferrer">Open public site</a>
</div>
</section>
<section class="admin-stat-grid" aria-label="Website overview">
<article class="admin-stat-card">
<span class="admin-stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="3" y="5" width="18" height="16" rx="2"></rect>
<path d="M16 3v4M8 3v4M3 10h18"></path>
</svg>
</span>
<div class="admin-stat-copy">
<span>Calendar events</span>
<strong>{totalEvents}</strong>
<small>{recurringCalendarEvents.length} recurring · {calendarEvents.length} dated</small>
</div>
</article>
<article class="admin-stat-card">
<span class="admin-stat-icon is-warm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M5 3h10l4 4v14H5z"></path>
<path d="M15 3v5h5M8 12h8M8 16h8"></path>
</svg>
</span>
<div class="admin-stat-copy">
<span>Speeches</span>
<strong>{speechEntries.length}</strong>
<small>Available to search and edit</small>
</div>
</article>
<article class="admin-stat-card">
<span class="admin-stat-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 5h16v14H4z"></path>
<path d="M8 9h8M8 13h8M8 17h5"></path>
</svg>
</span>
<div class="admin-stat-copy">
<span>Blog and news</span>
<strong>{blogEntries.length}</strong>
<small>Published content entries</small>
</div>
</article>
<article class="admin-stat-card">
<span class="admin-stat-icon is-green">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M20 6 9 17l-5-5"></path>
</svg>
</span>
<div class="admin-stat-copy">
<span>Connected services</span>
<strong>{configuredServices}/2</strong>
<small>Contact form and analytics</small>
</div>
</article>
</section>
<section class="admin-overview-grid" aria-label="Dashboard details">
<article class="admin-panel">
<header class="admin-panel-header">
<div>
<h3>Latest content</h3>
<p>Most recently dated site entry</p>
</div>
</header>
<div class="admin-panel-body">
<div class="admin-latest-card">
<span class="admin-content-type">{latestEntryType}</span>
<h4>{latestEntryTitle}</h4>
{latestEntryDateIso ? (
<p><time datetime={latestEntryDateIso}>{latestEntryDate}</time></p>
) : (
<p>{latestEntryDate}</p>
)}
</div>
</div>
</article>
<article class="admin-panel">
<header class="admin-panel-header">
<div>
<h3>Service status</h3>
<p>Build-time connections</p>
</div>
<button class="admin-button admin-button-quiet" type="button" data-admin-tab-link="settings">Details</button>
</header>
<div class="admin-panel-body">
<div class="admin-status-stack">
<div class="admin-status-row">
<div class="admin-status-row-copy">
<strong>Contact form</strong>
<small>Visitor submissions</small>
</div>
<span class={`admin-status-pill ${contactConfigured ? "is-ready" : "is-warning"}`}>
{contactConfigured ? "Ready" : "Setup needed"}
</span>
</div>
<div class="admin-status-row">
<div class="admin-status-row-copy">
<strong>Analytics</strong>
<small>Website reporting</small>
</div>
<span class={`admin-status-pill ${analyticsConfigured ? "is-ready" : "is-warning"}`}>
{analyticsConfigured ? "Ready" : "Setup needed"}
</span>
</div>
</div>
</div>
</article>
<article class="admin-panel">
<header class="admin-panel-header">
<div>
<h3>Publishing workflow</h3>
<p>How changes reach the website</p>
</div>
</header>
<div class="admin-panel-body">
<ol class="admin-workflow-list">
<li>Generate the event snippet or speech file here.</li>
<li>Add the generated content to the repository.</li>
<li>Commit and deploy the static site.</li>
</ol>
</div>
</article>
</section>
</section>
<section
class="admin-tab-panel"
id="admin-panel-calendar"
role="tabpanel"
aria-labelledby="admin-tab-calendar"
data-admin-panel="calendar"
hidden
>
<header class="admin-section-heading">
<div>
<p class="admin-eyebrow">Calendar</p>
<h2>Create a calendar event</h2>
<p>Fill in the event details. The repository-ready snippet updates automatically as you type.</p>
</div>
<div class="admin-section-actions">
<a class="admin-link-button" href="/events" target="_blank" rel="noreferrer">View public calendar</a>
</div>
</header>
<div class="admin-event-layout">
<section class="admin-panel" aria-labelledby="event-details-title">
<header class="admin-panel-header">
<div>
<h3 id="event-details-title">Event details</h3>
<p>Required fields are marked with an asterisk.</p>
</div>
</header>
<div class="admin-panel-body">
<form class="admin-form" data-event-form>
<div class="admin-form-grid">
<div class="admin-field">
<label for="event-title">Title <span aria-hidden="true">*</span></label>
<input id="event-title" name="title" required placeholder="Community gathering" autocomplete="off" />
</div>
<div class="admin-field">
<label for="event-date">Date <span aria-hidden="true">*</span></label>
<input id="event-date" name="date" type="date" value={today} required />
</div>
<div class="admin-field">
<label for="event-start">Start time <span aria-hidden="true">*</span></label>
<input id="event-start" name="startTime" type="time" value="11:00" required />
</div>
<div class="admin-field">
<label for="event-end">End time <span aria-hidden="true">*</span></label>
<input id="event-end" name="endTime" type="time" value="12:00" required />
</div>
<div class="admin-field">
<label for="event-location">Location <span aria-hidden="true">*</span></label>
<input id="event-location" name="location" value="19 North Great Georges St., Dublin 1, Ireland" required />
</div>
<div class="admin-field">
<label for="event-url">Details URL <span class="admin-optional">Optional</span></label>
<input id="event-url" name="url" placeholder="/events" inputmode="url" />
<small class="admin-field-help">Use a local path or a full https:// URL.</small>
</div>
</div>
<div class="admin-field">
<label for="event-details">Description <span aria-hidden="true">*</span></label>
<textarea id="event-details" name="details" required placeholder="Short description shown on the calendar."></textarea>
<small class="admin-field-help">Keep this clear and concise for the public calendar.</small>
</div>
<div class="admin-form-footer">
<span class="admin-field-help">Your changes stay in this browser until you copy the snippet.</span>
<div class="admin-button-row">
<button class="admin-button admin-button-quiet" type="reset">Reset fields</button>
<button class="admin-button" type="submit">Check preview</button>
</div>
</div>
</form>
</div>
</section>
<section class="admin-panel admin-preview-panel" aria-labelledby="event-preview-title">
<header class="admin-panel-header">
<div>
<h3 id="event-preview-title">Generated snippet</h3>
<p>Ready for <code>src/data/events.ts</code></p>
</div>
<span class="admin-status-pill is-neutral">Live preview</span>
</header>
<div class="admin-panel-body">
<div class="admin-output-block">
<div class="admin-output-meta">
<strong>Destination</strong>
<span class="admin-path" data-event-path>Complete the required fields to generate a snippet.</span>
</div>
<textarea class="admin-code-output" id="event-output" aria-label="Generated event snippet" readonly spellcheck="false"></textarea>
<span class="admin-message" data-event-message aria-live="polite"></span>
<button class="admin-button admin-button-primary" type="button" data-copy="event-output" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="8" y="8" width="11" height="11" rx="2"></rect>
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"></path>
</svg>
Copy snippet
</button>
<div class="admin-handoff-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 10v6M12 7h.01"></path>
</svg>
<span>Copy this object into <code>calendarEvents</code>, then commit and deploy the site.</span>
</div>
</div>
</div>
</section>
</div>
</section>
<section
class="admin-tab-panel"
id="admin-panel-speeches"
role="tabpanel"
aria-labelledby="admin-tab-speeches"
data-admin-panel="speeches"
hidden
>
<header class="admin-section-heading">
<div>
<p class="admin-eyebrow">Content library</p>
<h2>Manage speeches</h2>
<p>Search the archive, open an existing speech, or generate a new Markdown file.</p>
</div>
<div class="admin-section-actions">
<a class="admin-link-button" href="/speeches" target="_blank" rel="noreferrer">View speech archive</a>
<button class="admin-button admin-button-primary" type="button" data-add-speech>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true">
<path d="M12 5v14M5 12h14"></path>
</svg>
New speech
</button>
</div>
</header>
<div class="admin-speech-layout">
<section class="admin-panel admin-speech-collection" aria-labelledby="speech-library-title">
<header class="admin-panel-header">
<div>
<h3 id="speech-library-title">Speech library</h3>
<p>{speechAdminEntries.length} files in the archive</p>
</div>
</header>
<div class="admin-speech-toolbar">
<div class="admin-search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="m16 16 5 5"></path>
</svg>
<label for="speech-search" class="admin-visually-hidden">Search speeches</label>
<input id="speech-search" type="search" placeholder="Search title, year or path…" data-speech-search />
</div>
<div class="admin-filter-row">
<label for="speech-speaker-filter" class="admin-visually-hidden">Filter by speaker</label>
<select id="speech-speaker-filter" class="admin-filter-select" data-speech-speaker-filter>
<option value="">All speakers</option>
<option value="rev">Rev. Sun Myung Moon</option>
<option value="mrs">Dr. Hak Ja Han Moon</option>
<option value="other">Other speakers</option>
</select>
<button class="admin-button admin-button-quiet" type="button" data-clear-speech-filters>Clear</button>
</div>
<p class="admin-speech-result-count" data-speech-result-count aria-live="polite"></p>
</div>
<ul class="admin-speech-list" data-speech-list>
{speechAdminEntries.map((speech) => (
<li
data-speech-row
data-search={`${speech.title} ${speech.path} ${speech.speechYear}`.toLowerCase()}
data-speaker={speech.speaker}
>
<button
class="admin-speech-row"
type="button"
data-speech-edit={speech.id}
aria-label={`Edit ${speech.title}`}
>
<span class="admin-speech-row-copy">
<span class="admin-speech-row-title">{speech.title}</span>
<span class="admin-speech-row-meta">
<span>{speakerLabels[speech.speaker]}</span>
<span>{speech.speechYear}</span>
<time datetime={speech.publishedDate}>{speech.publishedDate}</time>
</span>
<span class="admin-speech-row-path">{speech.path}</span>
</span>
<span class="admin-speech-row-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"></path>
</svg>
</span>
</button>
</li>
))}
</ul>
<div class="admin-speech-empty" data-speech-empty hidden>
<strong>No speeches found</strong>
<p>Try a different title, year, or speaker.</p>
</div>
<div class="admin-speech-list-footer">
<button class="admin-button" type="button" data-speech-show-more>Show more speeches</button>
</div>
</section>
<section class="admin-panel admin-speech-editor" aria-labelledby="speech-builder-title" data-speech-editor>
<header class="admin-panel-header">
<div>
<h3 id="speech-builder-title" data-speech-editor-title>New speech</h3>
<p data-speech-editor-note>Generate a Markdown file for <code>content/speeches</code>.</p>
</div>
<span class="admin-status-pill is-neutral" data-speech-editor-status>New file</span>
</header>
<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 <span aria-hidden="true">*</span></label>
<input id="speech-title" name="title" required placeholder="Speech title" autocomplete="off" />
</div>
<div class="admin-field">
<label for="speech-date">Published date <span aria-hidden="true">*</span></label>
<input id="speech-date" name="publishedDate" type="date" value={today} required />
</div>
<div class="admin-field">
<label for="speech-year">Speech year <span aria-hidden="true">*</span></label>
<input id="speech-year" name="speechYear" inputmode="numeric" pattern="[0-9]{4}" value={new Date().getFullYear()} required />
</div>
<div class="admin-field">
<label for="speech-speaker">Speaker <span aria-hidden="true">*</span></label>
<select id="speech-speaker" name="speaker">
<option value="rev">Rev. Sun Myung Moon</option>
<option value="mrs">Dr. Hak Ja Han Moon</option>
<option value="other">Other speaker</option>
</select>
</div>
</div>
<div class="admin-field">
<label for="speech-body">Speech body <span aria-hidden="true">*</span></label>
<textarea class="admin-speech-body" id="speech-body" name="body" required placeholder="<p>Paste the speech text here.</p>"></textarea>
<small class="admin-field-help">Paste the existing HTML content or write simple HTML paragraphs.</small>
</div>
<div class="admin-form-footer">
<span class="admin-message" data-speech-message aria-live="polite"></span>
<div class="admin-button-row">
<button class="admin-button" type="button" data-copy="speech-output" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="8" y="8" width="11" height="11" rx="2"></rect>
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"></path>
</svg>
Copy Markdown
</button>
<a class="admin-link-button admin-link-button-primary" data-download-speech href="#" aria-disabled="true" tabindex="-1" download>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12M7 10l5 5 5-5M5 21h14"></path>
</svg>
Download .md
</a>
</div>
</div>
<details class="admin-code-details">
<summary>
<span>Generated Markdown</span>
<span class="admin-path" data-speech-path>Complete the required fields to generate a file.</span>
</summary>
<div class="admin-code-details-body">
<textarea class="admin-code-output" id="speech-output" aria-label="Generated speech markdown" readonly spellcheck="false"></textarea>
<div class="admin-handoff-note">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 10v6M12 7h.01"></path>
</svg>
<span>Download or copy the file, add it to the path above, then commit and deploy.</span>
</div>
</div>
</details>
</form>
</div>
</section>
</div>
</section>
<section
class="admin-tab-panel"
id="admin-panel-settings"
role="tabpanel"
aria-labelledby="admin-tab-settings"
data-admin-panel="settings"
hidden
>
<header class="admin-section-heading">
<div>
<p class="admin-eyebrow">Configuration</p>
<h2>Settings and connections</h2>
<p>Check the build-time services used by the public website and review the publishing workflow.</p>
</div>
</header>
<div class="admin-settings-grid">
<article class="admin-setting-card">
<div class="admin-setting-card-header">
<span class="admin-setting-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M3 6h18v12H3z"></path>
<path d="m4 7 8 6 8-6"></path>
</svg>
</span>
<div class="admin-setting-card-title">
<h3>Contact form</h3>
<p>Receives messages submitted from the website.</p>
</div>
<span class={`admin-status-pill ${contactConfigured ? "is-ready" : "is-warning"}`}>
{contactConfigured ? "Connected" : "Not configured"}
</span>
</div>
<div class="admin-setting-detail">
<span>Environment variable</span>
<strong><code>PUBLIC_CONTACT_FORM_ENDPOINT</code></strong>
</div>
{contactSubmissionsUrl ? (
<a class="admin-link-button" href={contactSubmissionsUrl} target="_blank" rel="noreferrer">Open submissions</a>
) : (
<p class="admin-field-help">Add <code>PUBLIC_CONTACT_SUBMISSIONS_URL</code> to link the submissions inbox here.</p>
)}
</article>
<article class="admin-setting-card">
<div class="admin-setting-card-header">
<span class="admin-setting-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"></path>
</svg>
</span>
<div class="admin-setting-card-title">
<h3>Analytics</h3>
<p>Plausible reporting for public website visits.</p>
</div>
<span class={`admin-status-pill ${analyticsConfigured ? "is-ready" : "is-warning"}`}>
{analyticsConfigured ? "Connected" : "Not configured"}
</span>
</div>
<div class="admin-setting-detail">
<span>Tracked domain</span>
<strong>{plausibleDomain || <code>PUBLIC_PLAUSIBLE_DOMAIN</code>}</strong>
</div>
{analyticsDashboardUrl ? (
<a class="admin-link-button" href={analyticsDashboardUrl} target="_blank" rel="noreferrer">Open analytics dashboard</a>
) : (
<p class="admin-field-help">Add <code>PUBLIC_ANALYTICS_DASHBOARD_URL</code> to link reports here.</p>
)}
</article>
<article class="admin-setting-card">
<div class="admin-setting-card-header">
<span class="admin-setting-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="4" y="10" width="16" height="11" rx="2"></rect>
<path d="M8 10V7a4 4 0 0 1 8 0v3"></path>
</svg>
</span>
<div class="admin-setting-card-title">
<h3>Admin access</h3>
<p>Authentication is enforced by the hosting proxy.</p>
</div>
<span class="admin-status-pill is-neutral">Hosting managed</span>
</div>
<div class="admin-setting-detail">
<span>Required Pocket ID group</span>
<strong><code>familyfed_admin</code></strong>
</div>
<p class="admin-field-help">The static page cannot enforce login on its own. Keep the admin host and content endpoints behind the proxy.</p>
</article>
<article class="admin-setting-card">
<div class="admin-setting-card-header">
<span class="admin-setting-card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 3v12M7 10l5 5 5-5M5 21h14"></path>
</svg>
</span>
<div class="admin-setting-card-title">
<h3>Static publishing</h3>
<p>The admin generates handoff files rather than saving directly.</p>
</div>
<span class="admin-status-pill is-neutral">Manual deploy</span>
</div>
<div class="admin-setting-detail">
<span>Build command</span>
<strong><code>npm run build</code></strong>
</div>
<a class="admin-link-button" href="/" target="_blank" rel="noreferrer">Open current website</a>
</article>
<article class="admin-setting-card admin-settings-wide">
<div class="admin-setting-card-title">
<h3>From admin to published</h3>
<p>Use the same three-step process for events and speeches.</p>
</div>
<div class="admin-settings-workflow">
<div class="admin-settings-step">
<span>Step 1</span>
<strong>Create</strong>
<p>Complete the form and check the generated output.</p>
</div>
<div class="admin-settings-step">
<span>Step 2</span>
<strong>Add to the repository</strong>
<p>Paste the event snippet or place the downloaded Markdown file.</p>
</div>
<div class="admin-settings-step">
<span>Step 3</span>
<strong>Deploy</strong>
<p>Commit the change and run the normal static-site deployment.</p>
</div>
</div>
</article>
</div>
</section>
</main>
</div>
</div>
<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 speechEditor = document.querySelector("[data-speech-editor]");
const speechEditorTitle = document.querySelector("[data-speech-editor-title]");
const speechEditorNote = document.querySelector("[data-speech-editor-note]");
const speechEditorStatus = document.querySelector("[data-speech-editor-status]");
const adminSectionTitle = document.querySelector("[data-admin-section-title]");
const adminSectionDescription = document.querySelector("[data-admin-section-description]");
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]));
const speechRows = Array.from(document.querySelectorAll("[data-speech-row]"));
const speechSearch = document.querySelector("[data-speech-search]");
const speechSpeakerFilter = document.querySelector("[data-speech-speaker-filter]");
const speechResultCount = document.querySelector("[data-speech-result-count]");
const speechEmpty = document.querySelector("[data-speech-empty]");
const speechShowMore = document.querySelector("[data-speech-show-more]");
const sectionMeta = {
overview: { title: "Overview", description: "Website dashboard" },
calendar: { title: "Events", description: "Calendar content" },
speeches: { title: "Speeches", description: "Content library" },
settings: { title: "Settings", description: "Connections and publishing" },
};
let speechVisibleLimit = 24;
let speechLoadRequest = 0;
function activateTab(tabName, shouldFocus = false, updateHistory = true) {
const nextTab = adminTabs.find((tab) => tab.dataset.adminTab === tabName) ?? adminTabs[0];
const nextTabName = nextTab?.dataset.adminTab;
if (!nextTabName) {
return;
}
adminTabs.forEach((tab) => {
const isActive = tab.dataset.adminTab === nextTabName;
tab.setAttribute("aria-selected", String(isActive));
tab.tabIndex = isActive ? 0 : -1;
});
adminPanels.forEach((panel) => {
panel.hidden = panel.dataset.adminPanel !== nextTabName;
});
const meta = sectionMeta[nextTabName] ?? sectionMeta.overview;
if (adminSectionTitle) {
adminSectionTitle.textContent = meta.title;
}
if (adminSectionDescription) {
adminSectionDescription.textContent = meta.description;
}
document.title = `${meta.title} · FamilyFed.ie Admin`;
if (updateHistory && window.location.hash !== `#${nextTabName}`) {
window.history.pushState({ adminTab: nextTabName }, "", `#${nextTabName}`);
}
if (shouldFocus) {
nextTab.focus();
}
}
adminTabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
activateTab(tab.dataset.adminTab);
window.scrollTo({ top: 0, behavior: "smooth" });
});
tab.addEventListener("keydown", (event) => {
const currentIndex = adminTabs.indexOf(tab);
const lastIndex = adminTabs.length - 1;
let nextIndex = currentIndex;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1;
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1;
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = lastIndex;
} else {
return;
}
event.preventDefault();
activateTab(adminTabs[nextIndex].dataset.adminTab, true);
window.scrollTo({ top: 0, behavior: "smooth" });
});
tab.tabIndex = index === 0 ? 0 : -1;
});
document.querySelectorAll("[data-admin-tab-link]").forEach((button) => {
button.addEventListener("click", () => {
activateTab(button.dataset.adminTabLink);
window.scrollTo({ top: 0, behavior: "smooth" });
});
});
window.addEventListener("popstate", () => {
const tabName = window.location.hash.slice(1);
activateTab(tabName || "overview", false, false);
});
function slugify(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
function jsString(value) {
return JSON.stringify(String(value || ""));
}
function yamlString(value) {
return JSON.stringify(String(value || ""));
}
function formValue(form, name) {
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;
}
element.textContent = text;
element.classList.toggle("is-success", type === "success");
element.classList.toggle("is-error", type === "error");
}
function setCopyEnabled(outputId, enabled) {
const button = document.querySelector(`[data-copy="${outputId}"]`);
if (button) {
button.disabled = !enabled;
}
}
function setDownloadEnabled(link, enabled) {
if (!link) {
return;
}
link.setAttribute("aria-disabled", enabled ? "false" : "true");
link.tabIndex = enabled ? 0 : -1;
}
function setDownload(link, fileName, contents) {
if (!link) {
return;
}
if (link.dataset.objectUrl) {
URL.revokeObjectURL(link.dataset.objectUrl);
}
const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
link.href = url;
link.download = fileName;
link.dataset.objectUrl = url;
setDownloadEnabled(link, true);
}
function resetDownload(link) {
if (!link) {
return;
}
if (link.dataset.objectUrl) {
URL.revokeObjectURL(link.dataset.objectUrl);
delete link.dataset.objectUrl;
}
link.href = "#";
link.removeAttribute("download");
setDownloadEnabled(link, false);
}
function writeClipboard(value) {
if (navigator.clipboard?.writeText && window.isSecureContext) {
return navigator.clipboard.writeText(value);
}
const helper = document.createElement("textarea");
helper.value = value;
helper.setAttribute("readonly", "");
helper.style.position = "fixed";
helper.style.left = "-9999px";
document.body.append(helper);
helper.select();
try {
document.execCommand("copy");
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
} finally {
helper.remove();
}
}
function validateEvent(data) {
if (!data.title || !data.date || !data.startTime || !data.endTime || !data.location || !data.details) {
return "Complete the required event fields.";
}
if (!slugify(data.title)) {
return "Use at least one letter or number in the event title.";
}
if (data.endTime <= data.startTime) {
return "End time must be later than start time.";
}
if (data.url && !data.url.startsWith("/") && !/^https?:\/\//i.test(data.url)) {
return "Use a local path or full URL for details.";
}
return "";
}
function validateSpeech(data) {
if (!data.title || !data.publishedDate || !data.speechYear || !data.body) {
return "Complete the required speech fields.";
}
if (!slugify(data.title)) {
return "Use at least one letter or number in the speech title.";
}
if (!/^\d{4}$/.test(data.speechYear)) {
return "Speech year must use four digits.";
}
return "";
}
function generateEvent(data) {
const id = `${slugify(data.title)}-${data.date}`;
return [
" {",
` id: ${jsString(id)},`,
` title: ${jsString(data.title)},`,
` date: ${jsString(data.date)},`,
` startTime: ${jsString(data.startTime)},`,
` endTime: ${jsString(data.endTime)},`,
` location: ${jsString(data.location)},`,
` details: ${jsString(data.details)},`,
data.url ? ` url: ${jsString(data.url)},` : "",
" },",
].filter(Boolean).join("\n");
}
function generateSpeech(data) {
const slug = slugify(data.title);
const collection = data.speaker === "mrs" ? "mrs-hak-ja-han-moon" : data.speechYear;
const category = data.speaker === "mrs"
? "Speeches of Mrs. Hak Ja Han Moon"
: data.speaker === "other"
? "Speeches"
: `Speeches of Rev Sun Myung Moon ${data.speechYear}`;
const published = `${data.publishedDate}T12:00:00+00:00`;
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:",
...categories.map((item) => ` - ${yamlString(item)}`),
...tags,
"---",
"",
data.body,
"",
].filter((line) => line !== null).join("\n");
return { markdown, path, slug };
}
function getEventData() {
return {
title: formValue(eventForm, "title"),
date: formValue(eventForm, "date"),
startTime: formValue(eventForm, "startTime"),
endTime: formValue(eventForm, "endTime"),
location: formValue(eventForm, "location"),
details: formValue(eventForm, "details"),
url: formValue(eventForm, "url"),
};
}
function getSpeechData() {
return {
title: formValue(speechForm, "title"),
publishedDate: formValue(speechForm, "publishedDate"),
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" : "New speech";
}
if (speechEditorNote) {
speechEditorNote.textContent = mode === "edit"
? `Editing ${speech.path}. Download or copy the Markdown when finished.`
: "Generate a Markdown file for content/speeches.";
}
if (speechEditorStatus) {
speechEditorStatus.textContent = mode === "edit" ? "Editing" : "New file";
}
}
function markSelectedSpeech(speechId = "") {
speechRows.forEach((row) => {
const button = row.querySelector("[data-speech-edit]");
const isSelected = button?.dataset.speechEdit === speechId;
button?.classList.toggle("is-selected", isSelected);
if (isSelected) {
button.setAttribute("aria-current", "true");
} else {
button?.removeAttribute("aria-current");
}
});
}
async function loadSpeechEditor(speech) {
if (!speechForm) {
return;
}
const requestId = ++speechLoadRequest;
const message = document.querySelector("[data-speech-message]");
markSelectedSpeech(speech.id);
setMessage(message, "Loading speech body...");
if (!speech.body && speech.bodyUrl) {
try {
const response = await fetch(speech.bodyUrl);
if (!response.ok) {
throw new Error(`Speech body request failed: ${response.status}`);
}
const loadedSpeech = await response.json();
if (requestId !== speechLoadRequest) {
return;
}
speech = { ...speech, ...loadedSpeech };
} catch {
if (requestId === speechLoadRequest) {
setMessage(message, "Could not load that speech body. Try refreshing the admin page.", "error");
}
return;
}
}
if (requestId !== speechLoadRequest) {
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, false);
if (window.matchMedia("(max-width: 1000px)").matches) {
speechEditor?.scrollIntoView({ behavior: "smooth", block: "start" });
}
speechForm.elements.title?.focus();
}
function resetSpeechEditor() {
if (!speechForm) {
return;
}
speechLoadRequest += 1;
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");
markSelectedSpeech();
refreshSpeechOutput(false, false);
if (window.matchMedia("(max-width: 1000px)").matches) {
speechEditor?.scrollIntoView({ behavior: "smooth", block: "start" });
}
speechForm.elements.title?.focus();
}
function refreshEventOutput(showSuccess = false, showErrors = true) {
if (!eventForm) {
return false;
}
const output = document.getElementById("event-output");
const path = document.querySelector("[data-event-path]");
const message = document.querySelector("[data-event-message]");
const data = getEventData();
const error = validateEvent(data);
if (error) {
output.value = "";
path.textContent = "Complete the required fields to generate a snippet.";
setCopyEnabled("event-output", false);
setMessage(message, showErrors ? error : "", showErrors ? "error" : "");
return false;
}
output.value = generateEvent(data);
path.textContent = "src/data/events.ts → calendarEvents";
setCopyEnabled("event-output", true);
setMessage(message, showSuccess ? "Preview is up to date." : "Ready to copy.", "success");
return true;
}
function refreshSpeechOutput(showSuccess = false, showErrors = true) {
if (!speechForm) {
return false;
}
const output = document.getElementById("speech-output");
const path = document.querySelector("[data-speech-path]");
const message = document.querySelector("[data-speech-message]");
const data = getSpeechData();
const error = validateSpeech(data);
if (error) {
output.value = "";
path.textContent = "Complete the required fields to generate a file.";
setCopyEnabled("speech-output", false);
resetDownload(speechDownload);
setMessage(message, showErrors ? error : "", showErrors ? "error" : "");
return false;
}
const generated = generateSpeech(data);
output.value = generated.markdown;
path.textContent = generated.path;
setCopyEnabled("speech-output", true);
setDownload(speechDownload, `${generated.slug}.md`, generated.markdown);
setMessage(message, showSuccess ? "Markdown is up to date." : "Ready to copy or download.", "success");
return true;
}
function updateSpeechList(resetLimit = false) {
if (resetLimit) {
speechVisibleLimit = 24;
}
const query = String(speechSearch?.value || "").trim().toLowerCase();
const speaker = String(speechSpeakerFilter?.value || "");
const matches = speechRows.filter((row) => {
const matchesText = !query || row.dataset.search.includes(query);
const matchesSpeaker = !speaker || row.dataset.speaker === speaker;
return matchesText && matchesSpeaker;
});
const matchSet = new Set(matches);
let visibleMatchIndex = 0;
speechRows.forEach((row) => {
if (!matchSet.has(row)) {
row.hidden = true;
return;
}
row.hidden = visibleMatchIndex >= speechVisibleLimit;
visibleMatchIndex += 1;
});
const visibleCount = Math.min(matches.length, speechVisibleLimit);
if (speechResultCount) {
speechResultCount.textContent = matches.length
? `Showing ${visibleCount} of ${matches.length} matching speeches`
: "No matching speeches";
}
if (speechEmpty) {
speechEmpty.hidden = matches.length !== 0;
}
if (speechShowMore) {
speechShowMore.hidden = visibleCount >= matches.length;
}
}
document.querySelectorAll("[data-copy]").forEach((button) => {
const originalContents = button.innerHTML;
button.addEventListener("click", async () => {
const target = document.getElementById(button.dataset.copy);
const panel = button.closest(".admin-panel");
const message = panel?.querySelector(".admin-message");
if (!target?.value) {
setMessage(message, "Generate content before copying.", "error");
return;
}
try {
await writeClipboard(target.value);
button.textContent = "Copied";
setMessage(message, "Copied to clipboard.", "success");
window.setTimeout(() => {
button.innerHTML = originalContents;
}, 1400);
} catch {
target.focus();
target.select();
setMessage(message, "Copy failed. Select the output manually.", "error");
}
});
});
speechDownload?.addEventListener("click", (event) => {
if (speechDownload.getAttribute("aria-disabled") === "true") {
event.preventDefault();
}
});
document.querySelectorAll("[data-add-speech]").forEach((button) => {
button.addEventListener("click", () => {
if (button.dataset.adminTabLink !== "speeches") {
activateTab("speeches");
}
resetSpeechEditor();
});
});
document.querySelectorAll("[data-speech-edit]").forEach((button) => {
button.addEventListener("click", () => {
const speech = speechEntryMap.get(button.dataset.speechEdit);
if (speech) {
loadSpeechEditor(speech);
}
});
});
speechSearch?.addEventListener("input", () => updateSpeechList(true));
speechSpeakerFilter?.addEventListener("change", () => updateSpeechList(true));
document.querySelector("[data-clear-speech-filters]")?.addEventListener("click", () => {
if (speechSearch) {
speechSearch.value = "";
}
if (speechSpeakerFilter) {
speechSpeakerFilter.value = "";
}
updateSpeechList(true);
speechSearch?.focus();
});
speechShowMore?.addEventListener("click", () => {
speechVisibleLimit += 24;
updateSpeechList(false);
});
eventForm?.addEventListener("input", () => refreshEventOutput(false, true));
speechForm?.addEventListener("input", () => refreshSpeechOutput(false, true));
eventForm?.addEventListener("submit", (event) => {
event.preventDefault();
refreshEventOutput(true, true);
});
speechForm?.addEventListener("submit", (event) => {
event.preventDefault();
refreshSpeechOutput(true, true);
});
eventForm?.addEventListener("reset", () => {
window.requestAnimationFrame(() => refreshEventOutput(false, false));
});
const initialTab = window.location.hash.slice(1);
activateTab(initialTab || "overview", false, false);
updateSpeechList(true);
refreshEventOutput(false, false);
refreshSpeechOutput(false, false);
window.familyfedAdmin = adminConfig;
})();
</script>
</body>
</html>