@the-smithy/create-hearth-site 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-smithy/create-hearth-site",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Start a new client website powered by Hearth: pnpm create @the-smithy/hearth-site",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,7 +29,7 @@
29
29
  ],
30
30
  "devDependencies": {
31
31
  "vitest": "^5.0.3",
32
- "@the-smithy/hearth-schema": "^0.19.0"
32
+ "@the-smithy/hearth-schema": "^0.20.0"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
@@ -2,3 +2,6 @@
2
2
 
3
3
  Read `CLAUDE.md` in this folder: it holds this project's instructions for every AI coding assistant, including
4
4
  the first steps to run in a new checkout (`pnpm hearth:check`, then `pnpm hearth:docs`).
5
+
6
+ `pnpm hearth:docs` also installs the skills Hearth offers this site in `.claude/skills/`. Read the `SKILL.md` of any
7
+ that fits your task and follow it (see "Skills" in `CLAUDE.md`).
@@ -13,12 +13,21 @@ Do these before writing code, and tell the developer what you found:
13
13
  fix (missing key, wrong `HEARTH_SITE`, revoked key). Ask the developer to fix `.env.local`; don't guess values.
14
14
  2. **Get the full reference:** `pnpm hearth:docs` saves Hearth's API guide (every block and its fields, events,
15
15
  forms, previews, SEO) to `hearth-api.md`. Read the parts you need from it, and from
16
- `node_modules/@the-smithy/hearth/llms.md` for the SDK.
16
+ `node_modules/@the-smithy/hearth/llms.md` for the SDK. It also installs any skills Hearth offers this site in
17
+ `.claude/skills/` (see "Skills" below), so run it again when they're missing.
17
18
  3. **"Nothing is published"** means every page is a 404 until editors publish in Hearth. That's not a code
18
19
  problem: say so, and don't add placeholder content to work around it.
19
20
  4. **Then build the design** in `src/app/globals.css` (tokens) and `src/components/blocks/` (one component per
20
21
  block type), checking with `pnpm dev` against the published pages.
21
22
 
23
+ ## Skills
24
+
25
+ `pnpm hearth:docs` installs the skills Hearth offers this site in `.claude/skills/` (which ones depends on the
26
+ site). Each `SKILL.md` says what it's for and when to use it. Claude Code loads them by itself; other assistants
27
+ should read the ones that fit the task and follow them. Use them for that work, design work in particular, rather
28
+ than starting from scratch. They aren't committed (`.claude/skills/.gitignore` lists them), so a fresh checkout gets
29
+ them by running `pnpm hearth:docs`; never edit them, because the next run replaces them.
30
+
22
31
  ## Rules
23
32
 
24
33
  - **Content comes from Hearth.** Don't hard-code page text, menus, contact details or SEO tags. They're edited
@@ -42,7 +42,8 @@ The project comes with instructions for coding agents (`CLAUDE.md`, which Claude
42
42
  1. **Check the connection yourself, or have the agent do it:** `pnpm hearth:check` confirms the key works and
43
43
  lists the published pages and the block types they use. It never prints the key.
44
44
  2. **Give the agent the full reference:** `pnpm hearth:docs` saves Hearth's API guide to `hearth-api.md` (not
45
- committed). The agent's instructions already point it there.
45
+ committed), and installs any coding-assistant skills Hearth offers your site in `.claude/skills/`. The agent's
46
+ instructions already point it at both.
46
47
  3. **Then tell it what to build.** Prompts that work well:
47
48
 
48
49
  - *"Run the first steps in CLAUDE.md and tell me what's published and which blocks the site uses."*
@@ -73,6 +74,8 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
73
74
  and sites hidden in Hearth are kept out of search engines. What's still yours:
74
75
  - Pages your own code serves (like `/events`) need their own `generateMetadata` and a place in `src/app/sitemap.ts`.
75
76
  - Use `<Img priority>` for the image at the top of a page, and keep one `<h1>` per page (the Hero block renders it).
77
+ - Give `<Img>` the `crop` of an image that fills a frame (`crop={4 / 3}` for `aspect-[4/3] object-cover`) so it keeps
78
+ the focal point set in Hearth's media library in view.
76
79
  - Add both the bare domain and `www` to the site in Netlify, with the one Hearth uses as primary: Netlify then
77
80
  redirects the other to it.
78
81
 
