1376 lines
60 KiB
Text
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>
|