diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index cb508692..874737bf 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -10,6 +10,13 @@ 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 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 configuredServices = [contactFormEndpoint, plausibleDomain].filter(Boolean).length; const adminConfig = { analyticsDashboardUrl, contactEndpointConfigured: Boolean(contactFormEndpoint), @@ -29,30 +36,48 @@ const adminConfig = { @@ -340,163 +693,351 @@ const adminConfig = {
-
-
- {calendarEvents.length + recurringCalendarEvents.length} - Calendar event definitions -
-
- {speechEntries.length} - Speeches in content -
-
- {blogEntries.length} - Blog and news posts -
-
- {plausibleDomain || "Off"} - Visitor analytics + + +
+
+
+

Content operations

+

Manage updates with cleaner handoff files.

+

+ Build event snippets and speech files for the static site, then commit the generated content to the repository before deploying. +

+
+
+ + {configuredServices === 2 ? "Configured" : "Setup needed"} + + {configuredServices}/2 services ready + Contact form and analytics environment variables. +
+
+ +
+
+ Calendar definitions + {totalEvents} + {recurringCalendarEvents.length} recurring, {calendarEvents.length} dated +
+
+ Speeches in content + {speechEntries.length} + Generated from content/speeches +
+
+ Blog and news posts + {blogEntries.length} + Generated from content/blog +
+
+ Latest content + {latestEntryDate} + {latestEntryTitle} +
+
+ +
+
+
+
+ +

Content Tools

+
+
+
+
+ + + View Public Speeches +
+
+
+ +
+
+
+ +

Operations

+
+
+
+
    +
  • + Contact form + + {contactFormEndpoint ? "Endpoint configured" : "Needs PUBLIC_CONTACT_FORM_ENDPOINT"} + +
  • +
  • + Visitor analytics + + {plausibleDomain ? `Tracking ${plausibleDomain}` : "Needs PUBLIC_PLAUSIBLE_DOMAIN"} + +
  • +
+
+
+ +
+ Static admin note +

The dashboard can generate clean content files, but it cannot write to the repository from the browser.

+ +
-
-
-
-

Add Calendar Event

-

Generate a valid event entry for src/data/events.ts. Add the snippet inside calendarEvents, then run the build.

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ - -
+
+ + +
+
@@ -505,6 +1046,64 @@ const adminConfig = { const eventForm = document.querySelector("[data-event-form]"); const speechForm = document.querySelector("[data-speech-form]"); const speechDownload = document.querySelector("[data-download-speech]"); + const adminTabs = Array.from(document.querySelectorAll("[data-admin-tab]")); + const adminPanels = Array.from(document.querySelectorAll("[data-admin-panel]")); + + function activateTab(tabName, shouldFocus = false) { + 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; + }); + + if (shouldFocus) { + nextTab.focus(); + } + } + + adminTabs.forEach((tab, index) => { + tab.addEventListener("click", () => activateTab(tab.dataset.adminTab)); + tab.addEventListener("keydown", (event) => { + const currentIndex = adminTabs.indexOf(tab); + const lastIndex = adminTabs.length - 1; + let nextIndex = currentIndex; + + if (event.key === "ArrowRight") { + nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1; + } else if (event.key === "ArrowLeft") { + 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); + }); + + tab.tabIndex = index === 0 ? 0 : -1; + }); + + document.querySelectorAll("[data-admin-tab-link]").forEach((button) => { + button.addEventListener("click", () => { + activateTab(button.dataset.adminTabLink, true); + window.scrollTo({ top: 0, behavior: "smooth" }); + }); + }); function slugify(value) { return value @@ -528,6 +1127,32 @@ const adminConfig = { return String(new FormData(form).get(name) || "").trim(); } + 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; @@ -542,20 +1167,87 @@ const adminConfig = { link.href = url; link.download = fileName; link.dataset.objectUrl = url; + setDownloadEnabled(link, true); } - function generateEvent(event) { - const id = `${slugify(event.title)}-${event.date}`; + 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 (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 (!/^\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(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)},` : "", + ` 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"); } @@ -589,27 +1281,8 @@ const adminConfig = { 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({ + function getEventData() { + return { title: formValue(eventForm, "title"), date: formValue(eventForm, "date"), startTime: formValue(eventForm, "startTime"), @@ -617,29 +1290,123 @@ const adminConfig = { 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({ + function getSpeechData() { + return { title: formValue(speechForm, "title"), publishedDate: formValue(speechForm, "publishedDate"), speechYear: formValue(speechForm, "speechYear"), speaker: formValue(speechForm, "speaker"), body: formValue(speechForm, "body"), - }); + }; + } + function refreshEventOutput(showSuccess = false) { + 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 event fields to generate a snippet."; + setCopyEnabled("event-output", false); + setMessage(message, error, "error"); + return false; + } + + output.value = generateEvent(data); + path.textContent = "Paste into src/data/events.ts inside calendarEvents."; + setCopyEnabled("event-output", true); + setMessage(message, showSuccess ? "Snippet refreshed." : "Ready to copy.", "success"); + return true; + } + + function refreshSpeechOutput(showSuccess = false) { + 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 speech fields to generate a file."; + setCopyEnabled("speech-output", false); + resetDownload(speechDownload); + setMessage(message, error, "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 refreshed." : "Ready to copy or download.", "success"); + return true; + } + + document.querySelectorAll("[data-copy]").forEach((button) => { + const originalText = button.textContent; + + 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.textContent = originalText; + }, 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(); + } + }); + + eventForm?.addEventListener("input", () => refreshEventOutput(false)); + speechForm?.addEventListener("input", () => refreshSpeechOutput(false)); + + eventForm?.addEventListener("submit", (event) => { + event.preventDefault(); + refreshEventOutput(true); + }); + + speechForm?.addEventListener("submit", (event) => { + event.preventDefault(); + refreshSpeechOutput(true); + }); + + refreshEventOutput(false); + refreshSpeechOutput(false); window.familyfedAdmin = adminConfig; })();