v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

102
src/lib/api.js Normal file
View file

@ -0,0 +1,102 @@
/* ═══════════════════════════════════════════════════════════════
API CLIENT
One place that knows how to talk to the server, so components
never call fetch directly and swapping the transport later is a
single-file change.
Two things worth knowing about the design:
The cache is a module-level Map of in-flight and settled
promises. Two components asking for /events during the same
render pass share one request, and a remount inside the TTL
costs nothing. It resets on page load, which is the right
lifetime for content that changes weekly.
Every reader can pass a `fallback`. If the request fails, that
value is used instead. This is what keeps the Figma preview
working: pass the old static module as the fallback and the
preview renders real content with no server in sight.
═══════════════════════════════════════════════════════════════ */
const BASE = import.meta.env?.VITE_API_BASE ?? "/api";
const DEFAULT_TTL = 60_000;
const cache = new Map(); // path → { at, promise }
export class ApiError extends Error {
constructor(message, { status, fields } = {}) {
super(message);
this.name = "ApiError";
this.status = status;
this.fields = fields;
}
}
async function request(path, options = {}) {
const response = await fetch(`${BASE}${path}`, {
headers: { Accept: "application/json", ...options.headers },
...options,
});
if (response.status === 204) return null;
const type = response.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
// Usually the SPA fallback returning index.html for a URL the
// API doesn't serve. Parsing it would throw something useless.
throw new ApiError("Server did not return JSON.", {
status: response.status,
});
}
const body = await response.json();
if (!response.ok) {
throw new ApiError(body.error ?? "Request failed.", {
status: response.status,
fields: body.fields,
});
}
return body;
}
/* ── Reads ─────────────────────────────────────────────────────
get("/events") → cached for 60s
get("/events", { ttl: 0 }) → always fresh
get("/events", { fallback }) → fallback on any failure
───────────────────────────────────────────────────────────── */
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
const hit = cache.get(path);
if (hit && Date.now() - hit.at < ttl) return hit.promise;
const promise = request(path).catch((err) => {
cache.delete(path); // a failure shouldn't be cached
if (fallback !== undefined) {
console.warn(`api: ${path} failed, using fallback`, err);
return fallback;
}
throw err;
});
cache.set(path, { at: Date.now(), promise });
return promise;
}
export function invalidate(path) {
if (path) cache.delete(path);
else cache.clear();
}
/* ── Writes ──────────────────────────────────────────────────── */
export function post(path, data) {
return request(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}

78
src/lib/sections.tsx Normal file
View file

@ -0,0 +1,78 @@
import { useState } from "react";
/* ═══════════════════════════════════════════════════════════════
SECTION MANIFEST
A page is a list of sections, and a section is a view over data
the database already holds, narrowed by a filter. The map of
chapters and the vertical list of regions are the same rows seen
two ways; the three bands of retreats are one view seen three
times with a different filter each.
So a page declares what it wants and this turns it into what
PageShell takes. Every entry looks like:
{
id, title, blurb, accent, background, // the heading
Component, // the view
props: { ... }, // the filter
views: { // optional toggle
options: ["carousel", "grid"],
default: "carousel",
Toggle: EventCardsToggle,
},
}
Sections fetch their own data. The page never does, which is why
there's no loading or error state here — each view handles its
own, and one section failing doesn't blank the page.
Every Component receives `accent` so its empty and error notices
match the heading above them, and `view` when the entry declares
a toggle. Both are ignored harmlessly by a section that doesn't
want them.
═══════════════════════════════════════════════════════════════ */
export function useSectionManifest(manifest) {
// One entry per section that has a toggle, seeded from its
// declared default so the control is right before anything loads.
const [views, setViews] = useState(() =>
Object.fromEntries(
manifest
.filter(entry => entry.views)
.map(entry => [
entry.id,
entry.views.default ?? entry.views.options?.[0] ?? null,
]),
),
);
const setView = (id, value) => setViews(prev => ({ ...prev, [id]: value }));
return manifest.map(entry => {
const { Component, props, views: spec, ...heading } = entry;
const view = views[entry.id];
const Toggle = spec?.Toggle;
return {
...heading,
actions: Toggle ? (
<Toggle
view={view}
setView={value => setView(entry.id, value)}
accent={entry.accent}
options={spec.options}
/>
) : undefined,
content: (
<Component
{...(props ?? {})}
accent={entry.accent}
{...(spec ? { view } : {})}
/>
),
};
});
}

52
src/lib/useResource.js Normal file
View file

@ -0,0 +1,52 @@
/* ═══════════════════════════════════════════════════════════════
useResource
The read hook every page uses:
const { data, error, loading } = useResource("/events", {
fallback: { events: EVENTS }, // the old static module
});
Deliberately small. If the site ever needs mutation, refetch on
focus, or pagination, that's the point to reach for TanStack
Query rather than growing this file.
Note the `ignore` flag rather than an AbortController: the
request is shared and cached, so cancelling it would throw away
work another component may still want. We just stop writing
state after unmount.
═══════════════════════════════════════════════════════════════ */
import { useEffect, useState } from "react";
import { get } from "./api.js";
export function useResource(path, { ttl, fallback } = {}) {
// Seed with the fallback so the first paint has content when one
// is available, rather than flashing a spinner and then the same
// data a moment later.
const [state, setState] = useState(() => ({
data: fallback,
error: null,
loading: true,
}));
useEffect(() => {
let ignore = false;
setState((prev) => ({ ...prev, loading: true, error: null }));
get(path, { ttl, fallback })
.then((data) => {
if (!ignore) setState({ data, error: null, loading: false });
})
.catch((error) => {
if (!ignore) setState((prev) => ({ ...prev, error, loading: false }));
});
return () => {
ignore = true;
};
}, [path, ttl]); // fallback is intentionally not a dependency
return state;
}