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
14
.env.example
Normal file
14
.env.example
Normal 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=
|
||||||
|
|
@ -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
49
docs/ADMIN.md
Normal 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
75
docs/POCKET-ID-AUTH.md
Normal 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.
|
||||||
|
|
@ -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/.`);
|
||||||
|
|
|
||||||
|
|
@ -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(/–|–/g, "-")
|
.replace(/–|–/g, "-")
|
||||||
.replace(/‘|’|‘|’/g, "'")
|
.replace(/‘|’|‘|’/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
31
src/data/events.ts
Normal 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
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>
|
||||||
|
|
@ -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, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue