diff --git a/CLAUDE.md b/CLAUDE.md index e04f6b2..214caf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,18 +51,18 @@ In dev, Vite proxies `/api` to the target set in `vite.config.ts`, so the API mu - 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. +- `navConfig.ts` is the single source of truth for navigation, routes, and actions (header, footer, pages). +- `api.ts` is the shared caching client used by frontend data hooks. - 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 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. +- **Don't use `fallback: EMPTY` in api.ts 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. +Descriptor-driven: `server/admin-crud.js` and `admin-schema.js` (server) and `adminSchema.ts` (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. diff --git a/server/src/seed.js b/server/src/seed.js index 7aa11a1..e105d3e 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -42,8 +42,8 @@ import { openDatabase, migrate, tx } from "./db.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const DB_PATH = process.env.DB_PATH ?? "./dev.db"; -const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js"; -const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js"; +const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.ts"; +const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.ts"; // The root organization. Every national retreat hangs off this, and // it's what makes the org_logo fallback work uniformly. diff --git a/src/components/ArrowLink.tsx b/src/components/ArrowLink.tsx index 056fd49..b2f9af0 100644 --- a/src/components/ArrowLink.tsx +++ b/src/components/ArrowLink.tsx @@ -4,15 +4,7 @@ import { Link } from "react-router-dom"; ARROW LINK ═══════════════════════════════════════════════════════════════ */ -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) { +export default function ArrowLink({ to, label, color, size = "h-9 w-9" }) { return ( ( diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5474c73..6b999a7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { NavLink, Link, Outlet, useLocation } from "react-router-dom"; -import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.js"; +import { PAGE_LINKS, PAGE_SECTIONS, NAV_ACTIONS } from "../navConfig.ts"; import Banner from "./Banner.jsx"; import nguLogo from "../assets/NGU_Logo.svg"; import Footer from "./Footer.tsx"; @@ -117,7 +117,7 @@ export default function Layout() { const targets = sections .map((s) => document.querySelector(s.hash)) - .filter((el): el is Element => el !== null); + .filter(Boolean); if (targets.length === 0) return; const observer = new IntersectionObserver( diff --git a/src/components/PeopleTiles.tsx b/src/components/PeopleTiles.tsx index 62dcf4f..ab1b12d 100644 --- a/src/components/PeopleTiles.tsx +++ b/src/components/PeopleTiles.tsx @@ -10,7 +10,7 @@ import { import { Link } from "react-router-dom"; -import { get } from "../lib/api.js"; +import { get } from "../lib/api.ts"; import { isBadId, personHref } from "../lib/hrefs.ts"; import "./PeopleTiles.css"; @@ -174,7 +174,7 @@ export default function PeopleTiles({ Promise.all( specs.map((spec) => - get(`/teams/${spec.id}/people`, { ttl }).then((data) => ({ + get(`/teams/${spec.id}/people`, { ttl }).then((data: TeamResponse) => ({ spec, data, })), @@ -208,8 +208,8 @@ export default function PeopleTiles({ let live = true; setFailed(false); - get<{ people: Person[] }>(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) - .then((data) => { + get(`/people?ids=${encodeURIComponent(slugKey)}`, { ttl }) + .then((data: { people: Person[] }) => { if (!live) return; const byId: Record = {}; for (const person of data.people) byId[String(person.id)] = person; @@ -314,10 +314,11 @@ function resolveAll( } const { peopleslug, ...overrides } = entry; - const defined: Partial = Object.fromEntries( - Object.entries(overrides).filter(([, value]) => value !== undefined), - ); - resolved.push({ ...base, ...defined }); + const merged: Person = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + if (value !== undefined) (merged as any)[key] = value; + } + resolved.push(merged); } return resolved; diff --git a/src/components/admin/fields.tsx b/src/components/admin/fields.tsx index d3a761b..c0fa4d2 100644 --- a/src/components/admin/fields.tsx +++ b/src/components/admin/fields.tsx @@ -32,16 +32,7 @@ able to read and copy. ═══════════════════════════════════════════════════════════════ */ -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"; +import { useRef, useState } from "react"; const input = "w-full rounded-lg border border-[#4a6b72]/25 bg-white px-3 py-2 text-sm text-[#26454c] " + @@ -65,56 +56,37 @@ const inputLocked = /* ── Dotted paths ────────────────────────────────────────────── */ -export function getPath(object: unknown, path: string | null | undefined): unknown { +export function getPath(object, path) { // 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 == null ? undefined : (value as AdminRow)[key]), object); + return path.split(".").reduce((value, key) => value?.[key], object); } -export function setPath(object: AdminRow | null | undefined, path: string, value: unknown): AdminRow { +export function setPath(object, path, value) { const [head, ...rest] = path.split("."); if (rest.length === 0) return { ...object, [head]: value }; - const inner = (object?.[head] ?? {}) as AdminRow; - return { ...object, [head]: setPath(inner, rest.join("."), value) }; + return { ...object, [head]: setPath(object?.[head] ?? {}, rest.join("."), value) }; } /* ── Field ───────────────────────────────────────────────────── */ -/* [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) { +export function Field({ field, value, row, options, error, onChange }: any) { 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: Choice[] = []; + let list: any = null; let orphaned = false; if (widget === "select") { list = field.optionsFrom - ? (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]], + ? (options?.[field.optionsFrom] ?? []).map((o) => [o.id, o.label, o]) + : (field.options ?? []).map((o) => + Array.isArray(o) ? [o[0], o[1]] : [o, o], ); - const { filterBy } = field; - if (filterBy && row) { - list = list.filter(([, , raw]) => !raw || filterBy(raw, row)); + if (field.filterBy && row) { + list = list.filter(([, , raw]) => !raw || field.filterBy(raw, row)); } // A stored value with no matching option renders as the blank @@ -130,9 +102,8 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp const common = { id, className: `${input} ${error ? inputError : ""}`, - value: text, - onChange: (e: ChangeEvent) => - onChange(e.target.value), + value: value ?? "", + onChange: (e) => onChange(e.target.value), }; return ( @@ -174,7 +145,7 @@ export function Field({ field, value, row, options, error, onChange }: FieldProp }`} > - {orphaned && } + {orphaned && } {list.map(([id2, label]) => (