From bce1a3fcf6c34653c22498fcccd33b46904d2628 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 03:05:45 -0500 Subject: [PATCH 01/11] Add CLAUDE.md --- CLAUDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..529ced8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# NGU-Web + +## Git workflow +- Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. +- Use `tea` for pull requests: `tea pr create --base main --head --title "..." --description "..."` +- Never commit directly to main. Create a branch for each change, push it, and open a PR. +- Versions are marked with annotated tags (v1.0, v2.0). Don't create or move tags unless asked. + +## Project +- Package manager: pnpm (not npm or yarn) +- Frontend: Vite + TypeScript in src/ +- Backend in server/; server/dev.db is local only and never committed \ No newline at end of file From ea58c5ce3fb5b2234d6bdd521ec95c40d6eb19b0 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 03:06:55 -0500 Subject: [PATCH 02/11] Add README with test line Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a78d25 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +Test line added by Claude Code. From 75c40cfb730064837c55e7e692a973d580f26569 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 03:20:11 -0500 Subject: [PATCH 03/11] Added update CLAUDE.md --- CLAUDE.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 529ced8..4c37542 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,59 @@ # NGU-Web +Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. + +## Stack +- Frontend: React, TypeScript, Tailwind CSS, React Router, Vite (in `src/`) +- Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) +- Package manager: pnpm only (never npm or yarn) + +## Deployment (production) +- Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` +- App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` +- Debugging: check `journalctl -u ngu-api -n 40 --no-pager` first. Make sure rsync ran from the repo (not the deployed copy) before restarting the service. +- Never run commands against the production server or database unless explicitly asked. + ## Git workflow - Remote is a self-hosted Forgejo server, not GitHub. Do not use `gh`. -- Use `tea` for pull requests: `tea pr create --base main --head --title "..." --description "..."` -- Never commit directly to main. Create a branch for each change, push it, and open a PR. -- Versions are marked with annotated tags (v1.0, v2.0). Don't create or move tags unless asked. +- Open pull requests with `tea`: `tea pr create --base main --head --title "..." --description "..."` +- Never commit directly to main. Create a branch per change, push it, open a PR. +- Versions are marked with annotated tags (v1.0, v1.3...). Don't create or move tags unless asked. +- `server/dev.db` and other `*.db` files are local only and never committed. -## Project -- Package manager: pnpm (not npm or yarn) -- Frontend: Vite + TypeScript in src/ -- Backend in server/; server/dev.db is local only and never committed \ No newline at end of file +## How to work in this repo +- Read the relevant existing files before writing anything. Follow existing patterns exactly: descriptors, field syntax, extension shape, import conventions. +- Ask questions up front before implementing non-trivial features. +- Prefer targeted edits when surrounding code is stable; full rewrites only when a component is being substantially reworked. +- Fix root causes. No redirect shims or workarounds. +- Keep data logic in the database and presentation logic in code. Make things configurable via constants, not hardcoded in components. +- Name components for what they do, not what they currently filter. + +## Project layout +- All pages use `PageShell.tsx` as the wrapper unless explicitly noted otherwise. +- Pages live in `src/pages/`; section-level components go in `src/pages/sections/`. +- `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. +- `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). +- `api.js` is the shared caching client used by frontend data hooks. +- Logos: org logos in `/org-logos/`, event logos in `public/event-logos/`. The `` component hides itself on load error. + +## Rules and gotchas +- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use `roleCanWrite(user)` / `roleCanDelete(user)` from `lib/roles` and the minimum-rank helpers (`canWrite`, `canDelete`, `isSuper`, `atLeast`). `role === "admin"` silently excludes higher roles and has caused repeated bugs. +- **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. +- **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. +- **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. + +## Admin CRUD engine +Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.js` (client) generate SQL and form fields from declarative entity configs. Adding an entity should mean adding a descriptor, not new CRUD code. +- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere. +- `reindex: false` prevents cross-entity sort order collisions. +- The `OMIT` sentinel distinguishes unsent fields from deliberate clears. +- `admin-schema-sync.js` runs at boot and throws if descriptors don't match live `PRAGMA table_info`. If boot fails after a schema change, update the descriptor or migration so they agree. +- `admin-cli.js` imports `ROLES` and `destroyAllSessionsFor` from `auth.js`. Keep it that way to prevent drift. + +## Migrations +- Sequential files: `001_`, `002_`, ... +- The runner may drop statements after a `BEGIN...END` trigger body. Put each `CREATE VIEW` in its own migration file with no `BEGIN...END` block. +- `PRAGMA foreign_keys = OFF` must be set outside transactions when cascading constraints are involved. + +## Integrations +- Church Center (ngu.churchcenteronline.com): Planning Center embeds for giving and the calendar. \ No newline at end of file From 32d04b63e95e1ba5850903e41807f32f62b9ff80 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 03:24:00 -0500 Subject: [PATCH 04/11] Add commands section to CLAUDE.md and fix role/logo details Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c37542..e04f6b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,7 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + # NGU-Web Website for NGU (Next Generation of Unity), a Unity movement organization with regional chapters in the US and internationally. Full-stack app with a public site and a role-based admin panel. @@ -7,6 +11,21 @@ Website for NGU (Next Generation of Unity), a Unity movement organization with r - Backend: Hono on Node.js, SQLite (WAL mode, STRICT tables) via better-sqlite3 / node:sqlite (in `server/`) - Package manager: pnpm only (never npm or yarn) +## Commands +Frontend (repo root): +- `pnpm dev` / `pnpm build` / `pnpm preview`: Vite +- `pnpm format`: oxfmt +- `pnpm exec tsc`: type-check (`noEmit`; there is no separate lint or typecheck script) +- There is no test suite. + +Backend (`server/`, Node >= 22). The server reads `HOST` (default `127.0.0.1`), `PORT` (default `3001`) and `DB_PATH` (default `./ngu.db`); locally, use `DB_PATH=./dev.db`: +- `DB_PATH=./dev.db pnpm dev`: run with `node --watch` +- `DB_PATH=./dev.db pnpm migrate`: apply migrations without starting the server +- `DB_PATH=./dev.db node src/seed.js`: rebuild content tables from `src/data/`. It wipes every content table first (feedback is kept). Run it from the repo, not the deployed copy. +- `DB_PATH=./dev.db node src/admin-cli.js add|list|passwd|role|disable|enable ...`: the only way accounts are created + +In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API must listen on that port. + ## Deployment (production) - Ubuntu VPS, nginx reverse proxy, systemd service `ngu-api` - App deployed to `/srv/ngu-api`; database at `/var/lib/ngu/ngu.db` @@ -34,10 +53,10 @@ Website for NGU (Next Generation of Unity), a Unity movement organization with r - `src/data/` holds only hardcoded data shared across multiple section files (e.g. `historyDecades.ts`, map grid). Everything else comes from SQLite. - `navConfig.js` is the single source of truth for navigation, routes, and actions (header, footer, pages). - `api.js` is the shared caching client used by frontend data hooks. -- Logos: org logos in `/org-logos/`, event logos in `public/event-logos/`. The `` component hides itself on load error. +- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `` component hides itself on load error. ## Rules and gotchas -- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use `roleCanWrite(user)` / `roleCanDelete(user)` from `lib/roles` and the minimum-rank helpers (`canWrite`, `canDelete`, `isSuper`, `atLeast`). `role === "admin"` silently excludes higher roles and has caused repeated bugs. +- **Role checks must use ladder comparisons, never equality.** Roles rank viewer → editor → admin → superadmin. Use the minimum-rank helpers from `src/lib/roles.ts` (`canWrite`, `canDelete`, `isSuper`, `atLeast`). Where a local variable shadows the name, import with an alias, e.g. `canWrite as roleCanWrite`. `role === "admin"` silently excludes higher roles and has caused repeated bugs. - **Imports need explicit extensions** (`.ts`, `.tsx`, `.js`) everywhere. - **Vite resolves `.js` before `.ts`**, so a `.js` and `.ts` file with the same base name will import the wrong one. Give new hooks distinct names. - **Don't use `fallback: EMPTY` in api.js hooks.** It silently returns empty arrays and hides server errors; let the error state surface. From 2428f4412acb61748f916a4b611f2e39ca49a2a5 Mon Sep 17 00:00:00 2001 From: Zaldimmar Date: Fri, 25 Sep 2026 04:13:46 -0500 Subject: [PATCH 05/11] Type src/ against API and database shapes, fixing implicit anys Adds .d.ts declarations beside the untyped JS modules imported from TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*), with shapes taken from the server routes and migrations. get/post/ patch/del now return unknown unless the caller names the response. Fixes found along the way: - website/email/instagram are bare strings from splitLinks, not Link objects; OrganizationDetail's website and email pills rendered with no href or label and now link correctly. - The chapter map falls back to FALLBACK_COLOR for a region with no colour instead of painting its tiles black. Adds defineSection() so each page manifest entry's props are checked against its own Component. Co-Authored-By: Claude Opus 5.5 --- src/components/ArrowLink.tsx | 10 +- src/components/Layout.tsx | 2 +- src/components/PageShell.tsx | 22 +- src/components/PeopleTiles.tsx | 15 +- src/components/admin/fields.tsx | 101 +++++-- src/data/bannerConfig.d.ts | 10 + src/data/chapters.d.ts | 47 ++++ src/data/eventData.d.ts | 27 ++ src/data/feedbackTypes.d.ts | 7 + src/data/mapGrid.d.ts | 51 ++++ src/data/organizations.d.ts | 21 ++ src/lib/adminSchema.d.ts | 118 +++++++++ src/lib/adminTitle.tsx | 6 +- src/lib/api.d.ts | 26 ++ src/lib/auth.tsx | 35 ++- src/lib/sections.tsx | 58 +++- src/lib/timeline.ts | 4 + src/lib/useContent.ts | 46 +++- src/lib/useRecord.ts | 2 +- src/navConfig.d.ts | 20 ++ src/pages/Community.tsx | 14 +- src/pages/EventDetail.tsx | 4 +- src/pages/Home.tsx | 12 +- src/pages/OrganizationDetail.tsx | 8 +- src/pages/Retreats.tsx | 28 +- src/pages/admin/AdminFeedback.tsx | 75 ++++-- src/pages/admin/AdminHome.tsx | 19 +- src/pages/admin/AdminLayout.tsx | 4 +- src/pages/admin/AdminLogin.tsx | 6 +- src/pages/admin/AdminPanel.tsx | 95 +++++-- src/pages/admin/EntityEdit.tsx | 84 +++--- src/pages/admin/EntityList.tsx | 35 ++- src/pages/admin/RequireRole.tsx | 4 +- src/pages/admin/adminNav.d.ts | 45 ++++ src/pages/sections/EventList-Cards.tsx | 88 ++++-- src/pages/sections/FeedbackForm.tsx | 48 +++- src/pages/sections/OrgList-Card.tsx | 58 +++- src/pages/sections/OrgList-Map.tsx | 339 ++++++++++++++++-------- src/pages/sections/OrgList-Vertical.tsx | 67 ++++- 39 files changed, 1318 insertions(+), 343 deletions(-) create mode 100644 src/data/bannerConfig.d.ts create mode 100644 src/data/chapters.d.ts create mode 100644 src/data/eventData.d.ts create mode 100644 src/data/feedbackTypes.d.ts create mode 100644 src/data/mapGrid.d.ts create mode 100644 src/data/organizations.d.ts create mode 100644 src/lib/adminSchema.d.ts create mode 100644 src/lib/api.d.ts create mode 100644 src/navConfig.d.ts create mode 100644 src/pages/admin/adminNav.d.ts diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index b2f9af0..056fd49 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -4,7 +4,15 @@ import { Link } from "react-router-dom"; ARROW LINK ═══════════════════════════════════════════════════════════════ */ -export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { +type ArrowLinkProps = { + to: string; + label: string; + color: string; + /** Tailwind size classes for the circle. */ + size?: string; +}; + +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }: ArrowLinkProps) { return ( document.querySelector(s.hash)) - .filter(Boolean); + .filter((el): el is Element => el !== null); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 63ce434..496f926 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -29,9 +29,27 @@ /> ═══════════════════════════════════════════════════════════════ */ +import type { ReactNode } from "react"; + const TEAL = "#138ba0"; -export function Section({ section }) { +export type ShellSection = { + id: string; + title: string; + blurb?: string; + accent: string; + background: string; + actions?: ReactNode; + content: ReactNode; +}; + +type PageShellProps = { + title: ReactNode; + intro?: ReactNode; + sections: ShellSection[]; +}; + +export function Section({ section }: { section: ShellSection }) { const { id, title, @@ -70,7 +88,7 @@ export function Section({ section }) { ); } -export default function PageShell({ title, intro, sections }) { +export default function PageShell({ title, intro, sections }: PageShellProps) { return ( <> {/* Page header */} diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index 61fd716..bd738a9 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -164,7 +164,7 @@ export default function PeopleTiles({ Promise.all( specs.map((spec) => - get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({ + get(`/teams/${spec.id}/people`, { ttl }).then((data) => ({ spec, data, })), @@ -198,8 +198,8 @@ export default function PeopleTiles({ let live = true; setFailed(false); - get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) - .then((data: { people: Person[] }) => { + get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data) => { if (!live) return; const byId: Record = {}; for (const person of data.people) byId[String(person.id)] = person; @@ -304,11 +304,10 @@ function resolveAll( } const { peopleslug, ...overrides } = entry; - const merged: Person = { ...base }; - for (const [key, value] of Object.entries(overrides)) { - if (value !== undefined) (merged as Record)[key] = value; - } - resolved.push(merged); + const defined: Partial = Object.fromEntries( + Object.entries(overrides).filter(([, value]) => value !== undefined), + ); + resolved.push({ ...base, ...defined }); } return resolved; diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index 95296f8..c56f092 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -32,7 +32,16 @@ able to read and copy. ═══════════════════════════════════════════════════════════════ */ -import { useRef, useState } from "react"; +import { useRef, useState, type ChangeEvent, type ReactNode } from "react"; + +import type { FieldErrors } from "../../lib/api.js"; +import type { + AdminFieldSpec, + AdminOption, + AdminOptions, + AdminRow, + CollectionSpec, +} from "../../lib/adminSchema.js"; const input = "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + @@ -56,37 +65,56 @@ const inputLocked = /* ── Dotted paths ────────────────────────────────────────────── */ -export function getPath(object, path) { +export function getPath(object: unknown, path: string | null | undefined): unknown { // An entity with no slug has no heading path either, and a missing // path should read as "no value" rather than throwing on .split. if (!path) return undefined; - return path.split(".").reduce((value, key) => value?.[key], object); + return path + .split(".") + .reduce((value, key) => (value == null ? undefined : (value as AdminRow)[key]), object); } -export function setPath(object, path, value) { +export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { const [head, ...rest] = path.split("."); if (rest.length === 0) return { ...object, [head]: value }; - return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; + const inner = (object?.[head] ?? {}) as AdminRow; + return { ...object, [head]: setPath(inner, rest.join("."), value) }; } /* ── Field ───────────────────────────────────────────────────── */ -export function Field({ field, value, row, options, error, onChange }) { +/* [value, label, the option row it came from]. Manifest options + have no row, which is what filterBy's `!raw` lets through. */ +type Choice = [id: string, label: string, raw?: AdminOption]; + +type FieldProps = { + field: AdminFieldSpec; + /* Whatever the row holds at field.path; shown as text. */ + value: unknown; + row?: AdminRow; + options?: AdminOptions | null; + error?: string; + onChange: (value: string | number) => void; +}; + +export function Field({ field, value, row, options, error, onChange }: FieldProps) { const id = `f-${field.path.replace(/\./g, "-")}`; const widget = field.widget ?? "text"; const locked = Boolean(field.readOnly); + const text = value == null ? "" : String(value); - let list = null; + let list: Choice[] = []; let orphaned = false; if (widget === "select") { list = field.optionsFrom - ? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o]) - : (field.options ?? []).map((o) => - Array.isArray(o) ? [o[0], o[1]] : [o, o], + ? (options?.[field.optionsFrom] ?? []).map((o): Choice => [o.id, o.label, o]) + : (field.options ?? []).map((o): Choice => + typeof o === "string" ? [o, o] : [o[0], o[1]], ); - if (field.filterBy && row) { - list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row)); + const { filterBy } = field; + if (filterBy && row) { + list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); } // A stored value with no matching option renders as the blank @@ -102,8 +130,9 @@ export function Field({ field, value, row, options, error, onChange }) { const common = { id, className: `${input} ${error ? inputError : ""}`, - value: value ?? "", - onChange: (e) => onChange(e.target.value), + value: text, + onChange: (e: ChangeEvent) => + onChange(e.target.value), }; return ( @@ -145,7 +174,7 @@ export function Field({ field, value, row, options, error, onChange }) { }`} > - {orphaned && } + {orphaned && } {list.map(([id2, label]) => (