Add admin dashboard and Pocket ID auth notes
All checks were successful
/ deploy (push) Successful in 42s

This commit is contained in:
Loyyd 2026-06-13 09:34:28 +02:00
parent fc5762d267
commit 06093af633
10 changed files with 947 additions and 29 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# Hosted form endpoint used by the contact form.
# Examples: Formspree, Basin, Getform, or a self-hosted POST endpoint.
PUBLIC_CONTACT_FORM_ENDPOINT=
# Optional comma-separated fallback recipients used by the mailto fallback.
PUBLIC_CONTACT_RECIPIENTS=directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie
# Optional admin URL for reviewing stored contact form submissions.
PUBLIC_CONTACT_SUBMISSIONS_URL=
# Optional Plausible analytics configuration.
PUBLIC_PLAUSIBLE_DOMAIN=familyfed.ie
PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
PUBLIC_ANALYTICS_DASHBOARD_URL=

View file

@ -15,6 +15,12 @@ jobs:
env: env:
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }} NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }} NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }}
PUBLIC_ANALYTICS_DASHBOARD_URL: ${{ secrets.PUBLIC_ANALYTICS_DASHBOARD_URL }}
PUBLIC_CONTACT_FORM_ENDPOINT: ${{ secrets.PUBLIC_CONTACT_FORM_ENDPOINT }}
PUBLIC_CONTACT_RECIPIENTS: ${{ secrets.PUBLIC_CONTACT_RECIPIENTS }}
PUBLIC_CONTACT_SUBMISSIONS_URL: ${{ secrets.PUBLIC_CONTACT_SUBMISSIONS_URL }}
PUBLIC_PLAUSIBLE_DOMAIN: ${{ secrets.PUBLIC_PLAUSIBLE_DOMAIN }}
PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${{ secrets.PUBLIC_PLAUSIBLE_SCRIPT_SRC }}
REPO_URL: https://git.bcgen.ie/familyfedie/familyfedie-website.git REPO_URL: https://git.bcgen.ie/familyfedie/familyfedie-website.git
run: | run: |
set -eu set -eu
@ -35,6 +41,7 @@ jobs:
-c npm run build -c npm run build
test -f dist/index.html test -f dist/index.html
test -f dist/admin/index.html
test -f dist/speeches/index.html test -f dist/speeches/index.html
test -f dist/assets/icons/familyfed-favicon.png test -f dist/assets/icons/familyfed-favicon.png

49
docs/ADMIN.md Normal file
View file

@ -0,0 +1,49 @@
# FamilyFed.ie admin notes
The admin dashboard is generated at `/admin/index.html`. To make
`https://admin.familyfed.ie/` show the dashboard, configure the web server or
reverse proxy for that hostname to serve `dist/admin/index.html` as its root
document.
Protect `admin.familyfed.ie` at the web server, reverse proxy, or hosting layer
with Pocket ID authentication. Require the Pocket ID user group
`familyfed_admin`. The generated page has `noindex`, but a static page cannot
enforce a secure admin login by itself.
See `docs/POCKET-ID-AUTH.md` for the exact proxy/auth requirements.
## Content changes
- Calendar events live in `src/data/events.ts`.
- Speeches live in `content/speeches/<year>/<slug>.md`.
- The admin page includes builders that generate valid snippets/files for both.
Because this is a static Astro site, the browser cannot securely write content
back into the repository by itself. Commit generated content changes to the
repository and deploy as usual.
## Contact form
Set `PUBLIC_CONTACT_FORM_ENDPOINT` during the build to make the contact form
send real submissions through a hosted form provider such as Formspree, Basin,
Getform, or a self-hosted endpoint.
Optional variables:
- `PUBLIC_CONTACT_RECIPIENTS`: comma-separated mail fallback recipients.
- `PUBLIC_CONTACT_SUBMISSIONS_URL`: admin dashboard URL for form submissions.
If no endpoint is configured, the form falls back to opening the visitor's email
application with the message filled in.
For deployment, add the matching variables as Forgejo secrets. The deploy
workflow passes them into the Astro build.
## Analytics
Set `PUBLIC_PLAUSIBLE_DOMAIN=familyfed.ie` to load Plausible analytics on the
public site. If using a self-hosted Plausible instance, set
`PUBLIC_PLAUSIBLE_SCRIPT_SRC` to the script URL.
Set `PUBLIC_ANALYTICS_DASHBOARD_URL` to the analytics dashboard URL so the admin
page can link directly to visitor reports.

75
docs/POCKET-ID-AUTH.md Normal file
View file

@ -0,0 +1,75 @@
# Pocket ID auth for admin.familyfed.ie
`admin.familyfed.ie` must be protected before traffic reaches the static Astro
files. Pocket ID is an OIDC provider, so the auth check belongs in the reverse
proxy or auth middleware.
Required policy:
- Host: `admin.familyfed.ie`
- Required Pocket ID group: `familyfed_admin`
- Static upstream/root: `dist/admin/index.html`
## Recommended Tinyauth setup
Pocket ID's proxy guide points to Tinyauth for reverse-proxy protection, and
Tinyauth supports Pocket ID groups through the `oauth.groups` app label.
Create a Pocket ID OIDC client:
- Name: `FamilyFed Admin`
- Callback URL: `https://auth.familyfed.ie/api/oauth/callback/pocketid`
- Scopes: `openid email profile groups`
Configure Tinyauth with the Pocket ID client:
```yaml
environment:
TINYAUTH_OAUTH_AUTOREDIRECT: pocketid
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTID: "<pocket-id-client-id>"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTSECRET: "<pocket-id-client-secret>"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_AUTHURL: "https://pocket-id.familyfed.ie/authorize"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_TOKENURL: "https://pocket-id.familyfed.ie/api/oidc/token"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_USERINFOURL: "https://pocket-id.familyfed.ie/api/oidc/userinfo"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_REDIRECTURL: "https://auth.familyfed.ie/api/oauth/callback/pocketid"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_SCOPES: "openid email profile groups"
TINYAUTH_OAUTH_PROVIDERS_POCKETID_NAME: "Pocket ID"
```
Add app access labels for the admin host:
```yaml
labels:
tinyauth.apps.familyfed-admin.config.domain: "admin.familyfed.ie"
tinyauth.apps.familyfed-admin.oauth.groups: "familyfed_admin"
```
Users outside `familyfed_admin` should receive the unauthorized page from the
auth middleware and never reach the static admin HTML.
## Caddy with caddy-security
If the live proxy is Caddy with `caddy-security`, create a Pocket ID OIDC client
with this callback URL:
```text
https://admin.familyfed.ie/caddy-security/oauth2/generic/authorization-code-callback
```
Configure the authorization policy to allow only users whose OIDC groups claim
contains `familyfed_admin`, then serve or reverse-proxy the static admin output.
Pocket ID's own guide documents the Caddy callback shape and OIDC provider
settings; the group condition must be added in the Caddy authorization policy.
## Deployment checks
The site build now verifies that `dist/admin/index.html` exists. That confirms
the static admin page is available for the host, but it does not prove the
external proxy has enabled Pocket ID. Verify live protection with:
```bash
curl -I https://admin.familyfed.ie/
```
Expected unauthenticated behavior is a redirect to the Pocket ID/Tinyauth login
or a `401`/`403` response from the auth middleware.

View file

@ -17,10 +17,17 @@ for (const dir of staticDirs) {
const speechIndexSource = path.join(distRoot, "speeches.html"); const speechIndexSource = path.join(distRoot, "speeches.html");
const speechIndexDestination = path.join(distRoot, "speeches", "index.html"); const speechIndexDestination = path.join(distRoot, "speeches", "index.html");
const adminIndexSource = path.join(distRoot, "admin.html");
const adminIndexDestination = path.join(distRoot, "admin", "index.html");
if (fs.existsSync(speechIndexSource)) { if (fs.existsSync(speechIndexSource)) {
fs.mkdirSync(path.dirname(speechIndexDestination), { recursive: true }); fs.mkdirSync(path.dirname(speechIndexDestination), { recursive: true });
fs.copyFileSync(speechIndexSource, speechIndexDestination); fs.copyFileSync(speechIndexSource, speechIndexDestination);
} }
if (fs.existsSync(adminIndexSource)) {
fs.mkdirSync(path.dirname(adminIndexDestination), { recursive: true });
fs.copyFileSync(adminIndexSource, adminIndexDestination);
}
console.log(`Copied ${staticDirs.join(", ")} into dist/.`); console.log(`Copied ${staticDirs.join(", ")} into dist/.`);

View file

@ -18,6 +18,8 @@ const siteOrigin = Astro.site?.origin ?? "https://familyfed.ie";
const absoluteUrl = (value) => value ? new URL(value, siteOrigin).toString() : undefined; const absoluteUrl = (value) => value ? new URL(value, siteOrigin).toString() : undefined;
const canonicalUrl = absoluteUrl(canonical ?? pathname); const canonicalUrl = absoluteUrl(canonical ?? pathname);
const imageUrl = absoluteUrl(image); const imageUrl = absoluteUrl(image);
const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN ?? "";
const plausibleScriptSrc = import.meta.env.PUBLIC_PLAUSIBLE_SCRIPT_SRC ?? "https://plausible.io/js/script.js";
const metaTitle = title const metaTitle = title
.replace(/&#8211;|&ndash;/g, "-") .replace(/&#8211;|&ndash;/g, "-")
.replace(/&#8216;|&#8217;|&lsquo;|&rsquo;/g, "'") .replace(/&#8216;|&#8217;|&lsquo;|&rsquo;/g, "'")
@ -49,6 +51,7 @@ const metaTitle = title
<link rel="stylesheet" id="familyfedie-theme-css" href="/css/theme.css" type="text/css" media="all" /> <link rel="stylesheet" id="familyfedie-theme-css" href="/css/theme.css" type="text/css" media="all" />
<link rel="stylesheet" id="familyfedie-site-css" href="/css/site.css" type="text/css" media="all" /> <link rel="stylesheet" id="familyfedie-site-css" href="/css/site.css" type="text/css" media="all" />
<link rel="stylesheet" id="parabola-mobile-css" href="/assets/theme/parabola/styles/style-mobile.css?ver=2.4.1" type="text/css" media="all" /> <link rel="stylesheet" id="parabola-mobile-css" href="/assets/theme/parabola/styles/style-mobile.css?ver=2.4.1" type="text/css" media="all" />
{plausibleDomain && <script defer data-domain={plausibleDomain} src={plausibleScriptSrc}></script>}
<script is:inline type="text/javascript" src="/js/jquery.min.js?ver=2.0.2" id="jquery-js"></script> <script is:inline type="text/javascript" src="/js/jquery.min.js?ver=2.0.2" id="jquery-js"></script>
<script is:inline type="text/javascript" id="parabola-frontend-js-extra" set:html={parabolaSettingsScript}></script> <script is:inline type="text/javascript" id="parabola-frontend-js-extra" set:html={parabolaSettingsScript}></script>
<script is:inline type="text/javascript" src="/assets/theme/parabola/js/frontend.js?ver=2.4.1" id="parabola-frontend-js"></script> <script is:inline type="text/javascript" src="/assets/theme/parabola/js/frontend.js?ver=2.4.1" id="parabola-frontend-js"></script>

31
src/data/events.ts Normal file
View file

@ -0,0 +1,31 @@
export type CalendarEvent = {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string;
details: string;
url?: string;
};
export type RecurringCalendarEvent = Omit<CalendarEvent, "date"> & {
weekday: number;
startDate?: string;
endDate?: string;
};
export const recurringCalendarEvents: RecurringCalendarEvent[] = [
{
id: "sunday-service",
title: "Sunday Service",
startTime: "11:00",
endTime: "12:00",
location: "19 North Great Georges St., Dublin 1, Ireland",
details: "Weekly Sunday Service video upload and community worship.",
url: "/services.html",
weekday: 0,
},
];
export const calendarEvents: CalendarEvent[] = [];

647
src/pages/admin/index.astro Normal file
View 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/&lt;year&gt;/&lt;slug&gt;.md</span>
</li>
<li>
<strong>Admin notes</strong>
<span class="admin-path">docs/ADMIN.md</span>
</li>
<li>
<strong>Pocket ID auth</strong>
<span class="admin-path">docs/POCKET-ID-AUTH.md</span>
</li>
</ul>
</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>

View file

@ -2,6 +2,29 @@
import SiteLayout from "../components/SiteLayout.astro"; import SiteLayout from "../components/SiteLayout.astro";
import TwoColumnPage from "../components/TwoColumnPage.astro"; import TwoColumnPage from "../components/TwoColumnPage.astro";
import articleHtml from "../content/pages/contact.html?raw"; import articleHtml from "../content/pages/contact.html?raw";
const contactFormEndpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? "";
const contactRecipients = import.meta.env.PUBLIC_CONTACT_RECIPIENTS ?? "directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie";
const contactFormAction = contactFormEndpoint || `mailto:${contactRecipients}`;
const contactFormConfig = JSON.stringify({
endpoint: contactFormEndpoint,
recipients: contactRecipients,
});
const escapeHtmlAttribute = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const contactArticleHtml = articleHtml
.replace(
'action="mailto:directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie"',
`action="${escapeHtmlAttribute(contactFormAction)}"`,
)
.replace(
'method="post" enctype="text/plain"',
contactFormEndpoint ? 'method="post"' : 'method="post" enctype="text/plain"',
);
--- ---
<SiteLayout <SiteLayout
@ -11,24 +34,18 @@ import articleHtml from "../content/pages/contact.html?raw";
pathname="/contact.html" pathname="/contact.html"
canonical="/contact.html" canonical="/contact.html"
> >
<TwoColumnPage articleHtml={articleHtml} /> <TwoColumnPage articleHtml={contactArticleHtml} />
<script is:inline slot="scripts"> <script is:inline slot="scripts" define:vars={{ contactFormConfig }}>
(() => { (() => {
const form = document.getElementById("contact-form"); const form = document.getElementById("contact-form");
const status = document.getElementById("contact-form-status"); const status = document.getElementById("contact-form-status");
const config = JSON.parse(contactFormConfig);
if (!form) { if (!form) {
return; return;
} }
form.addEventListener("submit", (event) => { function contactMessage() {
event.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const formData = new FormData(form); const formData = new FormData(form);
const name = String(formData.get("name") || "").trim(); const name = String(formData.get("name") || "").trim();
const email = String(formData.get("email") || "").trim(); const email = String(formData.get("email") || "").trim();
@ -43,11 +60,71 @@ import articleHtml from "../content/pages/contact.html?raw";
message, message,
].join("\n"); ].join("\n");
if (status) { return { body, email, formData, name, subject, topic };
status.textContent = "Opening your email app with the message ready to send."; }
function openEmailFallback(message) {
window.location.href = `mailto:${config.recipients}?subject=${encodeURIComponent(message.subject)}&body=${encodeURIComponent(message.body)}`;
}
if (config.endpoint) {
form.action = config.endpoint;
form.method = "post";
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
} }
window.location.href = `mailto:directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; const message = contactMessage();
if (!config.endpoint) {
if (status) {
status.textContent = "Opening your email app with the message ready to send.";
}
openEmailFallback(message);
return;
}
if (status) {
status.textContent = "Sending your message...";
}
message.formData.set("_subject", message.subject);
message.formData.set("_replyto", message.email);
message.formData.set("site", "familyfed.ie");
try {
const response = await fetch(config.endpoint, {
method: "POST",
headers: {
Accept: "application/json",
},
body: message.formData,
});
if (!response.ok) {
throw new Error(`Contact form failed with ${response.status}`);
}
form.reset();
if (status) {
status.textContent = "Thank you. Your message has been sent.";
}
} catch (error) {
console.error(error);
if (status) {
status.textContent = "We could not send the form automatically, so your email app is opening instead.";
}
openEmailFallback(message);
}
}); });
})(); })();
</script> </script>

View file

@ -1,6 +1,7 @@
--- ---
import SiteLayout from "../components/SiteLayout.astro"; import SiteLayout from "../components/SiteLayout.astro";
import mainHtml from "../content/pages/events.html?raw"; import mainHtml from "../content/pages/events.html?raw";
import { calendarEvents, recurringCalendarEvents } from "../data/events";
--- ---
<SiteLayout <SiteLayout
@ -11,7 +12,7 @@ import mainHtml from "../content/pages/events.html?raw";
canonical="/events.html" canonical="/events.html"
> >
<Fragment set:html={mainHtml} /> <Fragment set:html={mainHtml} />
<script is:inline slot="scripts"> <script is:inline slot="scripts" define:vars={{ calendarEvents, recurringCalendarEvents }}>
(() => { (() => {
const calendar = document.querySelector("[data-calendar]"); const calendar = document.querySelector("[data-calendar]");
@ -19,18 +20,8 @@ import mainHtml from "../content/pages/events.html?raw";
return; return;
} }
const recurringEvents = [ const datedEvents = calendarEvents;
{ const recurringEvents = recurringCalendarEvents;
id: "sunday-service",
title: "Sunday Service",
startTime: "11:00",
endTime: "12:00",
location: "19 North Great Georges St., Dublin 1, Ireland",
details: "Weekly Sunday Service video upload and community worship.",
url: "/services.html",
weekday: 0,
},
];
const monthTitle = calendar.querySelector("[data-calendar-panel-title]"); const monthTitle = calendar.querySelector("[data-calendar-panel-title]");
const grid = calendar.querySelector("[data-calendar-grid]"); const grid = calendar.querySelector("[data-calendar-grid]");
@ -81,6 +72,14 @@ import mainHtml from "../content/pages/events.html?raw";
function eventsBetween(start, end) { function eventsBetween(start, end) {
const events = []; const events = [];
for (const datedEvent of datedEvents) {
const eventDate = fromDateKey(datedEvent.date);
if (eventDate >= start && eventDate <= end) {
events.push(datedEvent);
}
}
for (let date = new Date(start); date <= end; date = addDays(date, 1)) { for (let date = new Date(start); date <= end; date = addDays(date, 1)) {
for (const recurringEvent of recurringEvents) { for (const recurringEvent of recurringEvents) {
if (date.getDay() !== recurringEvent.weekday) { if (date.getDay() !== recurringEvent.weekday) {
@ -88,6 +87,15 @@ import mainHtml from "../content/pages/events.html?raw";
} }
const dateKey = toDateKey(date); const dateKey = toDateKey(date);
if (recurringEvent.startDate && dateKey < recurringEvent.startDate) {
continue;
}
if (recurringEvent.endDate && dateKey > recurringEvent.endDate) {
continue;
}
events.push({ events.push({
...recurringEvent, ...recurringEvent,
id: `${recurringEvent.id}-${dateKey}`, id: `${recurringEvent.id}-${dateKey}`,
@ -96,7 +104,7 @@ import mainHtml from "../content/pages/events.html?raw";
} }
} }
return events; return events.sort((a, b) => `${a.date} ${a.startTime} ${a.title}`.localeCompare(`${b.date} ${b.startTime} ${b.title}`));
} }
function formatEventTime(event) { function formatEventTime(event) {
@ -216,8 +224,8 @@ import mainHtml from "../content/pages/events.html?raw";
details.textContent = event.details; details.textContent = event.details;
const link = document.createElement("a"); const link = document.createElement("a");
link.href = event.url; link.href = event.url || "/events.html";
link.textContent = "Sunday Services"; link.textContent = event.url ? "More details" : "Events";
item.append(date, heading, time, location, details, link); item.append(date, heading, time, location, details, link);
list.append(item); list.append(item);