@@ -14,6 +14,7 @@
14
14
  "dependencies": {
15
15
  "@the-smithy/ember": "^0.3.1",
16
16
  "@the-smithy/hearth": "workspace:^",
17
+ "maplibre-gl": "^6.16.0",
17
18
  "next": "16.3.8",
18
19
  "react": "19.2.8",
19
20
  "react-dom": "19.2.8",
@@ -1,7 +1,9 @@
1
1
  // Setup helpers for this site, for you or your coding agent. Both read .env.local and never print the API key.
2
2
  // pnpm hearth:check checks .env.local and the connection to Hearth, then lists what's published
3
- // pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read
4
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ // pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read,
4
+ // and installs any coding-assistant skills Hearth offers this site in .claude/skills/
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import path from "node:path";
5
7
  import { createHearthClient, DEFAULT_BASE_URL, isHearthError } from "@the-smithy/hearth";
6
8
 
7
9
  const command = process.argv[2];
@@ -87,6 +89,40 @@ async function docs() {
87
89
  if (!res.ok) fail(`Hearth didn't return the API guide (${res.status}). Check the API key with pnpm hearth:check.`);
88
90
  writeFileSync("hearth-api.md", await res.text());
89
91
  console.log("✓ Saved Hearth's full API guide to hearth-api.md (not committed). Point your coding assistant at it.");
92
+ await skills(env);
93
+ }
94
+
95
+ /**
96
+ * Installs the coding-assistant skills Hearth offers this site into .claude/skills/, replacing the ones it
97
+ * installed before. Their names are listed in .claude/skills/.gitignore, so they're never committed and the
98
+ * site's own skills there are left alone.
99
+ */
100
+ async function skills(env) {
101
+ const res = await fetch(`${env.baseUrl}/admin/docs/skills.json`, { headers: { Authorization: `Bearer ${env.apiKey}` } });
102
+ if (!res.ok) return console.log(` Hearth didn't return the coding-assistant skills (${res.status}); skipped them.`);
103
+ const { skills: offered = [] } = await res.json();
104
+ const dir = path.join(".claude", "skills");
105
+ const listFile = path.join(dir, ".gitignore");
106
+ const before = existsSync(listFile)
107
+ ? readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore")
108
+ : [];
109
+ const safe = (p) => /^[a-z0-9][a-z0-9-]*$/.test(p);
110
+ const names = offered.map((s) => s.name).filter(safe);
111
+ for (const name of before) if (safe(name) && !names.includes(name)) rmSync(path.join(dir, name), { recursive: true, force: true });
112
+ for (const skill of offered.filter((s) => safe(s.name))) {
113
+ rmSync(path.join(dir, skill.name), { recursive: true, force: true });
114
+ for (const file of skill.files) {
115
+ if (!file.path.split("/").every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== "..")) continue;
116
+ const target = path.join(dir, skill.name, ...file.path.split("/"));
117
+ mkdirSync(path.dirname(target), { recursive: true });
118
+ writeFileSync(target, file.content);
119
+ }
120
+ }
121
+ if (names.length || before.length) {
122
+ mkdirSync(dir, { recursive: true });
123
+ writeFileSync(listFile, ["# Installed by pnpm hearth:docs from Hearth; refreshed each run.", ".gitignore", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
124
+ }
125
+ if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in .claude/skills/: ${names.join(", ")}.`);
90
126
  }
91
127
 
92
128
  try {
@@ -41,3 +41,17 @@ export async function donate(prev: FormResult | null, formData: FormData): Promi
41
41
  export async function register(slug: string, prev: FormResult | null, formData: FormData): Promise<FormResult> {
42
42
  return hearthNext.registerAction(slug, { returnPath: "/registered" })(prev, formData);
43
43
  }
44
+
45
+ /**
46
+ * Signs up to volunteer at an event that asks for volunteers. Sign-ups land under the event's Volunteers in Hearth,
47
+ * which also emails the site's alert addresses.
48
+ */
49
+ export async function volunteer(slug: string, prev: VolunteerState | null, formData: FormData): Promise<VolunteerState> {
50
+ const result = await hearthNext.volunteerAction(slug)(prev, formData);
51
+ if (result.ok) return result;
52
+ // Hand back what they typed and chose, so the form keeps it after an error.
53
+ const values = Object.fromEntries([...formData.entries()].filter(([k, v]) => typeof v === "string" && !k.startsWith("$") && k !== "_gotcha" && k !== "shift")) as Record<string, string>;
54
+ return { ...result, values, shifts: formData.getAll("shift").map(String) };
55
+ }
56
+
57
+ export type VolunteerState = FormResult & { values?: Record<string, string>; shifts?: string[] };
@@ -38,7 +38,7 @@ export default async function FundraiserPage({ params }: Props) {
38
38
  {f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
39
39
  <Progress fundraiser={f} large />
40
40
  </div>
41
- {f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
41
+ {f.image ? <Img image={f.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
42
42
  </div>
43
43
  </section>
44
44
  <Blocks blocks={f.blocks} />
@@ -8,7 +8,10 @@
8
8
  --color-bg: #fbfaf7;
9
9
  --color-ink: #1c1c1a;
10
10
  --color-muted-ink: #5b5b55;
11
+ /* Dividers and card edges: decorative, so they can be faint. */
11
12
  --color-line: #e3e0d8;
13
+ /* Form field and option borders: the border is how people see the field, so it needs 3:1 on surface, bg and muted. */
14
+ --color-field: #8a867e;
12
15
  --color-surface: #ffffff;
13
16
  --color-muted: #f1eee7;
14
17
  --color-inverse: #1c1c1a;
@@ -5,7 +5,7 @@ import { useFormStatus } from "react-dom";
5
5
  import type { FormResult } from "@the-smithy/hearth";
6
6
  import { donate } from "@/app/actions";
7
7
 
8
- const input = "block w-full rounded-lg border border-line bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
8
+ const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
9
9
  const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
10
10
 
11
11
  function Submit({ label }: { label: string }) {
@@ -50,12 +50,12 @@ export function DonateForm({
50
50
  <legend className="mb-3 font-semibold">Amount</legend>
51
51
  <div className="flex flex-wrap gap-3">
52
52
  {presetsCents.map((cents) => (
53
- <label key={cents} className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-line has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
53
+ <label key={cents} className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-field has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
54
54
  <input type="radio" name="amount" value={cents / 100} checked={amount === String(cents / 100)} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
55
55
  {money(cents)}
56
56
  </label>
57
57
  ))}
58
- <label className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-line has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
58
+ <label className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-field has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
59
59
  <input type="radio" name="amount" value="custom" checked={amount === "custom"} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
60
60
  Other
61
61
  </label>
@@ -1,39 +1,86 @@
1
- import type { HearthEventDetail } from "@the-smithy/hearth";
1
+ import type { BlockOf, HearthEventDetail } from "@the-smithy/hearth";
2
2
  import { JsonLd } from "@the-smithy/hearth/react";
3
3
  import { Blocks } from "@/components/blocks";
4
- import { formatEventDate } from "@/components/blocks/events";
4
+ import { Hero } from "@/components/blocks/content";
5
+ import { formatEventDate, volunteerAnchor } from "@/components/blocks/events";
5
6
  import { Registration } from "@/components/Registration";
6
7
  import { RegistrationForm } from "@/components/RegistrationForm";
8
+ import { VolunteerForm } from "@/components/VolunteerForm";
7
9
  import { Img } from "@/components/ui";
8
10
 
9
- /** An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/…. */
11
+ /**
12
+ * An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/….
13
+ * When the event's blocks start with a hero, the hero is the header (its heading is the page's only h1, the event's
14
+ * name its eyebrow, the event's image its picture if it has none) and the details sit in a band under it.
15
+ * Volunteer sign-up goes at the bottom, unless a Volunteer sign-up block for the event is placed on the page.
16
+ */
10
17
  export function EventView({ event }: { event: HearthEventDetail }) {
11
18
  const { location } = event;
19
+ const [first, ...rest] = event.blocks;
20
+ const hero = first?.type === "hero" ? (first as BlockOf<"hero">) : null;
21
+ // A Volunteer sign-up block for this event puts the form where the editor placed it, instead of at the bottom.
22
+ const volunteerBlock = event.blocks.some((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
23
+
24
+ const cancelled = event.status === "cancelled" ? <p className="w-fit rounded bg-danger px-2 py-1 text-sm font-semibold text-white">This event is cancelled</p> : null;
25
+ const when = (
26
+ <p className="text-lg font-semibold text-highlight">
27
+ <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
28
+ </p>
29
+ );
30
+ const where =
31
+ location.name || location.address ? (
32
+ <p>
33
+ {location.name ? <span className="block font-semibold">{location.name}</span> : null}
34
+ {location.address ? <span className="block opacity-80">{location.address}</span> : null}
35
+ </p>
36
+ ) : null;
37
+ const summary = event.summary ? <p className="text-lg opacity-85">{event.summary}</p> : null;
38
+ const actions = (
39
+ <div className="flex flex-wrap items-center gap-3">
40
+ <Registration event={event} />
41
+ {event.volunteering?.open ? (
42
+ <a href={volunteerBlock ? `#${volunteerAnchor(event.slug)}` : "#volunteer"} className="w-fit rounded-lg px-6 py-3.5 font-semibold ring-2 ring-current hover:opacity-80">
43
+ Volunteer
44
+ </a>
45
+ ) : null}
46
+ </div>
47
+ );
48
+
12
49
  return (
13
50
  <>
14
51
  <JsonLd data={event.meta.jsonLd} />
15
- <section className="bg-muted">
16
- <div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
17
- <div className="flex flex-col gap-4">
18
- {event.collection ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{event.collection.name}</p> : null}
19
- {event.status === "cancelled" ? <p className="w-fit rounded bg-danger px-2 py-1 text-sm font-semibold text-white">This event is cancelled</p> : null}
20
- <h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{event.title}</h1>
21
- <p className="text-lg font-semibold text-highlight">
22
- <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
23
- </p>
24
- {location.name || location.address ? (
25
- <p>
26
- {location.name ? <span className="block font-semibold">{location.name}</span> : null}
27
- {location.address ? <span className="block opacity-80">{location.address}</span> : null}
28
- </p>
29
- ) : null}
30
- {event.summary ? <p className="text-lg opacity-85">{event.summary}</p> : null}
31
- <Registration event={event} />
52
+ {hero ? (
53
+ <>
54
+ <Hero block={{ ...hero, data: { ...hero.data, eyebrow: hero.data.eyebrow ?? event.title, image: hero.data.image ?? event.image ?? undefined } }} />
55
+ <section className="bg-muted">
56
+ <div className="mx-auto flex max-w-site flex-wrap items-center justify-between gap-6 px-4 py-8 sm:px-6">
57
+ <div className="flex flex-col gap-2">
58
+ {cancelled}
59
+ {when}
60
+ {where}
61
+ {hero.data.body ? null : summary}
62
+ </div>
63
+ {actions}
64
+ </div>
65
+ </section>
66
+ </>
67
+ ) : (
68
+ <section className="bg-muted">
69
+ <div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
70
+ <div className="flex flex-col gap-4">
71
+ {event.collection ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{event.collection.name}</p> : null}
72
+ {cancelled}
73
+ <h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{event.title}</h1>
74
+ {when}
75
+ {where}
76
+ {summary}
77
+ {actions}
78
+ </div>
79
+ {event.image ? <Img image={event.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
32
80
  </div>
33
- {event.image ? <Img image={event.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
34
- </div>
35
- </section>
36
- <Blocks blocks={event.blocks} />
81
+ </section>
82
+ )}
83
+ <Blocks blocks={hero ? rest : event.blocks} />
37
84
  {event.registration?.open ? (
38
85
  <section id="register" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
39
86
  <div className="max-w-2xl">
@@ -43,6 +90,18 @@ export function EventView({ event }: { event: HearthEventDetail }) {
43
90
  </div>
44
91
  </section>
45
92
  ) : null}
93
+ {event.volunteering?.open && !volunteerBlock ? (
94
+ <section id="volunteer" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
95
+ <div className="max-w-2xl">
96
+ <h2 className="mb-2 text-2xl font-bold">Volunteer</h2>
97
+ {event.volunteering.intro ? <p className="mb-2 whitespace-pre-line opacity-85">{event.volunteering.intro}</p> : null}
98
+ {event.volunteering.fundraiser ? <p className="mb-2 opacity-80">This event supports {event.volunteering.fundraiser.title}.</p> : null}
99
+ <div className="mt-6">
100
+ <VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
101
+ </div>
102
+ </div>
103
+ </section>
104
+ ) : null}
46
105
  </>
47
106
  );
48
107
  }
@@ -5,7 +5,7 @@ import { useFormStatus } from "react-dom";
5
5
  import type { EventTicket, FormResult } from "@the-smithy/hearth";
6
6
  import { register } from "@/app/actions";
7
7
 
8
- const input = "block w-full rounded-lg border border-line bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
8
+ const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
9
9
  const price = (cents: number) => (cents ? (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 }) : "Free");
10
10
 
11
11
  function Submit({ paid }: { paid: boolean }) {
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+ import { useFormStatus } from "react-dom";
5
+ import type { EventVolunteering } from "@the-smithy/hearth";
6
+ import { volunteer, type VolunteerState } from "@/app/actions";
7
+
8
+ const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
9
+
10
+ /** A shift's day and time in the event's time zone: "Sat, Nov 14 · 8:00 AM – 11:00 AM". */
11
+ function shiftTime(s: { startsAt: string; endsAt: string }, timeZone: string) {
12
+ const day = (d: string) => new Date(d).toLocaleDateString("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" });
13
+ const time = (d: string) => new Date(d).toLocaleTimeString("en-US", { timeZone, hour: "numeric", minute: "2-digit" });
14
+ return day(s.startsAt) === day(s.endsAt) ? `${day(s.startsAt)} · ${time(s.startsAt)} – ${time(s.endsAt)}` : `${day(s.startsAt)} ${time(s.startsAt)} – ${day(s.endsAt)} ${time(s.endsAt)}`;
15
+ }
16
+
17
+ function Submit({ label }: { label: string }) {
18
+ const { pending } = useFormStatus();
19
+ return (
20
+ <button type="submit" disabled={pending} className="rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90 disabled:opacity-60">
21
+ {pending ? "Signing up…" : label}
22
+ </button>
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Volunteer sign-up for an event: name and email, shifts to choose from (when the event lists them), then the
28
+ * event's own questions. Sign-ups land under the event's Volunteers in Hearth.
29
+ */
30
+ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string; timezone: string; volunteering: EventVolunteering }) {
31
+ const [state, action] = useActionState<VolunteerState | null, FormData>(volunteer.bind(null, slug), null);
32
+ const errors = state && !state.ok ? state.fieldErrors : {};
33
+ const values = (state && !state.ok ? state.values : undefined) ?? {};
34
+ const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
35
+ const many = volunteering.pickShifts === "many";
36
+
37
+ if (state?.ok) {
38
+ return (
39
+ <p role="status" className="rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink">
40
+ {state.message}
41
+ </p>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <form action={action} className="space-y-6" noValidate>
47
+ {state && !state.ok ? (
48
+ <p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
49
+ {state.message}
50
+ </p>
51
+ ) : null}
52
+ {/* Honeypot: hidden from people, filled in by bots. */}
53
+ <div aria-hidden className="absolute -left-[9999px]">
54
+ <label>
55
+ Leave this empty
56
+ <input name="_gotcha" tabIndex={-1} autoComplete="off" />
57
+ </label>
58
+ </div>
59
+ <div className="grid gap-5 sm:grid-cols-2">
60
+ <label className="block">
61
+ <span className="mb-1.5 block font-semibold">Name</span>
62
+ <input name="name" required autoComplete="name" defaultValue={values.name} aria-invalid={errors.name ? true : undefined} className={input} />
63
+ {errors.name ? <span className="mt-1 block text-danger">{errors.name}</span> : null}
64
+ </label>
65
+ <label className="block">
66
+ <span className="mb-1.5 block font-semibold">Email</span>
67
+ <input name="email" type="email" required autoComplete="email" defaultValue={values.email} aria-invalid={errors.email ? true : undefined} className={input} />
68
+ {errors.email ? <span className="mt-1 block text-danger">{errors.email}</span> : null}
69
+ </label>
70
+ </div>
71
+ {volunteering.pickShifts !== "none" ? (
72
+ <fieldset>
73
+ <legend className="mb-3 font-semibold">{many ? "Shifts you can do" : "Choose a shift"}</legend>
74
+ <div className="space-y-3">
75
+ {volunteering.shifts.map((s) => (
76
+ <label key={s.id} className={`flex cursor-pointer items-start gap-3 rounded-lg p-4 ring-1 ring-line has-checked:ring-2 has-checked:ring-accent ${s.full ? "cursor-not-allowed opacity-50" : ""}`}>
77
+ <input
78
+ type={many ? "checkbox" : "radio"}
79
+ name="shift"
80
+ value={s.id}
81
+ disabled={s.full}
82
+ defaultChecked={chosen.includes(s.id)}
83
+ className="mt-1 size-5 accent-accent"
84
+ />
85
+ <span className="flex-1">
86
+ <span className="flex flex-wrap justify-between gap-2 font-semibold">
87
+ <span>{s.name}</span>
88
+ <span className="font-normal opacity-80">{s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}</span>
89
+ </span>
90
+ <span className="mt-0.5 block opacity-80">{shiftTime(s, timezone)}</span>
91
+ {s.description ? <span className="mt-1 block opacity-80">{s.description}</span> : null}
92
+ </span>
93
+ </label>
94
+ ))}
95
+ </div>
96
+ {errors.shifts ? <p className="mt-2 text-danger">{errors.shifts}</p> : null}
97
+ </fieldset>
98
+ ) : null}
99
+ {volunteering.fields.map((f) => {
100
+ const id = `volunteer-${f.name}`;
101
+ const error = errors[f.name];
102
+ const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
103
+ return (
104
+ <div key={f.name}>
105
+ {f.type === "checkbox" ? (
106
+ <label className="flex items-center gap-3">
107
+ <input type="checkbox" {...common} defaultChecked={values[f.name] === "on"} className="size-5 accent-accent" />
108
+ {f.label}
109
+ </label>
110
+ ) : (
111
+ <>
112
+ <label htmlFor={id} className="mb-1.5 block font-semibold">
113
+ {f.label}
114
+ {f.required ? null : <span className="ml-1 font-normal opacity-70">(optional)</span>}
115
+ </label>
116
+ {f.type === "textarea" ? (
117
+ <textarea {...common} rows={4} defaultValue={values[f.name]} placeholder={f.placeholder} className={input} />
118
+ ) : f.type === "select" ? (
119
+ <select {...common} defaultValue={values[f.name] ?? ""} className={input}>
120
+ <option value="" disabled>
121
+ {f.placeholder || "Choose one…"}
122
+ </option>
123
+ {(f.options ?? []).map((o) => (
124
+ <option key={o}>{o}</option>
125
+ ))}
126
+ </select>
127
+ ) : (
128
+ <input {...common} type={f.type} defaultValue={values[f.name]} placeholder={f.placeholder} autoComplete={f.type === "tel" ? "tel" : undefined} className={input} />
129
+ )}
130
+ </>
131
+ )}
132
+ {error ? (
133
+ <p id={`${id}-error`} className="mt-1.5 text-sm text-danger">
134
+ {error}
135
+ </p>
136
+ ) : null}
137
+ </div>
138
+ );
139
+ })}
140
+ <Submit label={volunteering.submitLabel} />
141
+ </form>
142
+ );
143
+ }
@@ -23,7 +23,7 @@ export function EmberFormBlock({ formId, fallback }: { formId: string; fallback:
23
23
  muted: "var(--color-muted-ink)",
24
24
  error: "var(--color-danger)",
25
25
  inputBackground: "var(--color-surface)",
26
- border: "var(--color-line)",
26
+ border: "var(--color-field)",
27
27
  successBackground: "var(--color-muted)",
28
28
  radius: "8px",
29
29
  maxWidth: "none",
@@ -7,7 +7,7 @@ import { submitHearthForm, type FormState } from "@/app/actions";
7
7
 
8
8
  type FormData_ = BlockOf<"form">["data"];
9
9
 
10
- const input = "block w-full rounded-lg border border-line bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
10
+ const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
11
11
 
12
12
  function Submit({ label }: { label: string }) {
13
13
  const { pending } = useFormStatus();
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { directionsUrl, type Position } from "@the-smithy/hearth";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import "maplibre-gl/dist/maplibre-gl.css";
6
+
7
+ /** Free OpenStreetMap tiles from OpenFreeMap: no key, no account, no cookies. Swap for another MapLibre style to restyle. */
8
+ const MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
9
+
10
+ export type MapPlace = { name: string; address: string; position: Position };
11
+
12
+ /**
13
+ * A map with a pin for each place; a pin opens the place's name, address and directions. MapLibre loads only when the
14
+ * map is about to scroll into view, and scrolling the page never zooms it (two fingers, or Ctrl and scroll, do).
15
+ */
16
+ export function OpenMap({ places, label, className = "" }: { places: MapPlace[]; label: string; className?: string }) {
17
+ const container = useRef<HTMLDivElement>(null);
18
+ const [failed, setFailed] = useState(false);
19
+ const key = JSON.stringify(places);
20
+
21
+ useEffect(() => {
22
+ const el = container.current;
23
+ if (!el) return;
24
+ let cancelled = false;
25
+ let remove = () => {};
26
+ const show = async () => {
27
+ const ml = await import("maplibre-gl");
28
+ // MapLibre looks for its worker beside its own script, which isn't where the bundler puts it.
29
+ ml.setWorkerUrl(new URL("maplibre-gl/dist/maplibre-gl-worker.mjs", import.meta.url).href);
30
+ if (cancelled) return;
31
+ const list: MapPlace[] = JSON.parse(key);
32
+ const map = new ml.Map({
33
+ container: el,
34
+ style: MAP_STYLE,
35
+ center: [list[0].position.lng, list[0].position.lat],
36
+ zoom: 15,
37
+ attributionControl: { compact: true },
38
+ cooperativeGestures: true,
39
+ // MapLibre makes the map a labelled region (the canvas): name it for this map.
40
+ locale: { "Map.Title": label },
41
+ });
42
+ remove = () => map.remove();
43
+ map.addControl(new ml.NavigationControl({ showCompass: false }));
44
+ let loaded = false;
45
+ map.once("load", () => (loaded = true));
46
+ map.on("error", () => !loaded && setFailed(true));
47
+
48
+ const bounds = new ml.LngLatBounds();
49
+ for (const place of list) {
50
+ const at: [number, number] = [place.position.lng, place.position.lat];
51
+ bounds.extend(at);
52
+ const pin = document.createElement("button");
53
+ pin.type = "button";
54
+ pin.className = "block size-6 rounded-full border-[3px] border-white bg-highlight shadow-md";
55
+ pin.setAttribute("aria-label", place.name);
56
+ new ml.Marker({ element: pin }).setLngLat(at).setPopup(new ml.Popup({ offset: 14, closeButton: true }).setDOMContent(popup(place))).addTo(map);
57
+ }
58
+ if (list.length > 1) map.fitBounds(bounds, { padding: { top: 48, right: 56, bottom: 72, left: 48 }, maxZoom: 15, duration: 0 });
59
+ };
60
+ // Wait until the map is near the screen: most visitors never scroll to it.
61
+ const observer = new IntersectionObserver(
62
+ ([entry]) => {
63
+ if (!entry.isIntersecting) return;
64
+ observer.disconnect();
65
+ show().catch(() => setFailed(true));
66
+ },
67
+ { rootMargin: "300px" },
68
+ );
69
+ observer.observe(el);
70
+ return () => {
71
+ cancelled = true;
72
+ observer.disconnect();
73
+ remove();
74
+ };
75
+ }, [key, label]);
76
+
77
+ return (
78
+ <div className={`relative overflow-hidden rounded-xl bg-muted [&_.maplibregl-map]:font-sans [&_.maplibregl-popup-close-button]:size-8 [&_.maplibregl-popup-close-button]:text-xl ${className}`}>
79
+ {/* Sized by the frame: MapLibre makes its container position: relative. */}
80
+ <div ref={container} className="size-full" />
81
+ {failed ? <p className="absolute inset-0 grid place-items-center p-6 text-center opacity-80">The map couldn&apos;t load.</p> : null}
82
+ </div>
83
+ );
84
+ }
85
+
86
+ /** A pin's popup, built as elements (never HTML strings) so names and addresses stay plain text. */
87
+ function popup(place: MapPlace) {
88
+ const box = document.createElement("div");
89
+ box.className = "grid gap-1 pr-4 font-sans text-sm text-ink";
90
+ const name = document.createElement("strong");
91
+ name.textContent = place.name;
92
+ const address = document.createElement("span");
93
+ address.className = "whitespace-pre-line";
94
+ address.textContent = place.address;
95
+ const link = document.createElement("a");
96
+ link.href = directionsUrl(place);
97
+ link.rel = "noopener";
98
+ link.className = "font-semibold text-highlight underline underline-offset-4";
99
+ link.textContent = "Get directions";
100
+ box.append(name, address, link);
101
+ return box;
102
+ }
@@ -8,7 +8,7 @@ const date = (iso: string | null) =>
8
8
  function Card({ entry }: { entry: CollectionEntry }) {
9
9
  return (
10
10
  <li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
11
- {entry.image ? <Img image={entry.image} className="aspect-[16/10] w-full object-cover" /> : null}
11
+ {entry.image ? <Img image={entry.image} crop={16 / 10} className="aspect-[16/10] w-full object-cover" /> : null}
12
12
  <div className="flex flex-1 flex-col gap-2 p-6">
13
13
  {entry.date ? <p className="text-sm font-semibold text-highlight">{date(entry.date)}</p> : null}
14
14
  <h3 className="text-xl font-semibold">
@@ -5,7 +5,7 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
5
5
  const d = block.data;
6
6
  return (
7
7
  <section className="relative overflow-hidden bg-inverse text-inverse-ink">
8
- {d.image ? <Img image={d.image} priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
8
+ {d.image ? <Img image={d.image} crop priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
9
9
  <div className={`relative mx-auto max-w-site px-4 sm:px-6 ${d.size === "full" ? "py-28 sm:py-36" : "py-16 sm:py-20"}`}>
10
10
  {d.logo ? <Img image={d.logo} priority className="mb-6 h-20 w-auto sm:h-24" /> : null}
11
11
  {d.eyebrow ? <p className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] opacity-80">{d.eyebrow}</p> : null}
@@ -46,7 +46,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
46
46
  </div>
47
47
  ) : null}
48
48
  </div>
49
- {d.image ? <Img image={d.image} className={`w-full rounded-xl ${d.imageFit === "contain" ? "max-h-96 object-contain" : "aspect-[4/3] object-cover"}`} /> : null}
49
+ {d.image ? <Img image={d.image} crop={d.imageFit === "contain" ? undefined : 4 / 3} className={`w-full rounded-xl ${d.imageFit === "contain" ? "max-h-96 object-contain" : "aspect-[4/3] object-cover"}`} /> : null}
50
50
  </div>
51
51
  </Section>
52
52
  );
@@ -77,7 +77,7 @@ export function CardGrid({ block }: { block: BlockOf<"cardGrid"> }) {
77
77
  <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
78
78
  {d.items.map((c, i) => (
79
79
  <li key={i} className={`flex flex-col gap-3 rounded-2xl p-7 ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`}>
80
- {c.image ? <Img image={c.image} className="mb-2 aspect-[16/10] w-full rounded-lg object-cover" /> : null}
80
+ {c.image ? <Img image={c.image} crop={16 / 10} className="mb-2 aspect-[16/10] w-full rounded-lg object-cover" /> : null}
81
81
  <h3 className="text-xl font-semibold">{c.title}</h3>
82
82
  {c.body ? <p className="leading-relaxed opacity-80">{c.body}</p> : null}
83
83
  {c.link ? (
@@ -112,6 +112,7 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
112
112
  {p.image ? (
113
113
  <Img
114
114
  image={p.image}
115
+ crop={16 / 10}
115
116
  className={`aspect-[16/10] w-full rounded-xl object-cover object-top ${grid ? "mb-2" : i % 2 ? "md:order-2" : ""}`}
116
117
  />
117
118
  ) : null}
@@ -207,7 +208,7 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
207
208
  if (!d.image) return <Section tone={d.tone}>{content(isDark(d.tone))}</Section>;
208
209
  return (
209
210
  <section className="relative overflow-hidden text-white">
210
- <Img image={d.image} className="absolute inset-0 size-full object-cover" />
211
+ <Img image={d.image} crop className="absolute inset-0 size-full object-cover" />
211
212
  <div className="relative bg-black/55">
212
213
  <div className="mx-auto max-w-site px-4 py-20 sm:px-6 sm:py-24">{content(true)}</div>
213
214
  </div>
@@ -244,7 +245,7 @@ export function Quote({ block }: { block: BlockOf<"quote"> }) {
244
245
  <blockquote className="text-2xl font-semibold leading-snug sm:text-3xl">“{d.quote}”</blockquote>
245
246
  {d.name ? (
246
247
  <figcaption className="flex items-center gap-4">
247
- {d.image ? <Img image={d.image} className="size-14 rounded-full object-cover" /> : null}
248
+ {d.image ? <Img image={d.image} crop={1} className="size-14 rounded-full object-cover" /> : null}
248
249
  <span>
249
250
  <span className="block font-semibold">{d.name}</span>
250
251
  {d.role ? <span className="block opacity-75">{d.role}</span> : null}
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import type { BlockOf, HearthEvent } from "@the-smithy/hearth";
3
3
  import { Registration } from "@/components/Registration";
4
+ import { VolunteerForm } from "@/components/VolunteerForm";
4
5
  import { Img, Section, SectionHeading } from "@/components/ui";
5
6
 
6
7
  /** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
@@ -20,7 +21,7 @@ export function EventCard({ event }: { event: HearthEvent }) {
20
21
  const cancelled = event.status === "cancelled";
21
22
  return (
22
23
  <li className="flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
23
- {event.image ? <Img image={event.image} className="aspect-[16/9] w-full object-cover" /> : null}
24
+ {event.image ? <Img image={event.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
24
25
  <div className="flex flex-1 flex-col gap-2 p-6">
25
26
  <p className="text-sm font-semibold text-highlight">
26
27
  {cancelled ? <span className="mr-2 rounded bg-danger px-1.5 py-0.5 text-white">Cancelled</span> : null}
@@ -97,8 +98,41 @@ export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> })
97
98
  Event details
98
99
  </Link>
99
100
  </div>
100
- {e.image ? <Img image={e.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
101
+ {e.image ? <Img image={e.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
101
102
  </div>
102
103
  </Section>
103
104
  );
104
105
  }
106
+
107
+ /**
108
+ * Volunteer sign-up for an event, wherever the block is placed. On another page it names the event and links to it;
109
+ * once sign-up closes it says so. The event's own page leaves out its built-in form when this block is on it.
110
+ */
111
+ export function VolunteerSignup({ block }: { block: BlockOf<"volunteerSignup"> }) {
112
+ const e = block.data.event;
113
+ const v = e.volunteering;
114
+ const intro = block.data.intro || v.intro;
115
+ return (
116
+ <Section id={volunteerAnchor(e.slug)}>
117
+ <div className="max-w-2xl">
118
+ <h2 className="mb-2 text-2xl font-bold">{block.data.heading || "Volunteer"}</h2>
119
+ {block.data.eventSlug ? (
120
+ <p className="mb-2 font-semibold">
121
+ <Link href={e.path} className="underline underline-offset-4 hover:opacity-80">
122
+ {e.title}
123
+ </Link>
124
+ <span className="text-highlight"> · {formatEventDate(e)}</span>
125
+ </p>
126
+ ) : null}
127
+ {intro ? <p className="mb-2 whitespace-pre-line opacity-85">{intro}</p> : null}
128
+ {v.fundraiser ? <p className="mb-2 opacity-80">This event supports {v.fundraiser.title}.</p> : null}
129
+ <div className="mt-6">
130
+ {v.open ? <VolunteerForm slug={e.slug} timezone={e.timezone} volunteering={v} /> : <p className="opacity-80">Volunteer sign-up for this event has closed.</p>}
131
+ </div>
132
+ </div>
133
+ </Section>
134
+ );
135
+ }
136
+
137
+ /** Where an event's Volunteer sign-up block sits, for the event header's Volunteer button. */
138
+ export const volunteerAnchor = (slug: string) => `volunteer-${slug}`;
@@ -33,7 +33,7 @@ export function Progress({ fundraiser, large }: { fundraiser: Fundraiser; large?
33
33
  export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
34
34
  return (
35
35
  <li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
36
- {fundraiser.image ? <Img image={fundraiser.image} className="aspect-[16/9] w-full object-cover" /> : null}
36
+ {fundraiser.image ? <Img image={fundraiser.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
37
37
  <div className="flex flex-1 flex-col gap-3 p-6">
38
38
  {fundraiser.status === "completed" ? <p className="w-fit rounded bg-muted px-2 py-0.5 text-sm font-semibold">Completed</p> : null}
39
39
  <h3 className="text-xl font-semibold">
@@ -100,7 +100,7 @@ export function FundraiserSpotlight({ block }: { block: BlockOf<"fundraiserSpotl
100
100
  </Link>
101
101
  </p>
102
102
  </div>
103
- {f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
103
+ {f.image ? <Img image={f.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
104
104
  </div>
105
105
  </Section>
106
106
  );
@@ -3,12 +3,12 @@ import { HearthBlocks, type BlockComponents } from "@the-smithy/hearth/react";
3
3
  import { Section, SectionHeading } from "@/components/ui";
4
4
  import { CollectionList } from "./collections";
5
5
  import { CallToAction, CardGrid, Faq, Hero, MediaText, Pricing, Projects, Quote, RichText, Stats } from "./content";
6
- import { EventSpotlight, PastEvents, UpcomingEvents } from "./events";
6
+ import { EventSpotlight, PastEvents, UpcomingEvents, VolunteerSignup } from "./events";
7
7
  import { CustomBlock } from "./custom";
8
8
  import { EmberFormBlock } from "./EmberFormBlock";
9
9
  import { HearthForm } from "./Form";
10
10
  import { Donate, FundraiserGrid, FundraiserSpotlight } from "./giving";
11
- import { Location, Schedule } from "./info";
11
+ import { Location, Locations, Schedule } from "./info";
12
12
  import { Embed, Gallery, Logos, People, Video } from "./media";
13
13
 
14
14
  function FormBlock({ block }: { block: BlockOf<"form"> }) {
@@ -49,11 +49,13 @@ export const blocks: BlockComponents = {
49
49
  people: People,
50
50
  logos: Logos,
51
51
  location: Location,
52
+ locations: Locations,
52
53
  schedule: Schedule,
53
54
  form: FormBlock,
54
55
  upcomingEvents: UpcomingEvents,
55
56
  pastEvents: PastEvents,
56
57
  eventSpotlight: EventSpotlight,
58
+ volunteerSignup: VolunteerSignup,
57
59
  collectionList: CollectionList,
58
60
  fundraiserSpotlight: FundraiserSpotlight,
59
61
  fundraiserGrid: FundraiserGrid,
@@ -1,9 +1,11 @@
1
- import type { BlockOf } from "@the-smithy/hearth";
2
- import { Section, SectionHeading } from "@/components/ui";
1
+ import { directionsUrl, pinned, type BlockOf } from "@the-smithy/hearth";
2
+ import { Markdown, Section, SectionHeading, SmartLink } from "@/components/ui";
3
+ import { OpenMap } from "./OpenMap";
4
+
5
+ const linkStyle = "font-semibold text-highlight underline underline-offset-4";
3
6
 
4
7
  export function Location({ block }: { block: BlockOf<"location"> }) {
5
8
  const d = block.data;
6
- const query = encodeURIComponent(`${d.name}, ${d.address}`);
7
9
  return (
8
10
  <Section>
9
11
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
@@ -12,20 +14,49 @@ export function Location({ block }: { block: BlockOf<"location"> }) {
12
14
  <h3 className="text-xl font-semibold">{d.name}</h3>
13
15
  <address className="whitespace-pre-line not-italic">{d.address}</address>
14
16
  {d.hours ? <p className="whitespace-pre-line">{d.hours}</p> : null}
15
- {d.notes ? <p className="opacity-80">{d.notes}</p> : null}
16
- <a href={`https://www.google.com/maps/dir/?api=1&destination=${query}`} rel="noopener" className="font-semibold text-highlight underline underline-offset-4">
17
+ {d.notes ? <Markdown className="opacity-80">{d.notes}</Markdown> : null}
18
+ <a href={directionsUrl(d)} rel="noopener" className={linkStyle}>
17
19
  Get directions
18
20
  </a>
19
21
  </div>
20
- {d.showMap ? (
21
- <iframe
22
- title={`Map of ${d.name}`}
23
- src={`https://www.google.com/maps?q=${query}&output=embed`}
24
- loading="lazy"
25
- referrerPolicy="no-referrer-when-downgrade"
26
- className="aspect-[4/3] w-full rounded-xl border-0 bg-muted"
27
- />
22
+ {/* Content from before map pins has none: it shows no map until the pin is set in Hearth. */}
23
+ {d.showMap && d.position ? <OpenMap places={[{ name: d.name, address: d.address, position: d.position }]} label={`Map of ${d.name}`} className="aspect-[4/3]" /> : null}
24
+ </div>
25
+ </Section>
26
+ );
27
+ }
28
+
29
+ /** Several places: one map with a pin for each (places without a pin are only listed), and the list beside it. */
30
+ export function Locations({ block }: { block: BlockOf<"locations"> }) {
31
+ const d = block.data;
32
+ const onMap = d.showMap ? pinned(d.items) : [];
33
+ return (
34
+ <Section>
35
+ <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
36
+ <div className={`grid gap-10 ${onMap.length ? "md:grid-cols-[2fr_3fr]" : ""}`}>
37
+ {onMap.length ? (
38
+ <OpenMap places={onMap} label={d.heading ? `Map: ${d.heading}` : "Map of our locations"} className="aspect-[4/3] md:sticky md:top-24 md:order-2 md:aspect-auto md:h-[min(36rem,80vh)]" />
28
39
  ) : null}
40
+ <ul className={`divide-y divide-line border-y border-line ${onMap.length ? "" : "grid gap-x-10 sm:grid-cols-2 sm:divide-y-0 sm:border-y-0"}`}>
41
+ {d.items.map((place, i) => (
42
+ <li key={i} className="flex flex-col gap-2 py-6">
43
+ <h3 className="text-xl font-semibold">{place.name}</h3>
44
+ <address className="whitespace-pre-line not-italic">{place.address}</address>
45
+ {place.hours ? <p className="whitespace-pre-line">{place.hours}</p> : null}
46
+ {place.notes ? <p className="whitespace-pre-line opacity-80">{place.notes}</p> : null}
47
+ <p className="mt-1 flex flex-wrap gap-x-6 gap-y-2">
48
+ <a href={directionsUrl(place)} rel="noopener" className={linkStyle}>
49
+ Get directions<span className="sr-only"> to {place.name}</span>
50
+ </a>
51
+ {place.link ? (
52
+ <SmartLink href={place.link.href} className={linkStyle}>
53
+ {place.link.label}
54
+ </SmartLink>
55
+ ) : null}
56
+ </p>
57
+ </li>
58
+ ))}
59
+ </ul>
29
60
  </div>
30
61
  </Section>
31
62
  );
@@ -13,7 +13,7 @@ export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
13
13
  {d.images.map((item, i) => (
14
14
  <li key={i}>
15
15
  <figure>
16
- <Img image={item.image} className="aspect-[4/3] w-full rounded-xl object-cover" />
16
+ <Img image={item.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" />
17
17
  {item.caption ? <figcaption className="mt-2 text-sm opacity-75">{item.caption}</figcaption> : null}
18
18
  </figure>
19
19
  </li>
@@ -68,7 +68,7 @@ export function People({ block }: { block: BlockOf<"people"> }) {
68
68
  <ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
69
69
  {d.people.map((p, i) => (
70
70
  <li key={i} className="flex flex-col gap-3">
71
- {p.image ? <Img image={p.image} className="aspect-square w-full rounded-2xl object-cover" /> : null}
71
+ {p.image ? <Img image={p.image} crop={1} className="aspect-square w-full rounded-2xl object-cover" /> : null}
72
72
  <div>
73
73
  <h3 className="text-xl font-semibold">{p.name}</h3>
74
74
  {p.role ? <p className="opacity-75">{p.role}</p> : null}
@@ -1,6 +1,7 @@
1
1
  import Link from "next/link";
2
2
  import { Fragment, type ReactNode } from "react";
3
3
  import ReactMarkdown from "react-markdown";
4
+ import { focalPosition, type ImageRef } from "@the-smithy/hearth";
4
5
 
5
6
  type LinkRef = { label: string; href: string };
6
7
  export type Tone = "default" | "muted" | "inverse" | "accent";
@@ -15,9 +16,9 @@ const toneClass: Record<Tone, string> = {
15
16
  export const isDark = (tone?: Tone) => tone === "inverse" || tone === "accent";
16
17
 
17
18
  /** A full-width band with the site's content width inside. */
18
- export function Section({ tone = "default", className = "", children }: { tone?: Tone; className?: string; children: ReactNode }) {
19
+ export function Section({ tone = "default", id, className = "", children }: { tone?: Tone; id?: string; className?: string; children: ReactNode }) {
19
20
  return (
20
- <section className={toneClass[tone]}>
21
+ <section id={id} className={`${toneClass[tone]} ${id ? "scroll-mt-8" : ""}`}>
21
22
  <div className={`mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 ${className}`}>{children}</div>
22
23
  </section>
23
24
  );
@@ -103,17 +104,24 @@ export function Button({ link, variant = "primary", dark }: { link?: LinkRef; va
103
104
  * An image from Hearth. Its width and height (from Hearth's media library) reserve its space, so the page doesn't jump
104
105
  * as it loads. `priority` loads it first: use it for the image at the top of the page (the hero), which is usually
105
106
  * the largest thing on screen; everything else loads as it scrolls into view.
107
+ *
108
+ * `crop` is for images cropped to fill a frame (`object-cover`): they keep the focal point set in Hearth's media
109
+ * library in view. Give the frame's aspect ratio (`crop={4 / 3}` for `aspect-[4/3]`) to centre the focal point, or
110
+ * `crop` alone for frames whose shape changes with the screen (a full-bleed hero). Leave it off for uncropped images.
106
111
  */
107
112
  /* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
108
113
  export function Img({
109
114
  image,
110
115
  className = "",
111
116
  priority = false,
117
+ crop,
112
118
  }: {
113
- image: { src: string; alt: string; width?: number; height?: number };
119
+ image: ImageRef;
114
120
  className?: string;
115
121
  priority?: boolean;
122
+ crop?: number | true;
116
123
  }) {
124
+ const objectPosition = crop ? focalPosition(image, crop === true ? undefined : crop) : undefined;
117
125
  return (
118
126
  <img
119
127
  src={image.src}
@@ -123,6 +131,7 @@ export function Img({
123
131
  fetchPriority={priority ? "high" : "auto"}
124
132
  decoding={priority ? "sync" : "async"}
125
133
  className={className}
134
+ style={objectPosition ? { objectPosition } : undefined}
126
135
  />
127
136
  );
128
137
  }