Add admin dashboard and Pocket ID auth notes
All checks were successful
/ deploy (push) Successful in 42s
All checks were successful
/ deploy (push) Successful in 42s
This commit is contained in:
parent
fc5762d267
commit
06093af633
10 changed files with 947 additions and 29 deletions
647
src/pages/admin/index.astro
Normal file
647
src/pages/admin/index.astro
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
---
|
||||
import { getArchiveEntries } from "../../lib/archive";
|
||||
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 adminConfig = {
|
||||
analyticsDashboardUrl,
|
||||
contactEndpointConfigured: Boolean(contactFormEndpoint),
|
||||
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" />
|
||||
<title>FamilyFed.ie Admin</title>
|
||||
<link rel="icon" href="/assets/icons/familyfed-favicon.png" type="image/png" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--admin-ink: #1f2a2e;
|
||||
--admin-muted: #637077;
|
||||
--admin-border: #dfe4e1;
|
||||
--admin-soft: #f6f8f5;
|
||||
--admin-panel: #ffffff;
|
||||
--admin-accent: #b94b2b;
|
||||
--admin-accent-strong: #86351f;
|
||||
--admin-good: #2f6b3c;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--admin-ink);
|
||||
background: #eef2ef;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--admin-accent-strong);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
background: var(--admin-panel);
|
||||
}
|
||||
|
||||
.admin-header-inner,
|
||||
.admin-main {
|
||||
width: min(1180px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-header-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-brand img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.admin-brand p,
|
||||
.admin-brand h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-brand p {
|
||||
color: var(--admin-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-brand h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-button,
|
||||
.admin-link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 38px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
color: var(--admin-ink);
|
||||
background: var(--admin-panel);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-button-primary,
|
||||
.admin-link-button-primary {
|
||||
border-color: var(--admin-accent);
|
||||
color: #ffffff;
|
||||
background: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-button:hover,
|
||||
.admin-button:focus,
|
||||
.admin-link-button:hover,
|
||||
.admin-link-button:focus {
|
||||
border-color: var(--admin-accent-strong);
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 22px 0 34px;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-stat,
|
||||
.admin-panel {
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: var(--admin-panel);
|
||||
}
|
||||
|
||||
.admin-stat {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-stat strong {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-stat span {
|
||||
color: var(--admin-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.admin-panel + .admin-panel {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.admin-panel h2,
|
||||
.admin-panel h3 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.admin-panel h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-panel h3 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.admin-note {
|
||||
margin: 0 0 14px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-field label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-field input,
|
||||
.admin-field select,
|
||||
.admin-field textarea {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid #cfd6d2;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.admin-field textarea {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-field input:focus,
|
||||
.admin-field select:focus,
|
||||
.admin-field textarea:focus {
|
||||
border-color: var(--admin-accent);
|
||||
outline: 2px solid rgba(185, 75, 43, 0.18);
|
||||
}
|
||||
|
||||
.admin-output {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.admin-output textarea {
|
||||
width: 100%;
|
||||
min-height: 170px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--admin-soft);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-status-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.admin-status-list li {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--admin-soft);
|
||||
}
|
||||
|
||||
.admin-status-list strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-ok {
|
||||
color: var(--admin-good);
|
||||
}
|
||||
|
||||
.admin-missing {
|
||||
color: var(--admin-accent-strong);
|
||||
}
|
||||
|
||||
.admin-copy-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-path {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--admin-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.admin-grid,
|
||||
.admin-stats,
|
||||
.admin-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-shell">
|
||||
<header class="admin-header">
|
||||
<div class="admin-header-inner">
|
||||
<div class="admin-brand">
|
||||
<img src="/assets/icons/familyfed-logo.webp" alt="" />
|
||||
<div>
|
||||
<p>admin.familyfed.ie</p>
|
||||
<h1>FamilyFed.ie Admin</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="admin-actions" aria-label="Admin links">
|
||||
<a class="admin-link-button" href="/index.html">Public Site</a>
|
||||
<a class="admin-link-button" href="/events.html">Calendar</a>
|
||||
<a class="admin-link-button" href="/speeches/">Speeches</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
<section class="admin-stats" aria-label="Website overview">
|
||||
<div class="admin-stat">
|
||||
<strong>{calendarEvents.length + recurringCalendarEvents.length}</strong>
|
||||
<span>Calendar event definitions</span>
|
||||
</div>
|
||||
<div class="admin-stat">
|
||||
<strong>{speechEntries.length}</strong>
|
||||
<span>Speeches in content</span>
|
||||
</div>
|
||||
<div class="admin-stat">
|
||||
<strong>{blogEntries.length}</strong>
|
||||
<span>Blog and news posts</span>
|
||||
</div>
|
||||
<div class="admin-stat">
|
||||
<strong>{plausibleDomain || "Off"}</strong>
|
||||
<span>Visitor analytics</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-grid">
|
||||
<div>
|
||||
<section class="admin-panel" aria-labelledby="event-builder-title">
|
||||
<h2 id="event-builder-title">Add Calendar Event</h2>
|
||||
<p class="admin-note">Generate a valid event entry for <code>src/data/events.ts</code>. Add the snippet inside <code>calendarEvents</code>, then run the build.</p>
|
||||
<form class="admin-form" data-event-form>
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-field">
|
||||
<label for="event-title">Title</label>
|
||||
<input id="event-title" name="title" required placeholder="Community gathering" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="event-date">Date</label>
|
||||
<input id="event-date" name="date" type="date" value={today} required />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="event-start">Start time</label>
|
||||
<input id="event-start" name="startTime" type="time" value="11:00" required />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="event-end">End time</label>
|
||||
<input id="event-end" name="endTime" type="time" value="12:00" required />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="event-location">Location</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</label>
|
||||
<input id="event-url" name="url" placeholder="/events.html" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="event-details">Details</label>
|
||||
<textarea id="event-details" name="details" required></textarea>
|
||||
</div>
|
||||
<div class="admin-copy-row">
|
||||
<button class="admin-button admin-button-primary" type="submit">Generate Event</button>
|
||||
<button class="admin-button" type="button" data-copy="event-output">Copy Snippet</button>
|
||||
</div>
|
||||
<div class="admin-output">
|
||||
<textarea id="event-output" aria-label="Generated event snippet" readonly></textarea>
|
||||
<small class="admin-path" data-event-path></small>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" aria-labelledby="speech-builder-title">
|
||||
<h2 id="speech-builder-title">Add Speech</h2>
|
||||
<p class="admin-note">Generate a Markdown speech file for <code>content/speeches</code>. The generated path matches the archive structure used by the site.</p>
|
||||
<form class="admin-form" data-speech-form>
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-field">
|
||||
<label for="speech-title">Title</label>
|
||||
<input id="speech-title" name="title" required placeholder="Speech title" />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="speech-date">Published date</label>
|
||||
<input id="speech-date" name="publishedDate" type="date" value={today} required />
|
||||
</div>
|
||||
<div class="admin-field">
|
||||
<label for="speech-year">Speech year</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</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</label>
|
||||
<textarea id="speech-body" name="body" required placeholder="<p>Paste the speech text here.</p>"></textarea>
|
||||
</div>
|
||||
<div class="admin-copy-row">
|
||||
<button class="admin-button admin-button-primary" type="submit">Generate Speech File</button>
|
||||
<button class="admin-button" type="button" data-copy="speech-output">Copy Markdown</button>
|
||||
<a class="admin-link-button" data-download-speech href="#" download>Download .md</a>
|
||||
</div>
|
||||
<div class="admin-output">
|
||||
<textarea id="speech-output" aria-label="Generated speech markdown" readonly></textarea>
|
||||
<small class="admin-path" data-speech-path></small>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<section class="admin-panel" aria-labelledby="operations-title">
|
||||
<h2 id="operations-title">Operations</h2>
|
||||
<ul class="admin-status-list">
|
||||
<li>
|
||||
<strong>Contact form</strong>
|
||||
<span class={contactFormEndpoint ? "admin-ok" : "admin-missing"}>
|
||||
{contactFormEndpoint ? "Endpoint configured" : "Needs PUBLIC_CONTACT_FORM_ENDPOINT"}
|
||||
</span>
|
||||
{contactSubmissionsUrl && <a class="admin-path" href={contactSubmissionsUrl}>Open contact submissions</a>}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Visitor analytics</strong>
|
||||
<span class={plausibleDomain ? "admin-ok" : "admin-missing"}>
|
||||
{plausibleDomain ? `Tracking ${plausibleDomain}` : "Needs PUBLIC_PLAUSIBLE_DOMAIN"}
|
||||
</span>
|
||||
{analyticsDashboardUrl && <a class="admin-path" href={analyticsDashboardUrl}>Open analytics dashboard</a>}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Admin hosting</strong>
|
||||
<span>Serve <code>/admin/index.html</code> as the root for <code>admin.familyfed.ie</code> and require the Pocket ID group <code>familyfed_admin</code> at the proxy.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="admin-panel" aria-labelledby="quick-files-title">
|
||||
<h2 id="quick-files-title">Quick Files</h2>
|
||||
<ul class="admin-status-list">
|
||||
<li>
|
||||
<strong>Events data</strong>
|
||||
<span class="admin-path">src/data/events.ts</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Speech content</strong>
|
||||
<span class="admin-path">content/speeches/<year>/<slug>.md</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Admin notes</strong>
|
||||
<span class="admin-path">docs/ADMIN.md</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pocket ID auth</strong>
|
||||
<span class="admin-path">docs/POCKET-ID-AUTH.md</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script is:inline define:vars={{ adminConfig }}>
|
||||
(() => {
|
||||
const eventForm = document.querySelector("[data-event-form]");
|
||||
const speechForm = document.querySelector("[data-speech-form]");
|
||||
const speechDownload = document.querySelector("[data-download-speech]");
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
function generateEvent(event) {
|
||||
const id = `${slugify(event.title)}-${event.date}`;
|
||||
return [
|
||||
" {",
|
||||
` id: ${jsString(id)},`,
|
||||
` title: ${jsString(event.title)},`,
|
||||
` date: ${jsString(event.date)},`,
|
||||
` startTime: ${jsString(event.startTime)},`,
|
||||
` endTime: ${jsString(event.endTime)},`,
|
||||
` location: ${jsString(event.location)},`,
|
||||
` details: ${jsString(event.details)},`,
|
||||
event.url ? ` url: ${jsString(event.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 = `content/speeches/${collection}/${slug}.md`;
|
||||
const markdown = [
|
||||
"---",
|
||||
`title: ${yamlString(data.title)}`,
|
||||
'type: "speech"',
|
||||
`published: ${yamlString(published)}`,
|
||||
`updated: ${yamlString(published)}`,
|
||||
`speechYear: ${yamlString(data.speechYear)}`,
|
||||
"categories:",
|
||||
` - ${yamlString(category)}`,
|
||||
"tags: []",
|
||||
"---",
|
||||
"",
|
||||
data.body,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return { markdown, path, slug };
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-copy]").forEach((button) => {
|
||||
const originalText = button.textContent;
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
const target = document.getElementById(button.dataset.copy);
|
||||
if (!target || !target.value) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(target.value);
|
||||
button.textContent = "Copied";
|
||||
window.setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 1400);
|
||||
});
|
||||
});
|
||||
|
||||
eventForm?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const output = document.getElementById("event-output");
|
||||
const path = document.querySelector("[data-event-path]");
|
||||
const generated = generateEvent({
|
||||
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"),
|
||||
});
|
||||
|
||||
output.value = generated;
|
||||
path.textContent = "Paste into src/data/events.ts inside calendarEvents.";
|
||||
});
|
||||
|
||||
speechForm?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const output = document.getElementById("speech-output");
|
||||
const path = document.querySelector("[data-speech-path]");
|
||||
const generated = generateSpeech({
|
||||
title: formValue(speechForm, "title"),
|
||||
publishedDate: formValue(speechForm, "publishedDate"),
|
||||
speechYear: formValue(speechForm, "speechYear"),
|
||||
speaker: formValue(speechForm, "speaker"),
|
||||
body: formValue(speechForm, "body"),
|
||||
});
|
||||
|
||||
output.value = generated.markdown;
|
||||
path.textContent = generated.path;
|
||||
setDownload(speechDownload, `${generated.slug}.md`, generated.markdown);
|
||||
});
|
||||
|
||||
window.familyfedAdmin = adminConfig;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue