v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
102
src/lib/api.js
Normal file
102
src/lib/api.js
Normal 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
78
src/lib/sections.tsx
Normal 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
52
src/lib/useResource.js
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue