Admin-driven front page with event calendar #7
25 changed files with 2299 additions and 494 deletions
|
|
@ -249,6 +249,12 @@ export function normalizeId(entity, id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRow(db, entity, payload) {
|
export function createRow(db, entity, payload) {
|
||||||
|
// A singleton's one row comes from its migration. There is no
|
||||||
|
// second one to create, and the CHECK on its id would refuse it.
|
||||||
|
if (entity.singleton) {
|
||||||
|
throw new HttpError(405, "There is only one of these; edit it instead.");
|
||||||
|
}
|
||||||
|
|
||||||
// idKind "auto": the table assigns the id, so there is nothing to
|
// idKind "auto": the table assigns the id, so there is nothing to
|
||||||
// validate, nothing to check for collisions, and nothing for the
|
// validate, nothing to check for collisions, and nothing for the
|
||||||
// client to have sent. Timeline entries use this — they have no
|
// client to have sent. Timeline entries use this — they have no
|
||||||
|
|
@ -366,6 +372,12 @@ export function updateRow(db, entity, rawId, payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRow(db, entity, rawId) {
|
export function deleteRow(db, entity, rawId) {
|
||||||
|
// Deleting a singleton would leave the page it drives with nothing
|
||||||
|
// to read, and the admin with no way to make another.
|
||||||
|
if (entity.singleton) {
|
||||||
|
throw new HttpError(405, "This can't be deleted, only edited.");
|
||||||
|
}
|
||||||
|
|
||||||
const id = normalizeId(entity, rawId);
|
const id = normalizeId(entity, rawId);
|
||||||
const result = wrapDbErrors(() =>
|
const result = wrapDbErrors(() =>
|
||||||
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
db.prepare(`DELETE FROM ${entity.table} WHERE ${entity.idColumn} = ?`).run(id),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
value (organizations.kind decides whether a
|
value (organizations.kind decides whether a
|
||||||
regions or chapters row should exist)
|
regions or chapters row should exist)
|
||||||
children ordered collections, replaced wholesale on save
|
children ordered collections, replaced wholesale on save
|
||||||
|
singleton the one id this entity ever has; the engine
|
||||||
|
refuses create and delete (see front_page)
|
||||||
|
|
||||||
Replacing children wholesale is only safe because nothing has a
|
Replacing children wholesale is only safe because nothing has a
|
||||||
foreign key INTO these tables. That is the dividing line, and
|
foreign key INTO these tables. That is the dividing line, and
|
||||||
|
|
@ -650,7 +652,133 @@ const timeline = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ENTITIES = { organizations, events, people, teams, awards, timeline };
|
/* ── Front page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A singleton: one row, id 'home', created by migration 017 and
|
||||||
|
never by the admin. `singleton` tells the engine to refuse create
|
||||||
|
and delete, and the CHECK on front_page.id is what makes a second
|
||||||
|
row impossible even without it.
|
||||||
|
|
||||||
|
Every collection here is owned by page_id and replaced wholesale.
|
||||||
|
That is safe for the same reason it is for links and blocks —
|
||||||
|
nothing has a foreign key into these tables — and paths carry
|
||||||
|
their actions as a nested collection, the shape content blocks
|
||||||
|
and their items already use. */
|
||||||
|
|
||||||
|
const frontPage = {
|
||||||
|
key: "front_page",
|
||||||
|
table: "front_page",
|
||||||
|
idColumn: "id",
|
||||||
|
idKind: "slug",
|
||||||
|
singleton: "home",
|
||||||
|
concurrency: "updated_at",
|
||||||
|
|
||||||
|
list: {
|
||||||
|
columns: ["id", "headline", "hero_mode", "updated_at"],
|
||||||
|
filters: [],
|
||||||
|
search: [],
|
||||||
|
order: "id",
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
enumeration("hero_mode", ["brand", "photos", "livestream"]),
|
||||||
|
text("eyebrow"),
|
||||||
|
text("headline"),
|
||||||
|
text("subhead"),
|
||||||
|
text("primary_label"),
|
||||||
|
text("primary_url"),
|
||||||
|
text("secondary_label"),
|
||||||
|
text("secondary_url"),
|
||||||
|
int("slide_seconds"),
|
||||||
|
text("livestream_url"),
|
||||||
|
text("livestream_title"),
|
||||||
|
text("countdown_event_id"),
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "slides",
|
||||||
|
table: "front_page_slides",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("media", { required: true }),
|
||||||
|
text("alt"),
|
||||||
|
text("caption"),
|
||||||
|
text("link_url"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sections",
|
||||||
|
table: "front_page_sections",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
enumeration(
|
||||||
|
"section",
|
||||||
|
["countdown", "retreats", "stats", "timeline", "connect"],
|
||||||
|
{ required: true },
|
||||||
|
),
|
||||||
|
text("title"),
|
||||||
|
text("blurb"),
|
||||||
|
bool("is_hidden"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stats",
|
||||||
|
table: "front_page_stats",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("label", { required: true }),
|
||||||
|
enumeration("source", [
|
||||||
|
"manual",
|
||||||
|
"years_since",
|
||||||
|
"regions",
|
||||||
|
"chapters",
|
||||||
|
"partners",
|
||||||
|
"events_held",
|
||||||
|
"retreats_held",
|
||||||
|
"people",
|
||||||
|
"awards_given",
|
||||||
|
]),
|
||||||
|
text("value"),
|
||||||
|
text("suffix"),
|
||||||
|
text("note"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paths",
|
||||||
|
table: "front_page_paths",
|
||||||
|
owner: { column: "page_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [text("label", { required: true }), text("icon"), text("blurb")],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
table: "front_page_path_actions",
|
||||||
|
owner: { column: "path_id" },
|
||||||
|
order: "sort_order",
|
||||||
|
columns: [
|
||||||
|
text("label", { required: true }),
|
||||||
|
text("description"),
|
||||||
|
text("url", { required: true }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENTITIES = {
|
||||||
|
organizations,
|
||||||
|
events,
|
||||||
|
people,
|
||||||
|
teams,
|
||||||
|
awards,
|
||||||
|
timeline,
|
||||||
|
front_page: frontPage,
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Options for the form's select inputs ────────────────────── */
|
/* ── Options for the form's select inputs ────────────────────── */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { rateLimit } from "./rateLimit.js";
|
||||||
import content from "./routes/content.js";
|
import content from "./routes/content.js";
|
||||||
import people from "./routes/people.js";
|
import people from "./routes/people.js";
|
||||||
import history from "./routes/history.js";
|
import history from "./routes/history.js";
|
||||||
|
import home from "./routes/home.js";
|
||||||
import feedback from "./routes/feedback.js";
|
import feedback from "./routes/feedback.js";
|
||||||
import auth from "./routes/auth.js";
|
import auth from "./routes/auth.js";
|
||||||
import admin from "./routes/admin.js";
|
import admin from "./routes/admin.js";
|
||||||
|
|
@ -56,6 +57,7 @@ app.get("/api/health", (c) =>
|
||||||
app.route("/api", content);
|
app.route("/api", content);
|
||||||
app.route("/api", people);
|
app.route("/api", people);
|
||||||
app.route("/api", history);
|
app.route("/api", history);
|
||||||
|
app.route("/api", home);
|
||||||
|
|
||||||
// Tighter limit on the write path than anything else gets.
|
// Tighter limit on the write path than anything else gets.
|
||||||
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
app.use("/api/feedback", rateLimit({ windowMs: 60_000, max: 5 }));
|
||||||
|
|
|
||||||
189
server/src/migrations/017_front_page.sql
Normal file
189
server/src/migrations/017_front_page.sql
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- FRONT PAGE
|
||||||
|
--
|
||||||
|
-- The home page's editable half. One row in front_page — the CHECK
|
||||||
|
-- on id makes a second one impossible — and ordered collections
|
||||||
|
-- hanging off it, each replaced wholesale on save the way every
|
||||||
|
-- other child collection is. Nothing outside this file has a
|
||||||
|
-- foreign key into any of them, which is what makes that safe.
|
||||||
|
--
|
||||||
|
-- front_page the hero: its words, its buttons, and
|
||||||
|
-- which mode it's in
|
||||||
|
-- front_page_slides photos the hero cycles through in
|
||||||
|
-- 'photos' mode
|
||||||
|
-- front_page_sections which bands the page draws, in what
|
||||||
|
-- order, under what heading
|
||||||
|
-- front_page_stats the numbers band; each one typed in or
|
||||||
|
-- counted from the database
|
||||||
|
-- front_page_paths the connect section's "I want to…"
|
||||||
|
-- choices, each with its actions
|
||||||
|
-- front_page_path_actions
|
||||||
|
--
|
||||||
|
-- What stays in code: how each section looks, and the list of
|
||||||
|
-- section keys. A section is a component, so the CHECK on
|
||||||
|
-- front_page_sections.section is the list of components that
|
||||||
|
-- exist; a row can reorder, retitle or hide one, never invent one.
|
||||||
|
--
|
||||||
|
-- hero_mode is switched by hand. 'livestream' shows the embed with
|
||||||
|
-- a LIVE badge until someone switches it back — no schedule, so no
|
||||||
|
-- guessing whose timezone a start time was typed in.
|
||||||
|
--
|
||||||
|
-- countdown_event_id pins the countdown to one event. Null counts
|
||||||
|
-- down to the next upcoming published event, which is what it
|
||||||
|
-- should do almost always.
|
||||||
|
--
|
||||||
|
-- Stats: source says where the number comes from. 'manual' prints
|
||||||
|
-- value as typed. 'years_since' reads value as a year and counts up
|
||||||
|
-- from it. Everything else is a COUNT the API runs, so the band
|
||||||
|
-- never goes stale. Adding a source is this CHECK, the enum in both
|
||||||
|
-- descriptor halves, and the query in routes/home.js.
|
||||||
|
--
|
||||||
|
-- The seed is the page as it ships: every section, the stats that
|
||||||
|
-- need no typing, and the Church Center forms that were hardcoded
|
||||||
|
-- on the old home page, sorted into paths.
|
||||||
|
--
|
||||||
|
-- The updated_at trigger is in 018, on its own, so no statement
|
||||||
|
-- here sits after a BEGIN...END body.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TABLE front_page (
|
||||||
|
id TEXT PRIMARY KEY CHECK (id = 'home'),
|
||||||
|
|
||||||
|
hero_mode TEXT NOT NULL DEFAULT 'brand'
|
||||||
|
CHECK (hero_mode IN ('brand', 'photos', 'livestream')),
|
||||||
|
eyebrow TEXT,
|
||||||
|
headline TEXT NOT NULL DEFAULT 'Next Generation of Unity',
|
||||||
|
subhead TEXT,
|
||||||
|
primary_label TEXT,
|
||||||
|
primary_url TEXT,
|
||||||
|
secondary_label TEXT,
|
||||||
|
secondary_url TEXT,
|
||||||
|
|
||||||
|
slide_seconds INTEGER NOT NULL DEFAULT 7
|
||||||
|
CHECK (slide_seconds BETWEEN 3 AND 60),
|
||||||
|
|
||||||
|
livestream_url TEXT,
|
||||||
|
livestream_title TEXT,
|
||||||
|
|
||||||
|
countdown_event_id TEXT REFERENCES events (id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE front_page_slides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
media TEXT NOT NULL, -- filename in public/front-page/, or a URL
|
||||||
|
alt TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
link_url TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE front_page_sections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
section TEXT NOT NULL
|
||||||
|
CHECK (section IN ('countdown', 'retreats', 'stats', 'timeline', 'connect')),
|
||||||
|
title TEXT, -- null → the section's own heading
|
||||||
|
blurb TEXT,
|
||||||
|
-- Hidden rather than visible, so a freshly added row with nothing
|
||||||
|
-- ticked is still a blank row the engine can drop.
|
||||||
|
is_hidden INTEGER NOT NULL DEFAULT 0 CHECK (is_hidden IN (0, 1)),
|
||||||
|
UNIQUE (page_id, section)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE front_page_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual'
|
||||||
|
CHECK (source IN ('manual', 'years_since', 'regions', 'chapters',
|
||||||
|
'partners', 'events_held', 'retreats_held',
|
||||||
|
'people', 'awards_given')),
|
||||||
|
value TEXT,
|
||||||
|
suffix TEXT, -- '+', 'k', ' states'
|
||||||
|
note TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE front_page_paths (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id TEXT NOT NULL REFERENCES front_page (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL, -- 'Attend'
|
||||||
|
icon TEXT, -- one emoji
|
||||||
|
blurb TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE front_page_path_actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path_id INTEGER NOT NULL REFERENCES front_page_paths (id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
url TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE INDEX front_page_path_actions_path_idx ON front_page_path_actions (path_id, sort_order);
|
||||||
|
|
||||||
|
-- ── Seed ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT INTO front_page
|
||||||
|
(id, eyebrow, headline, subhead,
|
||||||
|
primary_label, primary_url, secondary_label, secondary_url)
|
||||||
|
VALUES
|
||||||
|
('home',
|
||||||
|
'Young adults of the Unity movement',
|
||||||
|
'Next Generation of Unity',
|
||||||
|
'A community for 18–40 year olds, rooted in spiritual growth, leadership and sacred service.',
|
||||||
|
'Find a retreat', '/retreats',
|
||||||
|
'Find your way in', '#connect');
|
||||||
|
|
||||||
|
INSERT INTO front_page_sections (page_id, sort_order, section, title, blurb) VALUES
|
||||||
|
('home', 0, 'countdown', NULL, NULL),
|
||||||
|
('home', 1, 'retreats', 'National Retreats', 'Our flagship gatherings, open to young adults across the country.'),
|
||||||
|
('home', 2, 'stats', 'NGU by the numbers', NULL),
|
||||||
|
('home', 3, 'timeline', 'Moments that shaped us', 'Highlights from our history.'),
|
||||||
|
('home', 4, 'connect', 'Find your way in', 'Tell us what you''re looking for.');
|
||||||
|
|
||||||
|
INSERT INTO front_page_stats (page_id, sort_order, label, source) VALUES
|
||||||
|
('home', 0, 'Regions', 'regions'),
|
||||||
|
('home', 1, 'Chapters', 'chapters'),
|
||||||
|
('home', 2, 'Retreats held', 'retreats_held'),
|
||||||
|
('home', 3, 'Awards given', 'awards_given');
|
||||||
|
|
||||||
|
INSERT INTO front_page_paths (page_id, sort_order, label, icon, blurb) VALUES
|
||||||
|
('home', 0, 'Attend', '🧭', 'Come to a gathering near you or across the country.'),
|
||||||
|
('home', 1, 'Serve', '🤲', 'Help create transformative experiences for young adults.'),
|
||||||
|
('home', 2, 'Belong', '🌱', 'Make NGU your community.'),
|
||||||
|
('home', 3, 'Partner', '🤝', 'Bring your ministry or organization alongside us.');
|
||||||
|
|
||||||
|
INSERT INTO front_page_path_actions (path_id, sort_order, label, description, url)
|
||||||
|
SELECT p.id, a.sort_order, a.label, a.description, a.url
|
||||||
|
FROM front_page_paths p
|
||||||
|
JOIN (
|
||||||
|
SELECT 'Attend' AS path, 0 AS sort_order, 'See upcoming retreats' AS label,
|
||||||
|
'National, regional and partner gatherings.' AS description,
|
||||||
|
'/retreats' AS url
|
||||||
|
UNION ALL SELECT 'Attend', 1, 'NGU calendar',
|
||||||
|
'Everything on the schedule, in one place.',
|
||||||
|
'https://ngu.churchcenter.com/calendar?view=gallery'
|
||||||
|
UNION ALL SELECT 'Serve', 0, 'Volunteer',
|
||||||
|
'Lend a hand at a retreat or event.',
|
||||||
|
'https://ngu.churchcenter.com/people/forms/1176908'
|
||||||
|
UNION ALL SELECT 'Serve', 1, 'Speaker & Musician Directory',
|
||||||
|
'Join our network of speakers, musicians and facilitators.',
|
||||||
|
'https://ngu.churchcenter.com/people/forms/1173181'
|
||||||
|
UNION ALL SELECT 'Belong', 0, 'Become a member',
|
||||||
|
'Join the NGU community officially.',
|
||||||
|
'https://ngu.churchcenter.com/people/forms/1135816'
|
||||||
|
UNION ALL SELECT 'Belong', 1, 'Find your region',
|
||||||
|
'Chapters and regions across the country.',
|
||||||
|
'/community'
|
||||||
|
UNION ALL SELECT 'Partner', 0, 'Affiliation form',
|
||||||
|
'Affiliate your ministry or spiritual organization with NGU.',
|
||||||
|
'https://ngu.churchcenter.com/people/forms/1135750'
|
||||||
|
) a ON a.path = p.label
|
||||||
|
WHERE p.page_id = 'home';
|
||||||
16
server/src/migrations/018_front_page_touch.sql
Normal file
16
server/src/migrations/018_front_page_touch.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- FRONT PAGE updated_at
|
||||||
|
--
|
||||||
|
-- Same rule as the other touch triggers in 002: an UPDATE that
|
||||||
|
-- doesn't set updated_at itself gets it set, which is what the
|
||||||
|
-- admin engine's optimistic concurrency compares against. On its
|
||||||
|
-- own because the migration runner may drop anything that follows
|
||||||
|
-- a BEGIN...END body.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
CREATE TRIGGER front_page_touch
|
||||||
|
AFTER UPDATE ON front_page
|
||||||
|
FOR EACH ROW WHEN new.updated_at = old.updated_at
|
||||||
|
BEGIN
|
||||||
|
UPDATE front_page SET updated_at = datetime('now') WHERE id = new.id;
|
||||||
|
END;
|
||||||
|
|
@ -47,7 +47,14 @@
|
||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import { asBool, loadBlocks, loadLinks, paragraphs, splitLinks } from "../shape.js";
|
import {
|
||||||
|
asBool,
|
||||||
|
loadBlocks,
|
||||||
|
loadLinks,
|
||||||
|
paragraphs,
|
||||||
|
shapeSeries,
|
||||||
|
splitLinks,
|
||||||
|
} from "../shape.js";
|
||||||
|
|
||||||
const content = new Hono();
|
const content = new Hono();
|
||||||
|
|
||||||
|
|
@ -108,25 +115,6 @@ function shapeHost(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The repeating schedule, or null for a one-off. Weekdays collapse
|
|
||||||
from seven flags to a list of the ticked ones, Sunday first; an
|
|
||||||
empty list means "starts_on's weekday", which the client resolves
|
|
||||||
since it already holds starts_on. Occurrences are not sent — they
|
|
||||||
are derived, and the client derives them against its own today. */
|
|
||||||
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
||||||
|
|
||||||
function shapeSeries(row) {
|
|
||||||
if (!asBool(row.is_series)) return null;
|
|
||||||
return {
|
|
||||||
frequency: row.series_frequency,
|
|
||||||
interval: row.series_interval,
|
|
||||||
weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])),
|
|
||||||
start_time: row.series_start_time,
|
|
||||||
end_time: row.series_end_time,
|
|
||||||
count: row.series_count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shapeEvent(row, links, cardBlocks, hosts = []) {
|
function shapeEvent(row, links, cardBlocks, hosts = []) {
|
||||||
const { actions, instagram } = splitLinks(links);
|
const { actions, instagram } = splitLinks(links);
|
||||||
|
|
||||||
|
|
|
||||||
186
server/src/routes/home.js
Normal file
186
server/src/routes/home.js
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FRONT PAGE ROUTE — read-only, mounted under /api
|
||||||
|
|
||||||
|
GET /front-page the home page's configuration, resolved
|
||||||
|
|
||||||
|
Everything the admin's Front page editor holds, shaped for the
|
||||||
|
page: hidden sections dropped, stats counted, paths carrying
|
||||||
|
their actions, and the countdown's event looked up.
|
||||||
|
|
||||||
|
The retreats carousel and the timeline rail are not in here.
|
||||||
|
They fetch /events and /history themselves, as they do on their
|
||||||
|
own pages, so the rules for which events and entries are public
|
||||||
|
live in one place each. This route only says whether those bands
|
||||||
|
appear and under what heading.
|
||||||
|
|
||||||
|
── Stats ──
|
||||||
|
A stat's source picks a query from STAT_QUERIES. Each counts
|
||||||
|
exactly what the matching public page shows: published rows, and
|
||||||
|
for awards only public citations to published people. A count
|
||||||
|
that disagreed with the page it summarises would be worse than
|
||||||
|
none. 'manual' and 'years_since' read the row's own value.
|
||||||
|
|
||||||
|
── Countdown ──
|
||||||
|
The pinned event if it is still published and not over;
|
||||||
|
otherwise the next published, non-cancelled event that hasn't
|
||||||
|
ended. "Hasn't ended" is COALESCE(ends_on, starts_on) >= today,
|
||||||
|
so a running series with a start date in the past still counts.
|
||||||
|
The client works out the next meeting of a series from `series`.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { asBool, shapeSeries } from "../shape.js";
|
||||||
|
|
||||||
|
const home = new Hono();
|
||||||
|
|
||||||
|
const CACHE = "public, max-age=60, stale-while-revalidate=300";
|
||||||
|
|
||||||
|
const json = (c, body) => c.json(body, 200, { "Cache-Control": CACHE });
|
||||||
|
|
||||||
|
const PAGE_ID = "home";
|
||||||
|
|
||||||
|
const STAT_QUERIES = {
|
||||||
|
regions: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'region' AND is_published = 1`,
|
||||||
|
chapters: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'chapter' AND is_published = 1`,
|
||||||
|
partners: `SELECT COUNT(*) AS n FROM organizations WHERE kind = 'partner' AND is_published = 1`,
|
||||||
|
events_held: `SELECT COUNT(*) AS n FROM v_events
|
||||||
|
WHERE is_published = 1 AND effective_status = 'past'`,
|
||||||
|
retreats_held: `SELECT COUNT(*) AS n FROM v_events
|
||||||
|
WHERE is_published = 1 AND effective_status = 'past'
|
||||||
|
AND event_type = 'retreat'`,
|
||||||
|
people: `SELECT COUNT(*) AS n FROM people WHERE is_published = 1`,
|
||||||
|
awards_given: `SELECT COUNT(*) AS n
|
||||||
|
FROM person_awards pa
|
||||||
|
JOIN people p ON p.id = pa.person_id AND p.is_published = 1
|
||||||
|
JOIN awards a ON a.id = pa.award_id AND a.is_published = 1
|
||||||
|
WHERE pa.is_public = 1`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The number as a string, or null when there's nothing to print —
|
||||||
|
a manual stat nobody filled in, or a year that isn't one. */
|
||||||
|
function statValue(db, row) {
|
||||||
|
if (row.source === "manual") return row.value || null;
|
||||||
|
|
||||||
|
if (row.source === "years_since") {
|
||||||
|
const year = Number.parseInt(row.value ?? "", 10);
|
||||||
|
if (!Number.isInteger(year)) return null;
|
||||||
|
return String(Math.max(0, new Date().getFullYear() - year));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = STAT_QUERIES[row.source];
|
||||||
|
return sql ? String(db.prepare(sql).get().n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeCountdown(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
theme: row.theme,
|
||||||
|
starts_on: row.starts_on,
|
||||||
|
ends_on: row.ends_on,
|
||||||
|
date_label: row.date_label,
|
||||||
|
location_label: row.location_label,
|
||||||
|
is_online: asBool(row.is_online),
|
||||||
|
color: row.effective_color,
|
||||||
|
event_logo: row.event_logo,
|
||||||
|
series: shapeSeries(row),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
home.get("/front-page", (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
|
||||||
|
const page = db.prepare(`SELECT * FROM front_page WHERE id = ?`).get(PAGE_ID);
|
||||||
|
|
||||||
|
// Migration 017 creates the row and the engine refuses to delete
|
||||||
|
// it, so this is a database that hasn't been migrated. Say so.
|
||||||
|
if (!page) return c.json({ error: "The front page hasn't been set up." }, 500);
|
||||||
|
|
||||||
|
const byOrder = (table) =>
|
||||||
|
db.prepare(`SELECT * FROM ${table} WHERE page_id = ? ORDER BY sort_order`).all(PAGE_ID);
|
||||||
|
|
||||||
|
const sections = byOrder("front_page_sections")
|
||||||
|
.filter((row) => !asBool(row.is_hidden))
|
||||||
|
.map((row) => ({ section: row.section, title: row.title, blurb: row.blurb }));
|
||||||
|
|
||||||
|
const slides = byOrder("front_page_slides").map((row) => ({
|
||||||
|
media: row.media,
|
||||||
|
alt: row.alt,
|
||||||
|
caption: row.caption,
|
||||||
|
link_url: row.link_url,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stats = byOrder("front_page_stats")
|
||||||
|
.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
value: statValue(db, row),
|
||||||
|
suffix: row.suffix,
|
||||||
|
note: row.note,
|
||||||
|
}))
|
||||||
|
.filter((stat) => stat.value !== null);
|
||||||
|
|
||||||
|
const actions = db.prepare(
|
||||||
|
`SELECT label, description, url FROM front_page_path_actions
|
||||||
|
WHERE path_id = ? ORDER BY sort_order`,
|
||||||
|
);
|
||||||
|
const paths = byOrder("front_page_paths")
|
||||||
|
.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
icon: row.icon,
|
||||||
|
blurb: row.blurb,
|
||||||
|
actions: actions.all(row.id),
|
||||||
|
}))
|
||||||
|
// A path with nothing to do is a dead tab.
|
||||||
|
.filter((path) => path.actions.length > 0);
|
||||||
|
|
||||||
|
const notOver = `is_published = 1
|
||||||
|
AND effective_status != 'cancelled'
|
||||||
|
AND COALESCE(ends_on, starts_on) >= date('now')`;
|
||||||
|
|
||||||
|
const pinned = page.countdown_event_id
|
||||||
|
? db
|
||||||
|
.prepare(`SELECT * FROM v_events WHERE id = ? AND ${notOver}`)
|
||||||
|
.get(page.countdown_event_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const next =
|
||||||
|
pinned ??
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM v_events
|
||||||
|
WHERE ${notOver}
|
||||||
|
ORDER BY starts_on, sort_order
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return json(c, {
|
||||||
|
front_page: {
|
||||||
|
hero: {
|
||||||
|
mode: page.hero_mode,
|
||||||
|
eyebrow: page.eyebrow,
|
||||||
|
headline: page.headline,
|
||||||
|
subhead: page.subhead,
|
||||||
|
primary: page.primary_label && page.primary_url
|
||||||
|
? { label: page.primary_label, url: page.primary_url }
|
||||||
|
: null,
|
||||||
|
secondary: page.secondary_label && page.secondary_url
|
||||||
|
? { label: page.secondary_label, url: page.secondary_url }
|
||||||
|
: null,
|
||||||
|
slide_seconds: page.slide_seconds,
|
||||||
|
slides,
|
||||||
|
livestream: page.livestream_url
|
||||||
|
? { url: page.livestream_url, title: page.livestream_title }
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
stats,
|
||||||
|
paths,
|
||||||
|
countdown: shapeCountdown(next),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default home;
|
||||||
|
|
@ -144,4 +144,26 @@ export function splitLinks(links = []) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Event series ──────────────────────────────────────────────
|
||||||
|
The repeating schedule, or null for a one-off. Weekdays
|
||||||
|
collapse from seven flags to a list of the ticked ones, Sunday
|
||||||
|
first; an empty list means "starts_on's weekday", which the
|
||||||
|
client resolves since it already holds starts_on. Occurrences
|
||||||
|
are not sent — they are derived, and the client derives them
|
||||||
|
against its own today. Shared by /events and /front-page.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
const SERIES_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
|
||||||
|
export function shapeSeries(row) {
|
||||||
|
if (!asBool(row.is_series)) return null;
|
||||||
|
return {
|
||||||
|
frequency: row.series_frequency,
|
||||||
|
interval: row.series_interval,
|
||||||
|
weekdays: SERIES_WEEKDAYS.filter((day) => asBool(row[`series_${day}`])),
|
||||||
|
start_time: row.series_start_time,
|
||||||
|
end_time: row.series_end_time,
|
||||||
|
count: row.series_count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export { asBool };
|
export { asBool };
|
||||||
|
|
|
||||||
6
src/lib/adminSchema.d.ts
vendored
6
src/lib/adminSchema.d.ts
vendored
|
|
@ -94,6 +94,9 @@ export type EntitySpec = {
|
||||||
idLabel: string;
|
idLabel: string;
|
||||||
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
/** "auto": the table assigns the id, so the form shows it rather than asking. */
|
||||||
idKind?: "auto";
|
idKind?: "auto";
|
||||||
|
/** The one id a singleton entity has. The list opens it directly,
|
||||||
|
* and the editor offers no slug, back link or delete. */
|
||||||
|
singleton?: string;
|
||||||
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
/** Field(s) the slug is composed from. Absent when idKind is "auto". */
|
||||||
slugFrom?: string | string[];
|
slugFrom?: string | string[];
|
||||||
titleFrom?: string;
|
titleFrom?: string;
|
||||||
|
|
@ -108,7 +111,8 @@ export type AdminEntityKey =
|
||||||
| "people"
|
| "people"
|
||||||
| "teams"
|
| "teams"
|
||||||
| "awards"
|
| "awards"
|
||||||
| "timeline";
|
| "timeline"
|
||||||
|
| "front_page";
|
||||||
|
|
||||||
/* Indexed by route param as often as by name, so any other string
|
/* Indexed by route param as often as by name, so any other string
|
||||||
reads as possibly missing. */
|
reads as possibly missing. */
|
||||||
|
|
|
||||||
|
|
@ -941,7 +941,201 @@ const timeline = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADMIN_ENTITIES = { organizations, events, people, teams, awards, timeline };
|
/* ── Front page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
One record (see the singleton note in server/src/admin-schema.js).
|
||||||
|
The groups are the hero; the collections below are the rest of
|
||||||
|
the page. Photos and the livestream are editable whatever the
|
||||||
|
mode, so either can be ready before the switch is flipped.
|
||||||
|
|
||||||
|
Section keys and stat sources are the CHECK lists in migration
|
||||||
|
017. The labels here are what the admin reads; the values are
|
||||||
|
what the page and the API key on. */
|
||||||
|
|
||||||
|
const FRONT_PAGE_SECTIONS = [
|
||||||
|
["countdown", "Countdown to the next event"],
|
||||||
|
["retreats", "National Retreats carousel"],
|
||||||
|
["stats", "Numbers"],
|
||||||
|
["timeline", "Featured timeline"],
|
||||||
|
["connect", "Find your way in"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const STAT_SOURCES = [
|
||||||
|
["manual", "Typed in — shows Value"],
|
||||||
|
["years_since", "Years since — Value is the year"],
|
||||||
|
["regions", "Count: published regions"],
|
||||||
|
["chapters", "Count: published chapters"],
|
||||||
|
["partners", "Count: published partners"],
|
||||||
|
["events_held", "Count: past events"],
|
||||||
|
["retreats_held", "Count: past retreats"],
|
||||||
|
["people", "Count: published people"],
|
||||||
|
["awards_given", "Count: public awards given"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const frontPage = {
|
||||||
|
key: "front_page",
|
||||||
|
label: "Front page",
|
||||||
|
singular: "front page",
|
||||||
|
idLabel: "Page",
|
||||||
|
singleton: "home",
|
||||||
|
|
||||||
|
list: { columns: [], filters: [] },
|
||||||
|
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
legend: "Hero",
|
||||||
|
note: "The first thing a visitor sees. Blank buttons don't render.",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "hero_mode",
|
||||||
|
label: "Mode",
|
||||||
|
widget: "select",
|
||||||
|
options: [
|
||||||
|
["brand", "Brand — animated colour, no media"],
|
||||||
|
["photos", "Photos — cycles through the photos below"],
|
||||||
|
["livestream", "Livestream — embeds the stream with a LIVE badge"],
|
||||||
|
],
|
||||||
|
blankLabel: "— brand —",
|
||||||
|
help: "Switch to Livestream when you go live, and back when you're done",
|
||||||
|
},
|
||||||
|
{ path: "eyebrow", label: "Eyebrow", help: "Small line above the headline" },
|
||||||
|
{ path: "headline", label: "Headline", full: true },
|
||||||
|
{ path: "subhead", label: "Subhead", widget: "textarea", full: true },
|
||||||
|
{ path: "primary_label", label: "Main button label" },
|
||||||
|
{ path: "primary_url", label: "Main button link", help: "/retreats, #connect or a full URL" },
|
||||||
|
{ path: "secondary_label", label: "Second button label" },
|
||||||
|
{ path: "secondary_url", label: "Second button link" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Photos and livestream",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "slide_seconds",
|
||||||
|
label: "Seconds per photo",
|
||||||
|
widget: "number",
|
||||||
|
help: "Photos mode. 3 to 60",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "livestream_url",
|
||||||
|
label: "Livestream link",
|
||||||
|
full: true,
|
||||||
|
help: "A YouTube, Facebook or Vimeo link, or any embed URL",
|
||||||
|
},
|
||||||
|
{ path: "livestream_title", label: "Livestream title", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
legend: "Countdown",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "countdown_event_id",
|
||||||
|
label: "Count down to",
|
||||||
|
widget: "select",
|
||||||
|
optionsFrom: "events",
|
||||||
|
blankLabel: "— the next upcoming event —",
|
||||||
|
help: "Leave blank almost always. A pinned event that's over falls back to the next one",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "sections",
|
||||||
|
label: "Page sections",
|
||||||
|
note: "Drag to reorder. Each section can appear once; leave one out to drop it.",
|
||||||
|
addLabel: "Add section",
|
||||||
|
title: (row) =>
|
||||||
|
FRONT_PAGE_SECTIONS.find(([key]) => key === row.section)?.[1] ?? "New section",
|
||||||
|
blank: { section: "", title: "", blurb: "" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: "section",
|
||||||
|
label: "Section",
|
||||||
|
widget: "select",
|
||||||
|
options: FRONT_PAGE_SECTIONS,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ path: "title", label: "Heading", help: "Blank for the section's own" },
|
||||||
|
{ path: "blurb", label: "Blurb", full: true },
|
||||||
|
{ path: "is_hidden", label: "Hidden", widget: "checkbox", help: "Hide for now" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "slides",
|
||||||
|
label: "Hero photos",
|
||||||
|
note: "Shown in Photos mode, in this order.",
|
||||||
|
addLabel: "Add photo",
|
||||||
|
title: (row) => row.caption || row.media || "New photo",
|
||||||
|
blank: { media: "", alt: "", caption: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "media", label: "Photo", required: true, help: "Filename in public/front-page/, or a URL" },
|
||||||
|
{ path: "alt", label: "Description", help: "For screen readers" },
|
||||||
|
{ path: "caption", label: "Caption", full: true },
|
||||||
|
{ path: "link_url", label: "Link", help: "Optional; makes the caption a link" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stats",
|
||||||
|
label: "Numbers",
|
||||||
|
note: "Counts update themselves. A typed-in number with no value is left out.",
|
||||||
|
addLabel: "Add number",
|
||||||
|
title: (row) => row.label || "New number",
|
||||||
|
blank: { label: "", source: "", value: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "label", label: "Label", required: true },
|
||||||
|
{
|
||||||
|
path: "source",
|
||||||
|
label: "Where it comes from",
|
||||||
|
widget: "select",
|
||||||
|
options: STAT_SOURCES,
|
||||||
|
blankLabel: "— typed in —",
|
||||||
|
},
|
||||||
|
{ path: "value", label: "Value", help: "Typed in: the number. Years since: the year" },
|
||||||
|
{ path: "suffix", label: "Suffix", help: "+, k, % …" },
|
||||||
|
{ path: "note", label: "Note", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "paths",
|
||||||
|
label: "Find your way in",
|
||||||
|
note: "Each path is a choice a visitor can pick; its actions appear when they do.",
|
||||||
|
addLabel: "Add path",
|
||||||
|
title: (row) => [row.icon, row.label].filter(Boolean).join(" ") || "New path",
|
||||||
|
blank: { label: "", icon: "", blurb: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "label", label: "Label", required: true, help: "Attend, Serve…" },
|
||||||
|
{ path: "icon", label: "Icon", help: "One emoji" },
|
||||||
|
{ path: "blurb", label: "Blurb", full: true },
|
||||||
|
],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
addLabel: "Add action",
|
||||||
|
title: (row) => row.label || "New action",
|
||||||
|
blank: { label: "", url: "" },
|
||||||
|
fields: [
|
||||||
|
{ path: "label", label: "Label", required: true },
|
||||||
|
{ path: "url", label: "Link", required: true },
|
||||||
|
{ path: "description", label: "Description", full: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_ENTITIES = {
|
||||||
|
organizations,
|
||||||
|
events,
|
||||||
|
people,
|
||||||
|
teams,
|
||||||
|
awards,
|
||||||
|
timeline,
|
||||||
|
front_page: frontPage,
|
||||||
|
};
|
||||||
|
|
||||||
export function slugify(value) {
|
export function slugify(value) {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
|
|
|
||||||
62
src/lib/embeds.ts
Normal file
62
src/lib/embeds.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
EMBEDS
|
||||||
|
|
||||||
|
Turns the link an admin pastes into the URL an <iframe> can
|
||||||
|
load. People paste the page they're looking at — a YouTube watch
|
||||||
|
or live link, a Facebook video, a Vimeo page — not the embed
|
||||||
|
form, so those are recognised and rewritten. Anything else that
|
||||||
|
is https is assumed to already be an embed URL and passed
|
||||||
|
through; anything that isn't https is refused, so the hero never
|
||||||
|
frames a plain-http or javascript: URL.
|
||||||
|
|
||||||
|
Streams start muted: browsers only autoplay muted video, and a
|
||||||
|
hero that starts shouting is worse than one that asks.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
function youtubeId(url: URL): string | null {
|
||||||
|
const host = url.hostname.replace(/^www\.|^m\./, '')
|
||||||
|
if (host === 'youtu.be') return url.pathname.slice(1) || null
|
||||||
|
if (host !== 'youtube.com' && host !== 'youtube-nocookie.com') return null
|
||||||
|
|
||||||
|
const v = url.searchParams.get('v')
|
||||||
|
if (v) return v
|
||||||
|
|
||||||
|
// /live/ID, /embed/ID, /shorts/ID
|
||||||
|
const match = url.pathname.match(/^\/(?:live|embed|shorts)\/([\w-]+)/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An iframe src for a pasted stream link, or null if it can't be framed. */
|
||||||
|
export function livestreamEmbedUrl(raw?: string | null): string | null {
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(raw.trim())
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'https:') return null
|
||||||
|
|
||||||
|
const yt = youtubeId(url)
|
||||||
|
if (yt) {
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(yt)}?autoplay=1&mute=1&playsinline=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = url.hostname.replace(/^www\./, '')
|
||||||
|
|
||||||
|
if (host === 'vimeo.com') {
|
||||||
|
const id = url.pathname.match(/^\/(?:event\/)?(\d+)/)?.[1]
|
||||||
|
if (id) {
|
||||||
|
return url.pathname.startsWith('/event/')
|
||||||
|
? `https://vimeo.com/event/${id}/embed?autoplay=1&muted=1`
|
||||||
|
: `https://player.vimeo.com/video/${id}?autoplay=1&muted=1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((host === 'facebook.com' || host === 'fb.watch') && !url.pathname.startsWith('/plugins/')) {
|
||||||
|
return `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url.href)}&autoplay=1&mute=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,10 @@ export const orgLogo = inDir('/org-logos') // ⚠ guess
|
||||||
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
export const teamLogo = inDir('/team-logos') // ⚠ guess
|
||||||
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
export const awardLogo = inDir('/award-logos') // ⚠ guess
|
||||||
|
|
||||||
|
/* front_page_slides.media — the home page hero's photos. New with
|
||||||
|
the Front page editor, which names the directory in its help. */
|
||||||
|
export const heroPhoto = inDir('/front-page')
|
||||||
|
|
||||||
/* content_blocks.media, which can be an image on any owner's page,
|
/* content_blocks.media, which can be an image on any owner's page,
|
||||||
so it can't share a per-entity directory. */
|
so it can't share a per-entity directory. */
|
||||||
export const blockMedia = inDir('/media') // ⚠ guess
|
export const blockMedia = inDir('/media') // ⚠ guess
|
||||||
|
|
|
||||||
89
src/lib/useFrontPage.ts
Normal file
89
src/lib/useFrontPage.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FRONT PAGE DATA
|
||||||
|
|
||||||
|
GET /front-page, as routes/home.js shapes it: the hero, the
|
||||||
|
visible sections in order, counted stats, paths with actions,
|
||||||
|
and the countdown's event. Edited in the admin under Front page.
|
||||||
|
|
||||||
|
No fallback content. If the request fails the page says so — a
|
||||||
|
plausible default home page would hide a broken server behind
|
||||||
|
something that looks fine.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useRecord, type Resource } from './useRecord.ts'
|
||||||
|
import type { EventSeries } from './eventSeries.ts'
|
||||||
|
|
||||||
|
export type HeroMode = 'brand' | 'photos' | 'livestream'
|
||||||
|
|
||||||
|
/** The CHECK list on front_page_sections.section. */
|
||||||
|
export type FrontPageSectionKey = 'countdown' | 'retreats' | 'stats' | 'timeline' | 'connect'
|
||||||
|
|
||||||
|
export type HeroButton = { label: string; url: string }
|
||||||
|
|
||||||
|
export type HeroSlide = {
|
||||||
|
media: string
|
||||||
|
alt?: string | null
|
||||||
|
caption?: string | null
|
||||||
|
link_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Hero = {
|
||||||
|
mode: HeroMode
|
||||||
|
eyebrow?: string | null
|
||||||
|
headline: string
|
||||||
|
subhead?: string | null
|
||||||
|
primary: HeroButton | null
|
||||||
|
secondary: HeroButton | null
|
||||||
|
slide_seconds: number
|
||||||
|
slides: HeroSlide[]
|
||||||
|
livestream: { url: string; title?: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPageSection = {
|
||||||
|
section: FrontPageSectionKey
|
||||||
|
/** Null → the section's own heading. */
|
||||||
|
title?: string | null
|
||||||
|
blurb?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPageStat = {
|
||||||
|
label: string
|
||||||
|
/** Already counted or typed; never null — the API drops those. */
|
||||||
|
value: string
|
||||||
|
suffix?: string | null
|
||||||
|
note?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PathAction = { label: string; description?: string | null; url: string }
|
||||||
|
|
||||||
|
export type FrontPagePath = {
|
||||||
|
label: string
|
||||||
|
icon?: string | null
|
||||||
|
blurb?: string | null
|
||||||
|
actions: PathAction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CountdownEvent = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
theme?: string | null
|
||||||
|
starts_on?: string | null
|
||||||
|
ends_on?: string | null
|
||||||
|
date_label?: string | null
|
||||||
|
location_label?: string | null
|
||||||
|
is_online: boolean
|
||||||
|
color?: string | null
|
||||||
|
event_logo?: string | null
|
||||||
|
series: EventSeries | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrontPage = {
|
||||||
|
hero: Hero
|
||||||
|
sections: FrontPageSection[]
|
||||||
|
stats: FrontPageStat[]
|
||||||
|
paths: FrontPagePath[]
|
||||||
|
countdown: CountdownEvent | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFrontPage = (): Resource<FrontPage> =>
|
||||||
|
useRecord<FrontPage>('/front-page', 'front_page')
|
||||||
|
|
@ -1,485 +1,125 @@
|
||||||
import { useState, useEffect } from "react";
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
import nguLogo from "../assets/NGU_Logo.svg";
|
HOME — /
|
||||||
import fallLogo from "../assets/Fall Logo.svg";
|
|
||||||
import nguLogo_WhiteBG from "../assets/NGU_Logo_WhiteBG.svg";
|
|
||||||
|
|
||||||
{/* SVGs */}
|
Not a PageShell page: the front page has no title bar, and each
|
||||||
const DoveSVG = ({ className = "" }: { className?: string }) => (
|
band draws its own heading in its own style.
|
||||||
<svg className={className} width="72.867699mm" height="48.568241mm" viewBox="0 0 72.867699 48.568241" id="svg1" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs id="defs1" />
|
|
||||||
<g id="layer1" transform="translate(-70.490069,-117.83965)">
|
|
||||||
<g id="g2-5" transform="matrix(0.71532587,0,0,0.71532587,-173.91758,237.73112)" style={{ display: "inline" }}>
|
|
||||||
<path style={{ color: "#000000", display: "inline", fill: "#ffffff", stroke: "none", strokeWidth: 2.284, strokeMiterlimit: 4, strokeDasharray: "none", strokeOpacity: 1}} d="m 355.91146,-123.11955 c 3.13369,-1.59928 7.04147,-4.49077 10.29591,-6.33595 3.25443,-1.84519 6.30899,-3.58417 9.61426,-4.20523 2.65302,-0.4889 6.37319,-0.18817 9.07211,0.58357 2.69896,0.77175 5.57299,2.70484 7.16593,3.40762 1.59295,0.70275 2.24655,0.19006 2.82855,-0.77494 0.582,-0.96504 2.81839,-6.05064 3.84362,-8.9293 1.02519,-2.87864 2.26409,-5.72337 4.14553,-7.76121 1.88144,-2.03782 2.31893,-2.07776 4.06202,-3.15865 1.74307,-1.08086 10.24244,-3.71628 14.53403,-5.61581 4.29158,-1.89953 8.11957,-3.58996 12.24067,-5.48028 4.12109,-1.89033 6.08861,-2.79441 7.30709,-3.29554 0.273,0.73801 -0.2034,3.06663 -1.16031,5.22317 -0.95691,2.15654 -2.9569,5.04357 -4.86279,7.26365 -1.90589,2.22009 -4.28775,4.10342 -6.82223,5.66812 -2.53448,1.56467 -4.53981,2.45823 -7.60439,3.71266 -3.06457,1.25446 -11.88915,4.7102 -14.64698,7.12005 -2.75786,2.40984 -4.18313,6.63212 -3.64772,7.60115 0.5354,0.96902 2.51391,-1.48598 3.81084,-2.34867 1.29694,-0.8627 1.95172,-1.05814 3.14897,-1.09198 1.17765,-0.0172 2.77532,0.40067 3.29849,0.63293 1.48441,0.659 3.97438,2.00122 3.54176,3.36038 -0.16368,0.51434 -0.19462,0.56904 -3.05611,1.25841 -2.86151,0.68935 -3.48508,1.29579 -3.81533,3.74861 -0.22097,1.47094 -1.44719,3.88743 -3.13317,5.28015 -1.68596,1.39274 -4.55099,2.93627 -8.41717,3.35482 -3.86617,0.41855 -6.17544,3.97192 -6.93256,5.37259 -0.75713,1.40064 -2.66104,5.90506 -2.99685,6.15238 -0.3358,0.24732 -2.76998,-0.36582 -3.79458,-0.82517 -1.02463,-0.45935 -2.612,-1.39111 -3.63624,-2.16132 -1.02425,-0.7702 -3.457,-2.975 -3.0018,-3.64018 0.45519,-0.66516 1.56543,-1.35445 2.73515,-2.12254 1.16972,-0.76811 3.86984,-2.33631 5.3456,-3.59002 1.47576,-1.25372 2.7334,-2.47754 3.4042,-3.48323 0.67079,-1.00567 0.9358,-2.14286 -0.28206,-2.7388 -1.21786,-0.59597 -1.84092,-0.39835 -4.4486,0.0225 -2.60769,0.42097 -4.8218,1.10142 -8.46858,1.9005 -3.64678,0.79905 -7.82786,2.49393 -10.97221,3.07304 -3.14439,0.5791 -4.3363,0.83756 -8.5197,0.95691 -4.1834,0.11935 -7.15938,-0.35864 -8.95468,-0.91763 -1.7953,-0.55899 -2.64147,-1.55556 -2.60415,-2.17667 3.90737,-1.58574 7.9469,-3.2841 11.38348,-5.04009 z" id="path1887-1-3-7-7-6-0-1" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
Driven by the admin's Front page editor through GET /front-page.
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ig-icon" style={{ "--ig-fill": `url(#${id})` }}>
|
The hero comes first, always. After it, the bands in the order
|
||||||
<defs>
|
the admin dragged them into, minus any they hid. Which component
|
||||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
draws a band is decided here, in SECTIONS, keyed on the same list
|
||||||
<stop offset="0%" stopColor="#FEDA75" />
|
the CHECK in migration 017 holds — the database says "retreats,
|
||||||
<stop offset="25%" stopColor="#FA7E1E" />
|
third, called National Retreats"; this file says what a retreats
|
||||||
<stop offset="50%" stopColor="#D62976" />
|
band looks like.
|
||||||
<stop offset="75%" stopColor="#962FBF" />
|
|
||||||
<stop offset="100%" stopColor="#4F5BD5" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FacebookIcon = () => (
|
A band with nothing to show draws nothing: the countdown with no
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 fb-icon" fill="currentColor">
|
upcoming event, the numbers with no numbers, the timeline with
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
nothing featured, the pathfinder with no paths.
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const DiscordIcon = () => (
|
If /front-page fails, the hero still draws (empty) and the error
|
||||||
<svg viewBox="0 0 24 24" className="w-6 h-6 ds-icon" fill="currentColor">
|
takes the place of the bands, with a retry. There's deliberately
|
||||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057c.001.022.015.04.033.05a19.81 19.81 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
no default page to fall back to: it would look fine and hide a
|
||||||
</svg>
|
broken server.
|
||||||
);
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
{/* Link Tables */}
|
import type { ReactNode } from 'react'
|
||||||
const Social_Links = [
|
|
||||||
{ label: "Instagram", href: "https://www.instagram.com/nextgenerationunity/", Icon: InstagramIcon },
|
|
||||||
{ label: "Facebook", href: "https://www.facebook.com/NextGenerationofUnity", Icon: FacebookIcon },
|
|
||||||
{ label: "Discord", href: "https://discord.com/invite/AtngzpqaX5", Icon: DiscordIcon },
|
|
||||||
]
|
|
||||||
|
|
||||||
const Footer_Links = [
|
import HeroStage from './sections/home/HeroStage.tsx'
|
||||||
{ label: "Privacy Policy", href: "#"},
|
import NextEventCountdown from './sections/home/NextEventCountdown.tsx'
|
||||||
{ label: "Terms of Service", href: "#"},
|
import RetreatsBand from './sections/home/RetreatsBand.tsx'
|
||||||
{ label: "Contact Us", href: "mailto:info@nextgenerationofunity.org"},
|
import StatsBand from './sections/home/StatsBand.tsx'
|
||||||
]
|
import FeaturedTimelineRail from './sections/home/FeaturedTimelineRail.tsx'
|
||||||
|
import Pathfinder from './sections/home/Pathfinder.tsx'
|
||||||
|
import {
|
||||||
|
useFrontPage,
|
||||||
|
type FrontPage,
|
||||||
|
type FrontPageSection,
|
||||||
|
type FrontPageSectionKey,
|
||||||
|
} from '../lib/useFrontPage.ts'
|
||||||
|
import './sections/home/home.css'
|
||||||
|
|
||||||
const EVENTS = [
|
type BandProps = {
|
||||||
{
|
page: FrontPage
|
||||||
id: "spring-2025",
|
section: FrontPageSection
|
||||||
title: "Spring Retreat 2026",
|
title: string
|
||||||
theme: "Altering Intertia",
|
/** Position among the visible bands, 0 = straight after the hero. */
|
||||||
date: "March/April 2026",
|
position: number
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: fallLogo, // e.g. springLogo
|
|
||||||
color: "#f1c2fe",
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(150deg, rgba(240, 224, 254, 1), rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a:
|
|
||||||
"A weekend of connection, workshops, and community for young adults across the Unity movement.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "past",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "fall-retreat-2026",
|
|
||||||
title: "Fall Retreat 2026",
|
|
||||||
theme: "Consciousness Creates",
|
|
||||||
date: "November 12-15th, 2026",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: fallLogo,
|
|
||||||
color: "#b89421",
|
|
||||||
gradient:
|
|
||||||
"linear-gradient(150deg, rgba(178, 150, 42, 0.45), rgba(230, 200, 120, 0.15) 65%, rgba(255, 255, 255, 0.28))",
|
|
||||||
desc_a:
|
|
||||||
"Join us for an exciting opportunity to connect with young adults from across the country through meaningful conversations, creative workshops, and shared artistic expression. All designed to shift your focus to your highest self.",
|
|
||||||
desc_b: "Registration starting at $150, and $75 lodging cost.",
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
{ label: "Register Now!", link: "https://ngu.churchcenter.com/registrations/events/3761999" },
|
|
||||||
{ label: "Scholarship Application", link: "https://ngu.churchcenter.com/people/forms/1261992" },
|
|
||||||
{ label: "Volunteer", link: "https://ngu.churchcenter.com/people/forms/1176908" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-recharge-2027",
|
|
||||||
title: "Spring Recharge 2027",
|
|
||||||
theme: "TBD",
|
|
||||||
date: "March 6th, 2027",
|
|
||||||
location: "Online",
|
|
||||||
image: null, // e.g. eventLogo
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient:
|
|
||||||
null,
|
|
||||||
desc_a:
|
|
||||||
"One-day online event to reconnect in the spring.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "spring-service-2027",
|
|
||||||
title: "Service Week 2027",
|
|
||||||
theme: "Leadership & Service",
|
|
||||||
date: "April 4-9th, 2027",
|
|
||||||
location: "Unity Village, MO",
|
|
||||||
image: null, // e.g. eventLogo
|
|
||||||
color: "#138ba0",
|
|
||||||
gradient:
|
|
||||||
null,
|
|
||||||
desc_a:
|
|
||||||
"Join us at beautiful Unity Village for a week of leadership development and service projects.",
|
|
||||||
desc_b: null,
|
|
||||||
status: "upcoming",
|
|
||||||
links: [
|
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const START_INDEX = (() => {
|
|
||||||
const i = EVENTS.findIndex(e => e.status === "upcoming");
|
|
||||||
return i === -1 ? EVENTS.length - 1 : i;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const TEAL = "#138ba0";
|
|
||||||
|
|
||||||
const CARD = "min(48rem, 90vw)"; // the card itself — your original max-w-3xl
|
|
||||||
const GAP = "5rem"; // space between cards ← this is your knob
|
|
||||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
|
||||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
|
||||||
const FADE_DIST = "18rem";
|
|
||||||
|
|
||||||
const EDGE_FADE = `linear-gradient(to right,
|
|
||||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
|
||||||
black calc(50% - ${HALF_SLIDE}),
|
|
||||||
black calc(50% + ${HALF_SLIDE}),
|
|
||||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
|
||||||
|
|
||||||
{/* Functions */}
|
|
||||||
function WaveText({
|
|
||||||
text,
|
|
||||||
baseDelay = 0,
|
|
||||||
step = 0.1,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
baseDelay?: number;
|
|
||||||
step?: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.split("").map((char, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="float-anim"
|
|
||||||
style={{ animationDelay: `${-i * step}s` }}
|
|
||||||
>
|
|
||||||
{char === " " ? "\u00A0" : char}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Default headings for a band the admin didn't title, and how each
|
||||||
|
one renders. The ids are the page anchors — #connect is what the
|
||||||
|
hero's second button points at out of the box. */
|
||||||
|
const SECTIONS: Record<
|
||||||
|
FrontPageSectionKey,
|
||||||
|
{ title: string; render: (props: BandProps) => ReactNode }
|
||||||
|
> = {
|
||||||
|
countdown: {
|
||||||
|
title: 'Next up',
|
||||||
|
render: ({ page, position }) =>
|
||||||
|
page.countdown && <NextEventCountdown event={page.countdown} overlap={position === 0} />,
|
||||||
|
},
|
||||||
|
retreats: {
|
||||||
|
title: 'National Retreats',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<RetreatsBand id="retreats" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
title: 'By the numbers',
|
||||||
|
render: ({ page, section, title }) =>
|
||||||
|
page.stats.length > 0 && (
|
||||||
|
<StatsBand id="numbers" title={title} blurb={section.blurb} stats={page.stats} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
title: 'Our story so far',
|
||||||
|
render: ({ section, title }) => (
|
||||||
|
<FeaturedTimelineRail id="story" title={title} blurb={section.blurb} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
connect: {
|
||||||
|
title: 'Find your way in',
|
||||||
|
render: ({ page, section, title }) => (
|
||||||
|
<Pathfinder id="connect" title={title} blurb={section.blurb} paths={page.paths} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
{/* Other useState consts and Functions*/}
|
const { data: page, error, reload } = useFrontPage()
|
||||||
const [showCalendar, setShowCalendar] = useState(false);
|
|
||||||
const [index, setIndex] = useState(START_INDEX);
|
|
||||||
|
|
||||||
const event = EVENTS[index];
|
|
||||||
const isPast = event.status === "past";
|
|
||||||
const currentColor = event.color || TEAL;
|
|
||||||
|
|
||||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
|
||||||
const next = () => setIndex(i => Math.min(EVENTS.length - 1, i + 1));
|
|
||||||
|
|
||||||
const arrowStyle = (enabled: boolean) => ({
|
|
||||||
border: `1px solid ${TEAL}`,
|
|
||||||
background: "rgba(255,255,255,0.6)",
|
|
||||||
color: enabled ? TEAL : "#b8c6c9",
|
|
||||||
cursor: enabled ? "pointer" : "default",
|
|
||||||
opacity: enabled ? 1 : 0.4,
|
|
||||||
});
|
|
||||||
|
|
||||||
{/* Start of Main Content*/}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── HERO/ABOUT ─────────────────────────────────────────────── */}
|
<HeroStage hero={page?.hero ?? null} />
|
||||||
<section id="hero" className="relative min-h-screen flex flex-col items-center justify-center text-center overflow-hidden pt-20" style={{ background: "linear-gradient(135deg, #042f3a 0%, #004552 40%, #004c52 70%, #0e7a5a 100%)", opacity: 1 }}>
|
|
||||||
{/* Floating doves */}
|
|
||||||
<div className="absolute top-20 right-16 opacity-30 float-anim"><DoveSVG className="w-20 h-16"/></div>
|
|
||||||
<div className="absolute top-32 right-36 opacity-20 float-anim" style={{ animationDelay: "1s" }}><DoveSVG className="w-10 h-8"/></div>
|
|
||||||
<div className="absolute bottom-32 left-16 opacity-25 float-anim" style={{ animationDelay: "2s" }}><DoveSVG className="w-16 h-12"/></div>
|
|
||||||
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6">
|
{error && (
|
||||||
<h1 className="text-5xl md:text-7xl font-900 text-white leading-tight mb-4" style={{ fontFamily: "Poppins,sans-serif" }}>
|
<section className="px-6 py-24 text-center">
|
||||||
Next Generation<br />
|
<p className="text-[#b3261e]">Couldn’t load the front page. {error}</p>
|
||||||
<span className="grad-hero-text">
|
<button
|
||||||
<WaveText text="of Unity" step={0.1} />
|
type="button"
|
||||||
</span>
|
onClick={reload}
|
||||||
</h1>
|
className="mt-4 rounded-full border border-[#138ba0] px-5 py-2 font-semibold text-[#138ba0] hover:bg-[#eef9fb]"
|
||||||
|
|
||||||
<p className="text-white/70 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed" style={{ fontFamily: "League Spartan,sans-serif" }}>
|
|
||||||
A young-adult focused community ministy focused on supporting individuals in Unity Ministries from 18-40 years old. Rooted in spiritual growth, leadership development, and sacred service.
|
|
||||||
</p>
|
|
||||||
<p className="text-white/90 text-lg md:text-xl max-w-2xl mx-auto leading-relaxed mb-10" style={{ fontFamily: "League Spartan,sans-serif", fontWeight: "bold"}}>
|
|
||||||
We are the future of the Unity Movement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 justify-center">
|
|
||||||
<a href="https://www.instagram.com/nextgenerationunity" className="px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105 hover:shadow-xl shadow-lg" style={{ background: "linear-gradient(135deg, #008fa8, #88b668)", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Follow Us on Instagram
|
|
||||||
</a>
|
|
||||||
<a href="#events" className="px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105" style={{ border: "2px solid rgba(92, 231, 255,0.6)", color: "#5ce7ff", fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Attend a Retreat
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* #About ─────────────────────────────────────── */}
|
|
||||||
<section id="about" className="py-24 px-6" style={{ background: "#f0fcfd" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">A Ministry Designed For<br />Young Adults</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-2xl mx-auto text-lg leading-relaxed">
|
|
||||||
NGU exists to connect young adults across Unity ministries and create spaces for authentic spiritual exploration, community, and conscious living.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
|
||||||
{[
|
|
||||||
{ title: "Spiritual Community", icon: "🕊️", desc: "We welcome all adults under 40 no matter where you are on your journey. Our community thrives on diversity of thought, background, and belief." },
|
|
||||||
{ title: "Conscious Development", icon: "🌱", desc: "Through workshops, retreats, and gatherings, we cultivate minds and spirits ready to engage with life's deepest questions." },
|
|
||||||
{ title: "Connected Network", icon: "🌐", desc: "NGU spans regions nationwide. From local chapter small group ministry to regional and national gatherings and retreats, you're never that far away from your people." },
|
|
||||||
].map((card) => (
|
|
||||||
<div key={card.title} className="p-8 rounded-2xl transition-all duration-300 hover:-translate-y-1 hover:shadow-xl" style={{ background: "white", border: "1px solid rgba(19,139,160,0.15)" }}>
|
|
||||||
<div className="text-4xl mb-4">{card.icon}</div>
|
|
||||||
<h3 className="font-700 text-xl text-[#073d4a] mb-3">{card.title}</h3>
|
|
||||||
<p className="text-[#0a5260]/70 leading-relaxed text-sm">{card.desc}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* #Events ─────────────────────────────────────────────── */}
|
|
||||||
<section id="events" className="py-24 overflow-hidden" style={{ background: "#eef9fb" }}>
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]">
|
|
||||||
{isPast ? "Past Events" : "Upcoming Events"}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Carousel — full-bleed so neighbors can peek in from the screen edges */}
|
|
||||||
<div className="relative mb-6">
|
|
||||||
{/* Masked viewport: everything outside the fade gradient is invisible */}
|
|
||||||
<div
|
|
||||||
className="overflow-hidden"
|
|
||||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
|
||||||
>
|
>
|
||||||
{/* Sliding track */}
|
Try again
|
||||||
<div
|
</button>
|
||||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
</section>
|
||||||
style={{
|
)}
|
||||||
transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))`,
|
|
||||||
}}
|
{page?.sections.map((section, position) => {
|
||||||
>
|
const band = SECTIONS[section.section]
|
||||||
{EVENTS.map((ev, i) => {
|
// A key the CHECK has gained since this file was written.
|
||||||
const active = i === index;
|
if (!band) return null
|
||||||
const past = ev.status === "past";
|
|
||||||
const color = ev.color || TEAL;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={section.section}>
|
||||||
key={ev.id}
|
{band.render({
|
||||||
className="shrink-0"
|
page,
|
||||||
style={{
|
section,
|
||||||
width: SLIDE,
|
title: section.title || band.title,
|
||||||
padding: `0 calc(${GAP} / 2)`,
|
position,
|
||||||
cursor: active ? "default" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => !active && setIndex(i)}
|
|
||||||
aria-hidden={!active}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="rounded-3xl text-black overflow-hidden shadow-2xl h-full"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${color}`,
|
|
||||||
background: ev.gradient ?? undefined,
|
|
||||||
filter: past ? "saturate(0.75)" : "none",
|
|
||||||
pointerEvents: active ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-10">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 mb-4">
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<img
|
|
||||||
src={nguLogo_WhiteBG}
|
|
||||||
alt="Next Generation of Unity"
|
|
||||||
className="h-15 w-auto mb-6"
|
|
||||||
/>
|
|
||||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
|
||||||
{ev.theme && (
|
|
||||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
|
||||||
)}
|
|
||||||
<p className="text-2xl">{ev.date}</p>
|
|
||||||
<p className="text-2xl">{ev.location}</p>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-1 flex justify-end items-start">
|
|
||||||
{ev.image && (
|
|
||||||
<img src={ev.image} alt={ev.title} className="h-60" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ev.desc_a && <p className="mb-2 leading-relaxed">{ev.desc_a}</p>}
|
|
||||||
{ev.desc_b && <p className="leading-relaxed">{ev.desc_b}</p>}
|
|
||||||
|
|
||||||
{ev.links.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mt-8">
|
|
||||||
{ev.links.map(item => (
|
|
||||||
<a
|
|
||||||
key={item.label}
|
|
||||||
href={item.link}
|
|
||||||
className="py-2.5 px-3 rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center"
|
|
||||||
style={{ border: `1px solid ${color}` }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
past ? (
|
|
||||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
|
||||||
This event has concluded, thank you to everyone who joined us!
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="mt-8 text-center font-600" style={{ color:TEAL }}>
|
|
||||||
Registration has not opened yet, follow our instagram for more details.
|
|
||||||
</p>
|
|
||||||
<a href="https://instagram.com/nextgenerationunity" target="_blank" rel="noopener noreferrer" className="ig-link mt-4 w-fit mx-auto flex items-center justify-center gap-3 py-3 px-4 rounded-xl font-700 transition-all duration-200 hover:scale-[1.02]" style={{ border: `1px solid ${color}`, color }}>
|
|
||||||
<InstagramIcon id={`ig-${ev.id}`} />
|
|
||||||
@nextgenerationunity
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})}
|
||||||
{/* Arrows — outside the masked element so they never fade */}
|
|
||||||
<button
|
|
||||||
onClick={prev}
|
|
||||||
disabled={index === 0}
|
|
||||||
aria-label="Previous event"
|
|
||||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
|
||||||
style={arrowStyle(index > 0)}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={next}
|
|
||||||
disabled={index === EVENTS.length - 1}
|
|
||||||
aria-label="Next event"
|
|
||||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
|
||||||
style={arrowStyle(index < EVENTS.length - 1)}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
|
||||||
{/* Dot indicators */}
|
|
||||||
<div className="flex justify-center gap-2 mb-10">
|
|
||||||
{EVENTS.map((e, i) => (
|
|
||||||
<button
|
|
||||||
key={e.id}
|
|
||||||
onClick={() => setIndex(i)}
|
|
||||||
aria-label={`Go to ${e.title}`}
|
|
||||||
className="h-2.5 rounded-full transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
width: i === index ? "1.5rem" : "0.625rem",
|
|
||||||
background: i === index ? currentColor : "#b8c6c9",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-[#138ba0] font-600 text-sm">
|
|
||||||
· More events coming soon, stay connected for announcements ·
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/* #Connect -------------------------------------------- */}
|
|
||||||
<section id="connect" className="py-24 px-6" style={{ background: "white" }}>
|
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<div className="text-center mb-16">
|
|
||||||
<h2 className="mt-4 text-4xl md:text-5xl font-800 text-[#138ba0]" style={{ fontFamily: "Poppins,sans-serif" }}>
|
|
||||||
Ready to Connect?
|
|
||||||
</h2>
|
|
||||||
<p className="mt-4 text-[#0a5260]/70 max-w-xl mx-auto text-xl" style={{ fontFamily: "League Spartan,sans-serif" }}>Find your place in the NGU community</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-3xl mx-auto">
|
|
||||||
{[
|
|
||||||
{ label: "Volunteer", desc: "Help create transformative experiences for young adults", link:"https://ngu.churchcenter.com/people/forms/1176908"},
|
|
||||||
{ label: "Membership", desc: "Become an official member of the NGU community", link:"https://ngu.churchcenter.com/people/forms/1135816"},
|
|
||||||
{ label: "Affiliation Form", desc: "Affiliate your ministry or spiritual organization with NGU", link:"https://ngu.churchcenter.com/people/forms/1135750"},
|
|
||||||
{ label: "Speaker & Musician Directory", desc: "Join our network of speakers, musicians, and facilitators", link:"https://ngu.churchcenter.com/people/forms/1173181"},
|
|
||||||
].map(item => (
|
|
||||||
<a key={item.label} href={item.link} className="flex items-center gap-5 p-6 rounded-2xl text-left transition-all duration-300 hover:-translate-y-1 hover:shadow-xl group" style={{ background: "#073d4a", border: "1px solid rgba(45,200,224,0.2)" }}>
|
|
||||||
<div>
|
|
||||||
<p className="text-white font-700 mb-1" style={{ fontFamily: "Poppins,sans-serif" }}>{item.label}</p>
|
|
||||||
<p className="text-white/80 leading-snug" style={{ fontFamily: "League Spartan,sans-serif" }}>{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
{/* Arrow */}
|
|
||||||
<svg className="w-5 h-5 text-[#10d48a] ml-auto flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 text-center">
|
|
||||||
<div className="inline-flex flex-wrap justify-center gap-4">
|
|
||||||
<a
|
|
||||||
href="https://ngu.churchcenter.com/calendar?view=gallery"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-full text-white font-700 text-lg transition-all duration-300 hover:scale-105"
|
|
||||||
style={{ background: "linear-gradient(135deg, #138ba0, #10d48a)", fontFamily: "Outfit,sans-serif" }}
|
|
||||||
>
|
|
||||||
📅 View NGU Calendar
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
||||||
<polyline points="15 3 21 3 21 9" />
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCalendar(!showCalendar)}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 rounded-full font-700 text-lg transition-all duration-300 hover:scale-105"
|
|
||||||
style={{ border: "2px solid #138ba0", color: "#138ba0", background: "transparent", fontFamily: "Outfit,sans-serif" }}
|
|
||||||
>
|
|
||||||
{showCalendar ? "▲ Hide Calendar" : "▼ Show Calendar Here"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCalendar && (
|
|
||||||
<div className="mt-8 mx-auto max-w-4xl rounded-2xl shadow-lg p-6" style={{ border: "2px solid #138ba0" }}>
|
|
||||||
<iframe
|
|
||||||
src="https://ngu.churchcenter.com/calendar?embed=true&view=month"
|
|
||||||
title="NGU Calendar"
|
|
||||||
className="w-full planning-center-calender-embed"
|
|
||||||
style={{ height: "700px", border: "none" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,12 @@ export default function EntityEdit() {
|
||||||
// references an event has no name of its own.
|
// references an event has no name of its own.
|
||||||
const autoId = manifest?.idKind === "auto";
|
const autoId = manifest?.idKind === "auto";
|
||||||
|
|
||||||
|
// A singleton has one row, made by its migration: no slug to show
|
||||||
|
// or type, no list to go back to, nothing to delete. The server
|
||||||
|
// refuses create and delete regardless; this only stops offering
|
||||||
|
// them.
|
||||||
|
const singleton = Boolean(manifest?.singleton);
|
||||||
|
|
||||||
// Hoisted above the loading guards: the title hook below is a
|
// Hoisted above the loading guards: the title hook below is a
|
||||||
// hook, so it can't sit after an early return, and it needs the
|
// hook, so it can't sit after an early return, and it needs the
|
||||||
// same paths the heading uses.
|
// same paths the heading uses.
|
||||||
|
|
@ -215,7 +221,7 @@ export default function EntityEdit() {
|
||||||
|
|
||||||
/* ── Heading ───────────────────────────────────────────────── */
|
/* ── Heading ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
const heading = headingOf(form);
|
const heading = singleton ? manifest.label : headingOf(form);
|
||||||
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
const updatedAt = typeof form.updated_at === "string" ? form.updated_at : null;
|
||||||
|
|
||||||
const children = manifest.children ?? [];
|
const children = manifest.children ?? [];
|
||||||
|
|
@ -311,6 +317,7 @@ export default function EntityEdit() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-24">
|
<div className="pb-24">
|
||||||
|
{!singleton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => leave(`/admin/${manifest.key}`)}
|
onClick={() => leave(`/admin/${manifest.key}`)}
|
||||||
|
|
@ -318,6 +325,7 @@ export default function EntityEdit() {
|
||||||
>
|
>
|
||||||
← {manifest.label}
|
← {manifest.label}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
<h1 className="mt-2 text-3xl font-bold text-[#138ba0]">
|
||||||
{isNew ? `New ${manifest.singular}` : heading}
|
{isNew ? `New ${manifest.singular}` : heading}
|
||||||
|
|
@ -326,7 +334,13 @@ export default function EntityEdit() {
|
||||||
{/* Slug. An auto-id entity has nothing to ask for on create, and
|
{/* Slug. An auto-id entity has nothing to ask for on create, and
|
||||||
nothing editable afterwards — so it gets a plain line rather
|
nothing editable afterwards — so it gets a plain line rather
|
||||||
than a disabled box pretending to be a field. */}
|
than a disabled box pretending to be a field. */}
|
||||||
{autoId ? (
|
{singleton ? (
|
||||||
|
updatedAt && (
|
||||||
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
|
<p className="text-sm text-[#4a6b72]">Last saved {updatedAt}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : autoId ? (
|
||||||
!isNew && (
|
!isNew && (
|
||||||
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
<div className="mt-6 rounded-2xl border border-[#138ba0]/20 bg-white p-5">
|
||||||
<p className="text-sm text-[#4a6b72]">
|
<p className="text-sm text-[#4a6b72]">
|
||||||
|
|
@ -425,7 +439,7 @@ export default function EntityEdit() {
|
||||||
>
|
>
|
||||||
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
{saving ? "Saving…" : isNew ? "Create" : "Save changes"}
|
||||||
</button>
|
</button>
|
||||||
{!isNew && canDelete && (
|
{!isNew && !singleton && canDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { get, ApiError } from "../../lib/api.js";
|
import { get, ApiError } from "../../lib/api.js";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
|
|
@ -46,7 +46,7 @@ export default function EntityList() {
|
||||||
const canWrite = atLeast(user, "editor");
|
const canWrite = atLeast(user, "editor");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!manifest) return;
|
if (!manifest || manifest.singleton) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -72,6 +72,11 @@ export default function EntityList() {
|
||||||
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
return <p className="text-[#4a6b72]">No such thing to edit.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row, so no list: the tab opens the row.
|
||||||
|
if (manifest.singleton) {
|
||||||
|
return <Navigate to={`/admin/${manifest.key}/${manifest.singleton}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function setParam(key: string, value: string) {
|
function setParam(key: string, value: string) {
|
||||||
const next = new URLSearchParams(params);
|
const next = new URLSearchParams(params);
|
||||||
if (value) next.set(key, value);
|
if (value) next.set(key, value);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ export const CMS_NAV = [
|
||||||
label: "Timeline",
|
label: "Timeline",
|
||||||
blurb: "What the history page shows, and the order it shows it in.",
|
blurb: "What the history page shows, and the order it shows it in.",
|
||||||
},
|
},
|
||||||
|
// One record, not a list — the tab opens it directly.
|
||||||
|
{
|
||||||
|
to: "/admin/front_page",
|
||||||
|
label: "Front page",
|
||||||
|
blurb: "The hero, its photos or livestream, and which bands the home page draws.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/* Forms are submissions coming in rather than content going out, so
|
/* Forms are submissions coming in rather than content going out, so
|
||||||
|
|
|
||||||
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
250
src/pages/sections/home/FeaturedTimelineRail.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FEATURED TIMELINE RAIL
|
||||||
|
|
||||||
|
The history page's featured entries, sideways: oldest on the
|
||||||
|
left, so scrolling right moves forward through time. A line runs
|
||||||
|
under the cards with a dot per entry, and a year label wherever
|
||||||
|
the year changes. The last card goes to the full history.
|
||||||
|
|
||||||
|
Featured is the admin's call — timeline_entries.is_featured,
|
||||||
|
"shown large" on the history page. Nothing else is filtered
|
||||||
|
here; /history has already decided what's public.
|
||||||
|
|
||||||
|
Scrolling is native — touch, trackpad, shift-wheel — with
|
||||||
|
scroll-snap so it settles on a card. A mouse can also drag it, and
|
||||||
|
the arrow buttons step one card. A drag that moved more than a few
|
||||||
|
pixels swallows the click it ends in, so letting go over a card
|
||||||
|
doesn't open it.
|
||||||
|
|
||||||
|
Nothing featured, and the section doesn't render at all.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import HomeLink from './HomeLink.tsx'
|
||||||
|
import { useHistory } from '../../../lib/useHistory.ts'
|
||||||
|
import { hrefFor, logoSrc } from '../../../lib/timelineRefs.ts'
|
||||||
|
import { MONTH_LABELS, type TimelineItem } from '../../../lib/timeline.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
const DRAG_SLOP = 6
|
||||||
|
|
||||||
|
type RailProps = { id: string; title: string; blurb?: string | null }
|
||||||
|
|
||||||
|
export default function FeaturedTimelineRail({ id, title, blurb }: RailProps) {
|
||||||
|
const { items, loading, error, reload } = useHistory()
|
||||||
|
const featured = items
|
||||||
|
.filter((item) => item.featured)
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
const railRef = useRef<HTMLOListElement>(null)
|
||||||
|
const drag = useRef({ x: 0, left: 0, moved: false, active: false })
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
|
if (!loading && !error && featured.length === 0) return null
|
||||||
|
|
||||||
|
const step = (direction: number) => {
|
||||||
|
const rail = railRef.current
|
||||||
|
const card = rail?.querySelector('li')
|
||||||
|
if (!rail || !card) return
|
||||||
|
rail.scrollBy({ left: direction * (card.clientWidth + 24), behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerDown = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||||
|
if (e.pointerType !== 'mouse' || !railRef.current) return
|
||||||
|
drag.current = { x: e.clientX, left: railRef.current.scrollLeft, moved: false, active: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerMove = (e: ReactPointerEvent<HTMLOListElement>) => {
|
||||||
|
const d = drag.current
|
||||||
|
if (!d.active || !railRef.current) return
|
||||||
|
const dx = e.clientX - d.x
|
||||||
|
if (!d.moved && Math.abs(dx) > DRAG_SLOP) {
|
||||||
|
d.moved = true
|
||||||
|
setDragging(true)
|
||||||
|
railRef.current.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
if (d.moved) railRef.current.scrollLeft = d.left - dx
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
drag.current.active = false
|
||||||
|
setDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="overflow-hidden py-24" style={{ background: '#f4faf7' }}>
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{featured.length > 1 && (
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
{[
|
||||||
|
[-1, '‹', 'Earlier'],
|
||||||
|
[1, '›', 'Later'],
|
||||||
|
].map(([direction, glyph, label]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(direction as number)}
|
||||||
|
aria-label={label as string}
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full border text-2xl transition-colors hover:bg-white"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="mx-auto mt-10 max-w-6xl px-6 text-[#b3261e]">
|
||||||
|
Couldn’t load the timeline. {error}{' '}
|
||||||
|
<button type="button" onClick={reload} className="underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="mx-auto mt-12 flex max-w-6xl gap-6 px-6" aria-hidden="true">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-72 w-80 shrink-0 animate-pulse rounded-3xl bg-white" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol
|
||||||
|
ref={railRef}
|
||||||
|
className={`hp-rail mt-12 flex gap-6 overflow-x-auto px-6 pb-4 md:px-[max(1.5rem,calc((100vw-72rem)/2+1.5rem))] ${
|
||||||
|
dragging ? 'hp-rail--dragging' : ''
|
||||||
|
}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
// Links and images are natively draggable, and a native
|
||||||
|
// drag cancels the pointer stream this relies on.
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
onClickCapture={(e) => {
|
||||||
|
if (drag.current.moved) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
drag.current.moved = false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{featured.map((item, index) => (
|
||||||
|
<li key={item.id} className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||||
|
<RailCard
|
||||||
|
item={item}
|
||||||
|
showYear={index === 0 || year(item) !== year(featured[index - 1])}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<li className="w-[19rem] shrink-0 md:w-[22rem]">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="h-12" />
|
||||||
|
<Link
|
||||||
|
to="/history"
|
||||||
|
draggable={false}
|
||||||
|
className="flex flex-1 flex-col justify-center rounded-3xl p-8 text-white transition-transform duration-300 hover:-translate-y-1"
|
||||||
|
style={{ background: `linear-gradient(150deg, ${TEAL}, #073d4a)` }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-2xl font-extrabold">The whole story</span>
|
||||||
|
<span className="mt-2 text-white/75">Every year, every milestone.</span>
|
||||||
|
<span className="mt-6 font-semibold">See the full history →</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = (item: TimelineItem) => item.date.slice(0, 4)
|
||||||
|
|
||||||
|
/* "June 2014", "2014", "12 June 2014" — only as much as precision
|
||||||
|
says is true. */
|
||||||
|
function dateLabel(item: TimelineItem): string {
|
||||||
|
const [y, m, d] = item.date.split('-')
|
||||||
|
const month = m ? MONTH_LABELS[Number(m) - 1] : null
|
||||||
|
if (item.precision === 'day' && d && month) return `${Number(d)} ${month} ${y}`
|
||||||
|
if (item.precision !== 'year' && month) return `${month} ${y}`
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
function RailCard({ item, showYear }: { item: TimelineItem; showYear: boolean }) {
|
||||||
|
const href = hrefFor(item)
|
||||||
|
const logo = logoSrc(item)
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{logo && (
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
draggable={false}
|
||||||
|
className="mb-5 h-14 w-14 object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.2em]" style={{ color: TEAL }}>
|
||||||
|
{dateLabel(item)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-display text-xl font-bold leading-snug text-[#073d4a]">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
{item.meta && <p className="mt-1 text-sm text-[#4a6b72]">{item.meta}</p>}
|
||||||
|
{item.blurb && (
|
||||||
|
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-[#4a6b72]">{item.blurb}</p>
|
||||||
|
)}
|
||||||
|
{href && (
|
||||||
|
<span className="mt-auto pt-5 text-sm font-semibold" style={{ color: TEAL }}>
|
||||||
|
Read more →
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const card =
|
||||||
|
'flex flex-1 flex-col rounded-3xl border border-[#138ba0]/15 bg-white p-7 shadow-sm'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* The line, its dot, and the year where it changes. */}
|
||||||
|
<div className="relative mb-4 h-8">
|
||||||
|
<div className="absolute inset-x-[-1.5rem] top-1/2 h-px bg-[#138ba0]/30" />
|
||||||
|
<span
|
||||||
|
className="absolute left-0 top-1/2 h-3 w-3 -translate-y-1/2 rounded-full border-2 bg-white"
|
||||||
|
style={{ borderColor: TEAL }}
|
||||||
|
/>
|
||||||
|
{showYear && (
|
||||||
|
<span
|
||||||
|
className="absolute left-5 top-1/2 -translate-y-1/2 rounded-full px-3 py-0.5 font-display text-sm font-bold text-white"
|
||||||
|
style={{ background: TEAL }}
|
||||||
|
>
|
||||||
|
{year(item)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{href ? (
|
||||||
|
<HomeLink
|
||||||
|
url={href}
|
||||||
|
className={`${card} transition-all duration-300 hover:-translate-y-1 hover:shadow-xl`}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</HomeLink>
|
||||||
|
) : (
|
||||||
|
<div className={card}>{body}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
307
src/pages/sections/home/HeroStage.tsx
Normal file
307
src/pages/sections/home/HeroStage.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HERO STAGE
|
||||||
|
|
||||||
|
The top of the front page, in whichever mode the admin set:
|
||||||
|
|
||||||
|
brand drifting colour and slow concentric rings — many
|
||||||
|
circles, one centre — behind the words
|
||||||
|
photos the hero photos, crossfading with a slow zoom, a
|
||||||
|
progress bar per photo and a pause button (anything
|
||||||
|
that moves on its own for more than five seconds
|
||||||
|
needs one)
|
||||||
|
livestream the stream beside the words, with a LIVE badge
|
||||||
|
|
||||||
|
A mode that has nothing to show falls back to brand: photos with
|
||||||
|
no photos, or a livestream link that can't be embedded. A stream
|
||||||
|
link that can't be framed still gets a "Watch live" button, so
|
||||||
|
switching the mode on is never a no-op.
|
||||||
|
|
||||||
|
`hero` is null while the page config loads or when it failed; the
|
||||||
|
stage still draws, empty, so the page doesn't jump when it
|
||||||
|
arrives. The error itself is shown by Home, not here.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useEffect, useState, type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
import HomeLink from './HomeLink.tsx'
|
||||||
|
import { livestreamEmbedUrl } from '../../../lib/embeds.ts'
|
||||||
|
import { heroPhoto } from '../../../lib/media.ts'
|
||||||
|
import type { Hero, HeroSlide } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
const DEEP = '#04262e'
|
||||||
|
|
||||||
|
export default function HeroStage({ hero }: { hero: Hero | null }) {
|
||||||
|
const slides = (hero?.slides ?? []).filter((slide) => heroPhoto(slide.media))
|
||||||
|
const embed = livestreamEmbedUrl(hero?.livestream?.url)
|
||||||
|
|
||||||
|
const mode =
|
||||||
|
hero?.mode === 'photos' && slides.length > 0
|
||||||
|
? 'photos'
|
||||||
|
: hero?.mode === 'livestream' && hero.livestream
|
||||||
|
? 'livestream'
|
||||||
|
: 'brand'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="hero"
|
||||||
|
className="relative isolate flex min-h-[92vh] items-center overflow-hidden pt-24 pb-16"
|
||||||
|
style={{ background: DEEP }}
|
||||||
|
>
|
||||||
|
{mode === 'photos' ? (
|
||||||
|
<PhotoBackdrop slides={slides} seconds={hero?.slide_seconds ?? 7} />
|
||||||
|
) : (
|
||||||
|
<BrandBackdrop />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 mx-auto grid w-full max-w-7xl items-center gap-12 px-6 lg:grid-cols-12">
|
||||||
|
<div className={mode === 'livestream' ? 'lg:col-span-5' : 'lg:col-span-8'}>
|
||||||
|
{hero && <Words hero={hero} live={mode === 'livestream'} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'livestream' && hero?.livestream && (
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<LiveFrame src={embed} url={hero.livestream.url} title={hero.livestream.title} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The words ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function Words({ hero, live }: { hero: Hero; live: boolean }) {
|
||||||
|
const words = hero.headline.split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-white">
|
||||||
|
{live ? (
|
||||||
|
<p className="mb-6 inline-flex items-center gap-3 rounded-full bg-white/10 px-4 py-1.5 text-sm font-semibold uppercase tracking-[0.2em] backdrop-blur">
|
||||||
|
<span className="hp-live-dot h-2.5 w-2.5 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||||
|
Live now
|
||||||
|
{hero.livestream?.title && (
|
||||||
|
<span className="normal-case tracking-normal text-white/75">
|
||||||
|
· {hero.livestream.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
hero.eyebrow && (
|
||||||
|
<p className="mb-6 inline-block rounded-full border border-white/20 px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.25em] text-[#9fe7d0]">
|
||||||
|
{hero.eyebrow}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className={`font-display font-extrabold leading-[0.95] tracking-tight ${
|
||||||
|
live ? 'text-5xl md:text-6xl' : 'text-6xl md:text-8xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{words.map((word, index) => (
|
||||||
|
<span key={`${word}-${index}`}>
|
||||||
|
<span
|
||||||
|
className="hp-rise"
|
||||||
|
style={{ animationDelay: `${120 + index * 110}ms` }}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>{' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{hero.subhead && (
|
||||||
|
<p
|
||||||
|
className="hp-rise mt-8 max-w-2xl text-lg leading-relaxed text-white/75 md:text-xl"
|
||||||
|
style={{ animationDelay: `${200 + words.length * 110}ms` }}
|
||||||
|
>
|
||||||
|
{hero.subhead}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hero.primary || hero.secondary) && (
|
||||||
|
<div
|
||||||
|
className="hp-rise mt-10 flex flex-wrap gap-4"
|
||||||
|
style={{ animationDelay: `${320 + words.length * 110}ms` }}
|
||||||
|
>
|
||||||
|
{hero.primary && (
|
||||||
|
<HomeLink
|
||||||
|
url={hero.primary.url}
|
||||||
|
className="rounded-full px-8 py-4 text-lg font-bold text-[#04262e] shadow-xl transition-transform duration-300 hover:-translate-y-0.5 hover:scale-[1.03]"
|
||||||
|
style={{ background: 'linear-gradient(120deg, #9fe7d0, #5ce7ff)' }}
|
||||||
|
>
|
||||||
|
{hero.primary.label}
|
||||||
|
</HomeLink>
|
||||||
|
)}
|
||||||
|
{hero.secondary && (
|
||||||
|
<HomeLink
|
||||||
|
url={hero.secondary.url}
|
||||||
|
className="rounded-full border border-white/35 px-8 py-4 text-lg font-semibold text-white transition-colors duration-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{hero.secondary.label} →
|
||||||
|
</HomeLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Brand backdrop ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function BrandBackdrop() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 -z-10" aria-hidden="true">
|
||||||
|
<div className="hp-aurora hp-aurora--a" style={blob('#138ba0', '48vw', '-10%', '-10%')} />
|
||||||
|
<div className="hp-aurora hp-aurora--b" style={blob('#10d48a', '38vw', '45%', '20%')} />
|
||||||
|
<div className="hp-aurora hp-aurora--c" style={blob('#d8b64a', '30vw', '70%', '-5%')} />
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className="absolute -right-[20vw] top-1/2 h-[120vw] w-[120vw] -translate-y-1/2 opacity-[0.16] md:-right-[10vw] md:h-[80vw] md:w-[80vw]"
|
||||||
|
viewBox="0 0 400 400"
|
||||||
|
>
|
||||||
|
<g className="hp-rings" fill="none" stroke="#ffffff">
|
||||||
|
{[40, 70, 100, 130, 160, 190].map((r, i) => (
|
||||||
|
<circle key={r} cx="200" cy="200" r={r} strokeWidth={i % 2 ? 0.6 : 1.2} strokeDasharray={i % 2 ? '2 6' : undefined} />
|
||||||
|
))}
|
||||||
|
<circle cx="390" cy="200" r="4" fill="#9fe7d0" stroke="none" />
|
||||||
|
<circle cx="200" cy="40" r="3" fill="#5ce7ff" stroke="none" />
|
||||||
|
</g>
|
||||||
|
<g className="hp-rings hp-rings--reverse" fill="none" stroke="#9fe7d0">
|
||||||
|
<circle cx="200" cy="200" r="115" strokeWidth="0.8" strokeDasharray="1 10" />
|
||||||
|
<circle cx="85" cy="200" r="3.5" fill="#ffffff" stroke="none" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 h-40"
|
||||||
|
style={{ background: `linear-gradient(to bottom, transparent, ${DEEP})` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function blob(color: string, size: string, left: string, top: string): CSSProperties {
|
||||||
|
return { background: color, width: size, height: size, left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo backdrop ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function PhotoBackdrop({ slides, seconds }: { slides: HeroSlide[]; seconds: number }) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [paused, setPaused] = useState(false)
|
||||||
|
const ms = Math.max(3, seconds) * 1000
|
||||||
|
const current = slides[index % slides.length]
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || slides.length < 2) return
|
||||||
|
const timer = window.setTimeout(() => setIndex((i) => (i + 1) % slides.length), ms)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [index, paused, ms, slides.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 -z-10"
|
||||||
|
style={{ '--hp-slide-ms': `${ms}ms` } as CSSProperties}
|
||||||
|
>
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<div
|
||||||
|
key={`${slide.media}-${i}`}
|
||||||
|
className={`hp-slide ${i === index ? 'hp-slide--on' : ''}`}
|
||||||
|
aria-hidden={i !== index}
|
||||||
|
>
|
||||||
|
<img src={heroPhoto(slide.media) ?? ''} alt={slide.alt ?? ''} loading={i === 0 ? 'eager' : 'lazy'} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(100deg, rgba(4,38,46,0.92) 0%, rgba(4,38,46,0.65) 45%, rgba(4,38,46,0.15) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-x-0 bottom-6 z-10 mx-auto flex max-w-7xl flex-wrap items-end gap-4 px-6">
|
||||||
|
{current?.caption && (
|
||||||
|
<p className="max-w-md rounded-xl bg-black/35 px-4 py-2 text-sm text-white/90 backdrop-blur">
|
||||||
|
{current.link_url ? (
|
||||||
|
<HomeLink url={current.link_url} className="hover:underline">
|
||||||
|
{current.caption} →
|
||||||
|
</HomeLink>
|
||||||
|
) : (
|
||||||
|
current.caption
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<button
|
||||||
|
key={`${slide.media}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIndex(i)}
|
||||||
|
aria-label={`Show photo ${i + 1} of ${slides.length}`}
|
||||||
|
aria-current={i === index}
|
||||||
|
className="h-1.5 w-10 overflow-hidden rounded-full bg-white/25"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
// Re-keyed per index so the fill restarts on every change.
|
||||||
|
key={`${index}-${i}`}
|
||||||
|
className={`hp-progress block h-full bg-white ${
|
||||||
|
i < index ? 'hp-progress--done' : i === index ? 'hp-progress--run' : ''
|
||||||
|
} ${paused ? 'hp-progress--paused' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPaused((p) => !p)}
|
||||||
|
aria-label={paused ? 'Play slideshow' : 'Pause slideshow'}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-white/40 text-xs text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{paused ? '▶' : '❚❚'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Livestream ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function LiveFrame({ src, url, title }: { src: string | null; url: string; title?: string | null }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-3xl border border-white/15 bg-black shadow-2xl"
|
||||||
|
style={{ boxShadow: '0 30px 80px -20px rgba(16, 212, 138, 0.35)' }}
|
||||||
|
>
|
||||||
|
<div className="aspect-video w-full">
|
||||||
|
{src ? (
|
||||||
|
<iframe
|
||||||
|
src={src}
|
||||||
|
title={title || 'Livestream'}
|
||||||
|
className="h-full w-full"
|
||||||
|
allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 text-white">
|
||||||
|
<span className="hp-live-dot h-4 w-4 rounded-full bg-[#ff4d4d]" aria-hidden="true" />
|
||||||
|
<HomeLink
|
||||||
|
url={url}
|
||||||
|
className="rounded-full bg-white px-6 py-3 font-semibold text-[#04262e] hover:scale-105"
|
||||||
|
>
|
||||||
|
Watch live
|
||||||
|
</HomeLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/pages/sections/home/HomeLink.tsx
Normal file
45
src/pages/sections/home/HomeLink.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
HOME LINK
|
||||||
|
|
||||||
|
Every link on the front page is typed into the admin, so any of
|
||||||
|
them can be a route (/retreats), an anchor on this page
|
||||||
|
(#connect) or somewhere else entirely. One component decides
|
||||||
|
which element that is, so the hero buttons, photo captions and
|
||||||
|
pathfinder actions can't disagree about it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
type HomeLinkProps = {
|
||||||
|
url: string
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isExternal = (url: string) => /^[a-z][a-z0-9+.-]*:/i.test(url)
|
||||||
|
|
||||||
|
export default function HomeLink({ url, className, style, children }: HomeLinkProps) {
|
||||||
|
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||||
|
return (
|
||||||
|
<Link to={url} className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailto: and tel: open an app, not a tab.
|
||||||
|
const newTab = isExternal(url) && !/^(mailto|tel):/i.test(url)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
{...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/pages/sections/home/NextEventCountdown.tsx
Normal file
133
src/pages/sections/home/NextEventCountdown.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
NEXT EVENT COUNTDOWN
|
||||||
|
|
||||||
|
A strip under the hero: the next event, and how long until it.
|
||||||
|
The API picks the event (pinned in the admin, or the next one that
|
||||||
|
hasn't ended); this works out the moment to count to.
|
||||||
|
|
||||||
|
For a one-off event that's local midnight on starts_on — dates
|
||||||
|
here are calendar dates with no time attached. For a series it's
|
||||||
|
the next meeting, at the series' start time when it has one, so a
|
||||||
|
weekly class counts down to Tuesday 7pm rather than to a start
|
||||||
|
date months in the past.
|
||||||
|
|
||||||
|
Once the moment passes and the event hasn't ended, the strip says
|
||||||
|
it's happening now instead of counting below zero.
|
||||||
|
|
||||||
|
The strip's heading is the event itself, so the section title and
|
||||||
|
blurb from the admin aren't drawn here.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { eventHref } from '../../../lib/hrefs.ts'
|
||||||
|
import { upcomingOccurrences } from '../../../lib/eventSeries.ts'
|
||||||
|
import type { CountdownEvent } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
|
||||||
|
/* Local midnight (or HH:MM) on a 'YYYY-MM-DD'. */
|
||||||
|
function localMoment(date: string, time?: string | null): Date | null {
|
||||||
|
const [y, m, d] = date.split('-').map(Number)
|
||||||
|
if (!y || !m || !d) return null
|
||||||
|
const [hh, mm] = (time ?? '00:00').split(':').map(Number)
|
||||||
|
return new Date(y, m - 1, d, hh || 0, mm || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function target(event: CountdownEvent): Date | null {
|
||||||
|
if (event.series) {
|
||||||
|
const next = upcomingOccurrences(event.series, event.starts_on, event.ends_on, 1)[0]
|
||||||
|
return next ? localMoment(next, event.series.start_time) : null
|
||||||
|
}
|
||||||
|
return event.starts_on ? localMoment(event.starts_on) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNow(intervalMs: number) {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), intervalMs)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [intervalMs])
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `overlap` tucks the strip up over the hero's bottom edge, which
|
||||||
|
only makes sense when it's the first band after the hero. The
|
||||||
|
admin can move it anywhere, so Home decides. */
|
||||||
|
export default function NextEventCountdown({
|
||||||
|
event,
|
||||||
|
overlap,
|
||||||
|
}: {
|
||||||
|
event: CountdownEvent
|
||||||
|
overlap: boolean
|
||||||
|
}) {
|
||||||
|
const now = useNow(1000)
|
||||||
|
const when = target(event)
|
||||||
|
const accent = event.color || TEAL
|
||||||
|
|
||||||
|
const remaining = when ? when.getTime() - now : 0
|
||||||
|
const live = !when || remaining <= 0
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
['days', Math.floor(remaining / 86_400_000)],
|
||||||
|
['hrs', Math.floor(remaining / 3_600_000) % 24],
|
||||||
|
['min', Math.floor(remaining / 60_000) % 60],
|
||||||
|
['sec', Math.floor(remaining / 1000) % 60],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const where = event.location_label || (event.is_online ? 'Online' : null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Next event"
|
||||||
|
className={`relative z-20 px-6 ${overlap ? '-mt-12' : 'py-12'}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={eventHref(event.id)}
|
||||||
|
className="group mx-auto flex max-w-6xl flex-col gap-6 rounded-3xl border bg-white/95 p-6 shadow-2xl backdrop-blur transition-transform duration-300 hover:-translate-y-1 md:flex-row md:items-center md:p-8"
|
||||||
|
style={{ borderColor: `${accent}55` }}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.25em]" style={{ color: accent }}>
|
||||||
|
{live ? 'Happening now' : 'Next up'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 truncate font-display text-2xl font-extrabold text-[#073d4a] md:text-3xl">
|
||||||
|
{event.title}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-[#4a6b72]">
|
||||||
|
{[event.theme && `“${event.theme}”`, event.date_label, where]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!live && (
|
||||||
|
<div className="flex gap-2 md:gap-3" role="timer" aria-live="off">
|
||||||
|
{parts.map(([label, value]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex w-16 flex-col items-center rounded-2xl py-3 text-white md:w-20"
|
||||||
|
style={{ background: `linear-gradient(160deg, ${accent}, #073d4a)` }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-2xl font-bold tabular-nums md:text-3xl">
|
||||||
|
{String(value).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.65rem] uppercase tracking-widest text-white/75">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="self-start text-sm font-semibold transition-transform group-hover:translate-x-1 md:self-center"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/pages/sections/home/Pathfinder.tsx
Normal file
186
src/pages/sections/home/Pathfinder.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
PATHFINDER — "Find your way in"
|
||||||
|
|
||||||
|
The connect section as a question rather than a wall of forms:
|
||||||
|
pick what you're here for, and that path's actions arrive. Paths
|
||||||
|
and actions are the admin's (Front page → Find your way in);
|
||||||
|
a path with no actions never reaches this component.
|
||||||
|
|
||||||
|
The choices are a real tablist: arrow keys move between them,
|
||||||
|
Home and End jump to the ends, and only the selected tab is in
|
||||||
|
the tab order. Selection follows focus, which is right when
|
||||||
|
showing a panel costs nothing.
|
||||||
|
|
||||||
|
Re-keying the panel on the selected index is what replays the
|
||||||
|
entrance animation for each choice.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useId, useRef, useState, type KeyboardEvent } from 'react'
|
||||||
|
|
||||||
|
import HomeLink, { isExternal } from './HomeLink.tsx'
|
||||||
|
import type { FrontPagePath } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
type PathfinderProps = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
blurb?: string | null
|
||||||
|
paths: FrontPagePath[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One accent per position, cycling. Paths are data; colour is
|
||||||
|
presentation, so it's assigned here rather than stored. */
|
||||||
|
const ACCENTS = ['#138ba0', '#10a36e', '#c7972b', '#7a5ea8', '#d0643c']
|
||||||
|
|
||||||
|
export default function Pathfinder({ id, title, blurb, paths }: PathfinderProps) {
|
||||||
|
const [selected, setSelected] = useState(0)
|
||||||
|
const tabs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
const base = useId().replace(/:/g, '')
|
||||||
|
|
||||||
|
if (paths.length === 0) return null
|
||||||
|
|
||||||
|
const path = paths[Math.min(selected, paths.length - 1)]
|
||||||
|
const accent = ACCENTS[selected % ACCENTS.length]
|
||||||
|
|
||||||
|
const focus = (index: number) => {
|
||||||
|
const next = (index + paths.length) % paths.length
|
||||||
|
setSelected(next)
|
||||||
|
tabs.current[next]?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const moves: Record<string, number> = {
|
||||||
|
ArrowRight: selected + 1,
|
||||||
|
ArrowDown: selected + 1,
|
||||||
|
ArrowLeft: selected - 1,
|
||||||
|
ArrowUp: selected - 1,
|
||||||
|
Home: 0,
|
||||||
|
End: paths.length - 1,
|
||||||
|
}
|
||||||
|
if (!(e.key in moves)) return
|
||||||
|
e.preventDefault()
|
||||||
|
focus(moves[e.key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="py-24" style={{ background: '#ffffff' }}>
|
||||||
|
<div className="mx-auto max-w-6xl px-6">
|
||||||
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
|
<div className="lg:col-span-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.3em] text-[#138ba0]">
|
||||||
|
I’m looking to…
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-4 text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={title}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
className="mt-10 flex flex-col gap-3"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{paths.map((option, index) => {
|
||||||
|
const on = index === selected
|
||||||
|
const color = ACCENTS[index % ACCENTS.length]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${option.label}-${index}`}
|
||||||
|
ref={(el) => {
|
||||||
|
tabs.current[index] = el
|
||||||
|
}}
|
||||||
|
id={`${base}-tab-${index}`}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
aria-selected={on}
|
||||||
|
aria-controls={`${base}-panel`}
|
||||||
|
tabIndex={on ? 0 : -1}
|
||||||
|
onClick={() => setSelected(index)}
|
||||||
|
className="group flex items-center gap-4 rounded-2xl border-2 px-5 py-4 text-left transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
borderColor: on ? color : 'rgba(19,139,160,0.12)',
|
||||||
|
background: on ? `${color}12` : '#ffffff',
|
||||||
|
transform: on ? 'translateX(8px)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-2xl transition-transform duration-300 group-hover:scale-110"
|
||||||
|
style={{ background: on ? color : `${color}1f` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{option.icon || '•'}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-xl font-bold" style={{ color: on ? color : '#073d4a' }}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto text-xl transition-opacity"
|
||||||
|
style={{ color, opacity: on ? 1 : 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
key={selected}
|
||||||
|
id={`${base}-panel`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`${base}-tab-${selected}`}
|
||||||
|
className="relative overflow-hidden rounded-[2rem] p-8 md:p-10 lg:col-span-7"
|
||||||
|
style={{ background: `linear-gradient(155deg, ${accent}14, ${accent}05 60%, #ffffff)` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute -right-6 -top-10 select-none text-[10rem] leading-none opacity-10"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{path.icon}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{path.blurb && (
|
||||||
|
<p className="hp-pop relative max-w-md font-display text-2xl font-semibold leading-snug text-[#073d4a]">
|
||||||
|
{path.blurb}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="relative mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
{path.actions.map((action, index) => (
|
||||||
|
<li
|
||||||
|
key={`${action.url}-${index}`}
|
||||||
|
className="hp-pop"
|
||||||
|
style={{ animationDelay: `${120 + index * 90}ms` }}
|
||||||
|
>
|
||||||
|
<HomeLink
|
||||||
|
url={action.url}
|
||||||
|
className="group flex h-full flex-col rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<span className="flex items-start gap-2 font-display text-lg font-bold text-[#073d4a]">
|
||||||
|
{action.label}
|
||||||
|
<span
|
||||||
|
className="ml-auto transition-transform group-hover:translate-x-1"
|
||||||
|
style={{ color: accent }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isExternal(action.url) ? '↗' : '→'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{action.description && (
|
||||||
|
<span className="mt-2 text-sm leading-relaxed text-[#4a6b72]">
|
||||||
|
{action.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</HomeLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
40
src/pages/sections/home/RetreatsBand.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
RETREATS BAND
|
||||||
|
|
||||||
|
The National Retreats carousel from the Retreats page, as-is:
|
||||||
|
same component, same filter, so an event edited in the admin
|
||||||
|
shows up identically in both places. This file only adds the
|
||||||
|
front page's heading and a way through to the full page.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
import EventListCards from '../EventList-Cards.tsx'
|
||||||
|
|
||||||
|
const TEAL = '#138ba0'
|
||||||
|
|
||||||
|
type RetreatsBandProps = { id: string; title: string; blurb?: string | null }
|
||||||
|
|
||||||
|
export default function RetreatsBand({ id, title, blurb }: RetreatsBandProps) {
|
||||||
|
return (
|
||||||
|
<section id={id} className="overflow-hidden py-24" style={{ background: '#eef9fb' }}>
|
||||||
|
<div className="mx-auto mb-12 flex max-w-6xl flex-wrap items-end gap-6 px-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-4xl font-extrabold text-[#073d4a] md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-xl text-lg text-[#4a6b72]">{blurb}</p>}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/retreats"
|
||||||
|
className="ml-auto rounded-full border px-5 py-2 text-sm font-semibold transition-colors hover:bg-white"
|
||||||
|
style={{ borderColor: TEAL, color: TEAL }}
|
||||||
|
>
|
||||||
|
All retreats →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EventListCards section="national" type="retreat" view="carousel" accent={TEAL} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/pages/sections/home/StatsBand.tsx
Normal file
127
src/pages/sections/home/StatsBand.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
STATS BAND
|
||||||
|
|
||||||
|
The numbers from the admin's Front page editor, counted by the
|
||||||
|
API or typed in. Each one counts up from zero the first time the
|
||||||
|
band scrolls into view; a value that isn't a plain number
|
||||||
|
("Since 2004", "Coast to coast") just appears.
|
||||||
|
|
||||||
|
Reduced motion skips the count and shows the number.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { FrontPageStat } from '../../../lib/useFrontPage.ts'
|
||||||
|
|
||||||
|
const COUNT_MS = 1400
|
||||||
|
|
||||||
|
type StatsBandProps = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
blurb?: string | null
|
||||||
|
stats: FrontPageStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsBand({ id, title, blurb, stats }: StatsBandProps) {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
const [seen, setSeen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el || seen) return
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setSeen(true)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.35 },
|
||||||
|
)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [seen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
className="relative overflow-hidden py-24"
|
||||||
|
style={{ background: 'linear-gradient(160deg, #073d4a 0%, #04262e 70%)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -left-40 -top-40 h-[32rem] w-[32rem] rounded-full opacity-25 blur-3xl"
|
||||||
|
style={{ background: '#10d48a' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative mx-auto max-w-6xl px-6">
|
||||||
|
<h2 className="font-display text-sm font-bold uppercase tracking-[0.3em] text-[#9fe7d0]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{blurb && <p className="mt-3 max-w-2xl text-white/70">{blurb}</p>}
|
||||||
|
|
||||||
|
<dl className="mt-12 grid grid-cols-2 gap-x-8 gap-y-14 md:grid-cols-4">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div
|
||||||
|
key={`${stat.label}-${index}`}
|
||||||
|
className={`hp-stat border-l border-white/15 pl-6 ${seen ? 'hp-stat--in' : ''}`}
|
||||||
|
style={{ transitionDelay: `${index * 120}ms` }}
|
||||||
|
>
|
||||||
|
<dd className="font-display text-5xl font-extrabold leading-none text-white md:text-6xl">
|
||||||
|
<CountUp value={stat.value} run={seen} />
|
||||||
|
{stat.suffix && <span className="text-[#9fe7d0]">{stat.suffix}</span>}
|
||||||
|
</dd>
|
||||||
|
<dt className="mt-3 text-sm font-semibold uppercase tracking-widest text-white/70">
|
||||||
|
{stat.label}
|
||||||
|
</dt>
|
||||||
|
{stat.note && <p className="mt-1 text-sm text-white/50">{stat.note}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "1,200" counts to 1,200 and keeps its comma; "12.5" keeps its
|
||||||
|
decimal. Anything that isn't just a number renders as given. */
|
||||||
|
function CountUp({ value, run }: { value: string; run: boolean }) {
|
||||||
|
const numeric = /^\d[\d,]*(\.\d+)?$/.test(value)
|
||||||
|
const target = numeric ? Number(value.replace(/,/g, '')) : 0
|
||||||
|
const decimals = value.split('.')[1]?.length ?? 0
|
||||||
|
const grouped = value.includes(',')
|
||||||
|
|
||||||
|
const reduced =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const [shown, setShown] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numeric || !run || reduced) return
|
||||||
|
let frame = 0
|
||||||
|
const start = performance.now()
|
||||||
|
const tick = (t: number) => {
|
||||||
|
const p = Math.min(1, (t - start) / COUNT_MS)
|
||||||
|
// Ease out: fast at first, settling onto the number.
|
||||||
|
setShown(target * (1 - Math.pow(1 - p, 3)))
|
||||||
|
if (p < 1) frame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [numeric, run, reduced, target])
|
||||||
|
|
||||||
|
if (!numeric) return <>{value}</>
|
||||||
|
|
||||||
|
const n = reduced || !run ? (run ? target : 0) : shown
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{n.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
useGrouping: grouped || target >= 10_000,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
src/pages/sections/home/home.css
Normal file
156
src/pages/sections/home/home.css
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
FRONT PAGE MOTION
|
||||||
|
|
||||||
|
Keyframes and the few rules Tailwind utilities can't express.
|
||||||
|
Everything that moves on its own stops under
|
||||||
|
prefers-reduced-motion; the page still reads the same without it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Hero: brand mode aurora ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-aurora {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 9999px;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.55;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-aurora--a { animation: hp-drift-a 22s ease-in-out infinite alternate; }
|
||||||
|
.hp-aurora--b { animation: hp-drift-b 28s ease-in-out infinite alternate; }
|
||||||
|
.hp-aurora--c { animation: hp-drift-c 34s ease-in-out infinite alternate; }
|
||||||
|
|
||||||
|
@keyframes hp-drift-a {
|
||||||
|
from { transform: translate(-10%, -5%) scale(1); }
|
||||||
|
to { transform: translate(15%, 10%) scale(1.25); }
|
||||||
|
}
|
||||||
|
@keyframes hp-drift-b {
|
||||||
|
from { transform: translate(10%, 5%) scale(1.1); }
|
||||||
|
to { transform: translate(-20%, -10%) scale(0.9); }
|
||||||
|
}
|
||||||
|
@keyframes hp-drift-c {
|
||||||
|
from { transform: translate(0, 10%) scale(0.9); }
|
||||||
|
to { transform: translate(-10%, -15%) scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Concentric rings: many circles, one centre. */
|
||||||
|
.hp-rings {
|
||||||
|
animation: hp-spin 90s linear infinite;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
.hp-rings--reverse { animation-direction: reverse; animation-duration: 140s; }
|
||||||
|
|
||||||
|
@keyframes hp-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero: headline words rise in ────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-rise {
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.6em);
|
||||||
|
animation: hp-rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hp-rise {
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero: photos ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-slide {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 1.4s ease;
|
||||||
|
}
|
||||||
|
.hp-slide--on { opacity: 1; }
|
||||||
|
|
||||||
|
.hp-slide img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.hp-slide--on img { animation: hp-kenburns var(--hp-slide-ms, 7000ms) ease-out forwards; }
|
||||||
|
|
||||||
|
@keyframes hp-kenburns {
|
||||||
|
from { transform: scale(1.12) translate(1.5%, 1%); }
|
||||||
|
to { transform: scale(1) translate(0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp-progress {
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
.hp-progress--run { animation: hp-fill var(--hp-slide-ms, 7000ms) linear forwards; }
|
||||||
|
.hp-progress--done { transform: scaleX(1); }
|
||||||
|
.hp-progress--paused { animation-play-state: paused; }
|
||||||
|
|
||||||
|
@keyframes hp-fill {
|
||||||
|
to { transform: scaleX(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero: LIVE ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-live-dot {
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.7);
|
||||||
|
animation: hp-pulse 1.6s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hp-pulse {
|
||||||
|
to { box-shadow: 0 0 0 12px rgba(255, 77, 77, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Timeline rail ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-rail {
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.hp-rail::-webkit-scrollbar { display: none; }
|
||||||
|
.hp-rail--dragging { cursor: grabbing; scroll-snap-type: none; user-select: none; }
|
||||||
|
.hp-rail > * { scroll-snap-align: start; }
|
||||||
|
|
||||||
|
/* ── Pathfinder: actions arrive one after another ────────────── */
|
||||||
|
|
||||||
|
.hp-pop {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px) scale(0.98);
|
||||||
|
animation: hp-pop 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hp-pop {
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats: numbers settle in ────────────────────────────────── */
|
||||||
|
|
||||||
|
.hp-stat {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
transition: opacity 0.7s ease, transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1);
|
||||||
|
}
|
||||||
|
.hp-stat--in { opacity: 1; transform: none; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hp-aurora,
|
||||||
|
.hp-rings,
|
||||||
|
.hp-slide--on img,
|
||||||
|
.hp-live-dot {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.hp-rise,
|
||||||
|
.hp-pop {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.hp-stat {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.hp-slide {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue