Revert implicit-any typing and convert src/ JavaScript to TypeScript #8
43 changed files with 195 additions and 135 deletions
|
|
@ -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.
|
- 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/`.
|
- 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.
|
- `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).
|
- `navConfig.ts` 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.
|
- `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 `<Logo>` component hides itself on load error.
|
- Logos: org logos in `public/org-logos/` (served at `/org-logos/`), event logos in `public/event-logos/`. The `<Logo>` component hides itself on load error.
|
||||||
|
|
||||||
## Rules and gotchas
|
## 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.
|
- **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.
|
- **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.
|
- **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
|
## 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.
|
- Child collections are deleted and reinserted wholesale. Unsafe for entities referenced by foreign keys elsewhere.
|
||||||
- `reindex: false` prevents cross-entity sort order collisions.
|
- `reindex: false` prevents cross-entity sort order collisions.
|
||||||
- The `OMIT` sentinel distinguishes unsent fields from deliberate clears.
|
- The `OMIT` sentinel distinguishes unsent fields from deliberate clears.
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,8 @@ import { openDatabase, migrate, tx } from "./db.js";
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const DB_PATH = process.env.DB_PATH ?? "./dev.db";
|
const DB_PATH = process.env.DB_PATH ?? "./dev.db";
|
||||||
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.js";
|
const EVENTS_MODULE = process.env.EVENTS_MODULE ?? "../../src/data/events.ts";
|
||||||
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.js";
|
const CHAPTERS_MODULE = process.env.CHAPTERS_MODULE ?? "../../src/data/chapters.ts";
|
||||||
|
|
||||||
// The root organization. Every national retreat hangs off this, and
|
// The root organization. Every national retreat hangs off this, and
|
||||||
// it's what makes the org_logo fallback work uniformly.
|
// it's what makes the org_logo fallback work uniformly.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { SITE_BANNER } from "../data/bannerConfig.js";
|
import { SITE_BANNER } from "../data/bannerConfig.ts";
|
||||||
|
|
||||||
export default function Banner() {
|
export default function Banner() {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } 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 nguLogo from "../assets/NGU_Logo.svg";
|
import nguLogo from "../assets/NGU_Logo.svg";
|
||||||
|
|
||||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { NavLink, Link, Outlet, useLocation } from "react-router-dom";
|
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 Banner from "./Banner.jsx";
|
||||||
import nguLogo from "../assets/NGU_Logo.svg";
|
import nguLogo from "../assets/NGU_Logo.svg";
|
||||||
import Footer from "./Footer.tsx";
|
import Footer from "./Footer.tsx";
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,27 @@
|
||||||
/>
|
/>
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const TEAL = "#138ba0";
|
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 {
|
const {
|
||||||
id,
|
id,
|
||||||
title,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
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 { isBadId, personHref } from "../lib/hrefs.ts";
|
||||||
import "./PeopleTiles.css";
|
import "./PeopleTiles.css";
|
||||||
|
|
||||||
|
|
@ -316,7 +316,7 @@ function resolveAll(
|
||||||
const { peopleslug, ...overrides } = entry;
|
const { peopleslug, ...overrides } = entry;
|
||||||
const merged: Person = { ...base };
|
const merged: Person = { ...base };
|
||||||
for (const [key, value] of Object.entries(overrides)) {
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
|
if (value !== undefined) (merged as any)[key] = value;
|
||||||
}
|
}
|
||||||
resolved.push(merged);
|
resolved.push(merged);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,12 @@ export function setPath(object, path, value) {
|
||||||
|
|
||||||
/* ── Field ───────────────────────────────────────────────────── */
|
/* ── Field ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function Field({ field, value, row, options, error, onChange }) {
|
export function Field({ field, value, row, options, error, onChange }: any) {
|
||||||
const id = `f-${field.path.replace(/\./g, "-")}`;
|
const id = `f-${field.path.replace(/\./g, "-")}`;
|
||||||
const widget = field.widget ?? "text";
|
const widget = field.widget ?? "text";
|
||||||
const locked = Boolean(field.readOnly);
|
const locked = Boolean(field.readOnly);
|
||||||
|
|
||||||
let list = null;
|
let list: any = null;
|
||||||
let orphaned = false;
|
let orphaned = false;
|
||||||
|
|
||||||
if (widget === "select") {
|
if (widget === "select") {
|
||||||
|
|
@ -236,9 +236,9 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
|
||||||
// Which row is in flight, and which one it's currently over.
|
// Which row is in flight, and which one it's currently over.
|
||||||
// Both are per-Repeater, which is what keeps a drag inside a
|
// Both are per-Repeater, which is what keeps a drag inside a
|
||||||
// nested collection from being accepted by the outer one.
|
// nested collection from being accepted by the outer one.
|
||||||
const [dragIndex, setDragIndex] = useState(null);
|
const [dragIndex, setDragIndex] = useState<any>(null);
|
||||||
const [overIndex, setOverIndex] = useState(null);
|
const [overIndex, setOverIndex] = useState<any>(null);
|
||||||
const rowRefs = useRef([]);
|
const rowRefs = useRef<any[]>([]);
|
||||||
|
|
||||||
const update = (index, next) =>
|
const update = (index, next) =>
|
||||||
onChange(list.map((row, i) => (i === index ? next : row)));
|
onChange(list.map((row, i) => (i === index ? next : row)));
|
||||||
|
|
@ -400,7 +400,7 @@ export function Repeater({ spec, rows, options, errors, errorPrefix, onChange })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconButton({ label, onClick, danger, disabled, children }) {
|
function IconButton({ label, onClick, danger = false, disabled = false, children }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
|
|
||||||
Two things live elsewhere:
|
Two things live elsewhere:
|
||||||
|
|
||||||
the tile grid src/data/mapGrid.js. Where a state sits never
|
the tile grid src/data/mapGrid.ts. Where a state sits never
|
||||||
changes, so it isn't worth a round trip.
|
changes, so it isn't worth a round trip.
|
||||||
|
|
||||||
the fetch src/data/organizations.js. One endpoint for
|
the fetch src/data/organizations.ts. One endpoint for
|
||||||
every kind, so a section listing regions and
|
every kind, so a section listing regions and
|
||||||
a section listing partners read alike.
|
a section listing partners read alike.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -25,12 +25,12 @@ import {
|
||||||
areasSentence,
|
areasSentence,
|
||||||
initialsFor,
|
initialsFor,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "./organizations.js";
|
} from "./organizations.ts";
|
||||||
import {
|
import {
|
||||||
areaForChapter,
|
areaForChapter,
|
||||||
buildAreaSlices,
|
buildAreaSlices,
|
||||||
countChaptersByArea,
|
countChaptersByArea,
|
||||||
} from "./mapGrid.js";
|
} from "./mapGrid.ts";
|
||||||
|
|
||||||
const byName = (a, b) => a.name.localeCompare(b.name);
|
const byName = (a, b) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
One request, filtered per section. The Retreats page has three
|
One request, filtered per section. The Retreats page has three
|
||||||
bands of events, and all three call this hook — the cache in
|
bands of events, and all three call this hook — the cache in
|
||||||
api.js keys on the path, so they share a single fetch and each
|
api.ts keys on the path, so they share a single fetch and each
|
||||||
narrows the result to what it shows.
|
narrows the result to what it shows.
|
||||||
|
|
||||||
useEvents({ section: "national" }) one band
|
useEvents({ section: "national" }) one band
|
||||||
|
|
@ -33,13 +33,13 @@
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { useResource } from "../lib/useResource.js";
|
import { useResource } from "../lib/useResource.ts";
|
||||||
|
|
||||||
/* One shared empty list, so a memo keyed on `sections` doesn't
|
/* One shared empty list, so a memo keyed on `sections` doesn't
|
||||||
restart on every render before the data arrives. */
|
restart on every render before the data arrives. */
|
||||||
const NO_SECTIONS = [];
|
const NO_SECTIONS: any[] = [];
|
||||||
|
|
||||||
export function useEvents({ section, host, status, type } = {}) {
|
export function useEvents({ section, host, status, type }: any = {}) {
|
||||||
const { data, error, loading } = useResource("/events");
|
const { data, error, loading } = useResource("/events");
|
||||||
|
|
||||||
const all = data?.events;
|
const all = data?.events;
|
||||||
|
|
@ -71,9 +71,9 @@ export function useEvents({ section, host, status, type } = {}) {
|
||||||
/* Past and upcoming, split. `status` arrives already resolved — the
|
/* Past and upcoming, split. `status` arrives already resolved — the
|
||||||
explicit value when there is one, otherwise derived from ends_on
|
explicit value when there is one, otherwise derived from ends_on
|
||||||
— so nothing here needs to know which of the two it got. */
|
— so nothing here needs to know which of the two it got. */
|
||||||
export function splitByStatus(events = []) {
|
export function splitByStatus(events: any[] = []) {
|
||||||
const upcoming = [];
|
const upcoming: any[] = [];
|
||||||
const past = [];
|
const past: any[] = [];
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
(event.status === "past" ? past : upcoming).push(event);
|
(event.status === "past" ? past : upcoming).push(event);
|
||||||
|
|
@ -86,7 +86,7 @@ export function splitByStatus(events = []) {
|
||||||
EVENT_TYPES order rather than whatever order the rows arrived
|
EVENT_TYPES order rather than whatever order the rows arrived
|
||||||
in. A section with one type has nothing to filter, which is what
|
in. A section with one type has nothing to filter, which is what
|
||||||
lets the chip bar hide itself. */
|
lets the chip bar hide itself. */
|
||||||
export function typesPresent(events = [], declared = []) {
|
export function typesPresent(events: any[] = [], declared: any[] = []) {
|
||||||
const seen = new Set(events.map(e => e.event_type));
|
const seen = new Set(events.map(e => e.event_type));
|
||||||
return declared.filter(entry => seen.has(entry.id));
|
return declared.filter(entry => seen.has(entry.id));
|
||||||
}
|
}
|
||||||
|
|
@ -121,9 +121,9 @@ export function areaForChapter(chapter) {
|
||||||
regionAreas [{ region_id, area_code, share, edge, note }]
|
regionAreas [{ region_id, area_code, share, edge, note }]
|
||||||
regions [{ id, name, color, ... }]
|
regions [{ id, name, color, ... }]
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function buildAreaSlices(regionAreas = [], regions = []) {
|
export function buildAreaSlices(regionAreas: any[] = [], regions: any[] = []) {
|
||||||
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
const regionById = Object.fromEntries(regions.map((r) => [r.id, r]));
|
||||||
const byArea = {};
|
const byArea: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const row of regionAreas) {
|
for (const row of regionAreas) {
|
||||||
const area = AREA_BY_CODE[row.area_code];
|
const area = AREA_BY_CODE[row.area_code];
|
||||||
|
|
@ -151,7 +151,7 @@ export function buildAreaSlices(regionAreas = [], regions = []) {
|
||||||
/* ── Chapter counts per tile ───────────────────────────────────
|
/* ── Chapter counts per tile ───────────────────────────────────
|
||||||
{ WA: 2, MO: 1, CANADA: 1 }
|
{ WA: 2, MO: 1, CANADA: 1 }
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function countChaptersByArea(chapters = []) {
|
export function countChaptersByArea(chapters: any[] = []) {
|
||||||
const counts = {};
|
const counts = {};
|
||||||
for (const chapter of chapters) {
|
for (const chapter of chapters) {
|
||||||
const code = areaForChapter(chapter);
|
const code = areaForChapter(chapter);
|
||||||
|
|
@ -165,7 +165,7 @@ export function countChaptersByArea(chapters = []) {
|
||||||
from the row rather than being looked up in a splits table and
|
from the row rather than being looked up in a splits table and
|
||||||
branched on whether this region is the primary.
|
branched on whether this region is the primary.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
export function areasLabel(regionId, regionAreas = []) {
|
export function areasLabel(regionId, regionAreas: any[] = []) {
|
||||||
return regionAreas
|
return regionAreas
|
||||||
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
.filter((row) => row.region_id === regionId && row.area_code !== "CANADA")
|
||||||
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
.map((row) => (row.note ? `${row.area_code} (${row.note})` : row.area_code))
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
chapter { region_id, region_name, region_color, meets, started }
|
chapter { region_id, region_name, region_color, meets, started }
|
||||||
partner {}
|
partner {}
|
||||||
|
|
||||||
Each kind is a separate request path, so the cache in api.js
|
Each kind is a separate request path, so the cache in api.ts
|
||||||
keys them apart and two sections asking for regions share one
|
keys them apart and two sections asking for regions share one
|
||||||
fetch.
|
fetch.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useResource } from "../lib/useResource.js";
|
import { useResource } from "../lib/useResource.ts";
|
||||||
|
|
||||||
const EMPTY = { organizations: [] };
|
const EMPTY = { organizations: [] };
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function orgPath(org) {
|
||||||
any more.
|
any more.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function areasSentence(areas = []) {
|
export function areasSentence(areas: any[] = []) {
|
||||||
return areas
|
return areas
|
||||||
.filter((area) => area.area_code !== "CANADA")
|
.filter((area) => area.area_code !== "CANADA")
|
||||||
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
.map((area) => (area.note ? `${area.area_code} (${area.note})` : area.area_code))
|
||||||
|
|
@ -1128,7 +1128,7 @@ const frontPage = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ADMIN_ENTITIES = {
|
export const ADMIN_ENTITIES: Record<string, any> = {
|
||||||
organizations,
|
organizations,
|
||||||
events,
|
events,
|
||||||
people,
|
people,
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
import { createContext, useContext, useEffect } from "react";
|
import { createContext, useContext, useEffect } from "react";
|
||||||
|
|
||||||
export const AdminTitleContext = createContext(null);
|
export const AdminTitleContext = createContext<any>(null);
|
||||||
|
|
||||||
/* Publish the name of whatever this page is showing. Clears on
|
/* Publish the name of whatever this page is showing. Clears on
|
||||||
unmount, so navigating away can't leave a stale record name in
|
unmount, so navigating away can't leave a stale record name in
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,10 @@ const DEFAULT_TTL = 60_000;
|
||||||
const cache = new Map(); // path → { at, promise }
|
const cache = new Map(); // path → { at, promise }
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(message, { status, fields } = {}) {
|
status;
|
||||||
|
fields;
|
||||||
|
|
||||||
|
constructor(message, { status, fields }: { status?: number; fields?: any } = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
|
@ -33,7 +36,7 @@ export class ApiError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options: RequestInit = {}) {
|
||||||
const response = await fetch(`${BASE}${path}`, {
|
const response = await fetch(`${BASE}${path}`, {
|
||||||
headers: { Accept: "application/json", ...options.headers },
|
headers: { Accept: "application/json", ...options.headers },
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -68,7 +71,7 @@ async function request(path, options = {}) {
|
||||||
get("/events", { fallback }) → fallback on any failure
|
get("/events", { fallback }) → fallback on any failure
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function get(path, { ttl = DEFAULT_TTL, fallback } = {}) {
|
export function get(path, { ttl = DEFAULT_TTL, fallback }: { ttl?: number; fallback?: any } = {}) {
|
||||||
const hit = cache.get(path);
|
const hit = cache.get(path);
|
||||||
|
|
||||||
if (hit && Date.now() - hit.at < ttl) return hit.promise;
|
if (hit && Date.now() - hit.at < ttl) return hit.promise;
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { get, post, ApiError } from "./api.js";
|
import { get, post, ApiError } from "./api.ts";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext<any>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
table, so there is one answer to "where does an event live" and
|
table, so there is one answer to "where does an event live" and
|
||||||
changing it is changing EVENT_BASE below.
|
changing it is changing EVENT_BASE below.
|
||||||
|
|
||||||
navConfig.js stays the source of truth for the *nav*: these are
|
navConfig.ts stays the source of truth for the *nav*: these are
|
||||||
record routes, which never appear in it.
|
record routes, which never appear in it.
|
||||||
|
|
||||||
── On missing ids ──
|
── On missing ids ──
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
* and the public/ layout stay the frontend's business.
|
* and the public/ layout stay the frontend's business.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { OrgKind } from './hrefs.ts'
|
||||||
|
|
||||||
export type DatePrecision = 'year' | 'month' | 'day'
|
export type DatePrecision = 'year' | 'month' | 'day'
|
||||||
|
|
||||||
/** What an entry is about. Drives the marker and the body layout. */
|
/** What an entry is about. Drives the marker and the body layout. */
|
||||||
|
|
@ -41,6 +43,8 @@ export type TimelineRef = {
|
||||||
kind: RefKind
|
kind: RefKind
|
||||||
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
/** The row's TEXT primary key — an event id, org slug, team slug. */
|
||||||
id: string
|
id: string
|
||||||
|
/** Only on organizations: history.js copies v_timeline.org_kind. */
|
||||||
|
orgKind?: OrgKind
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filename plus the table it came from; the directory is derived
|
/** Filename plus the table it came from; the directory is derived
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,22 @@ export type EventRecord = {
|
||||||
hosts: EventHost[]
|
hosts: EventHost[]
|
||||||
description: string[]
|
description: string[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
instagram?: Link | null
|
/** The instagram link's label, the handle. See splitLinks in shape.js. */
|
||||||
|
instagram?: string | null
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
people: EventPerson[]
|
people: EventPerson[]
|
||||||
awards: EventAward[]
|
awards: EventAward[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of GET /events: shapeEvent without the detail-only
|
||||||
|
* blocks, people and awards. */
|
||||||
|
export type EventListItem = Omit<EventRecord, 'blocks' | 'people' | 'awards'>
|
||||||
|
|
||||||
|
/** An event_sections row, as GET /events sends it beside the list. */
|
||||||
|
export type EventSection = { id: string; name: string; sort_order: number }
|
||||||
|
|
||||||
|
export type EventsResponse = { sections: EventSection[]; events: EventListItem[] }
|
||||||
|
|
||||||
export const useEvent = (id?: string): Resource<EventRecord> =>
|
export const useEvent = (id?: string): Resource<EventRecord> =>
|
||||||
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
useRecord<EventRecord>(detailPath('/events', id), 'event')
|
||||||
|
|
||||||
|
|
@ -157,6 +167,17 @@ export type OrgEvent = {
|
||||||
color?: string | null
|
color?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** regions.scope's CHECK values. */
|
||||||
|
export type RegionScope = 'domestic' | 'international' | 'virtual'
|
||||||
|
|
||||||
|
/** A region_areas row as attachRegionDetails sends it. */
|
||||||
|
export type RegionArea = {
|
||||||
|
area_code: string
|
||||||
|
share: number
|
||||||
|
edge: 'top' | 'bottom' | null
|
||||||
|
note: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type OrganizationRecord = {
|
export type OrganizationRecord = {
|
||||||
id: string
|
id: string
|
||||||
kind: 'national' | 'region' | 'chapter' | 'partner'
|
kind: 'national' | 'region' | 'chapter' | 'partner'
|
||||||
|
|
@ -176,14 +197,16 @@ export type OrganizationRecord = {
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
socials: Link[]
|
socials: Link[]
|
||||||
website?: Link | null
|
/* splitLinks in shape.js lifts these out as bare strings: the
|
||||||
email?: Link | null
|
website's url, the email's label, the instagram handle. */
|
||||||
instagram?: Link | null
|
website?: string | null
|
||||||
|
email?: string | null
|
||||||
|
instagram?: string | null
|
||||||
/** Shape depends on `kind`; empty object for national and partner. */
|
/** Shape depends on `kind`; empty object for national and partner. */
|
||||||
details: {
|
details: {
|
||||||
scope?: string | null
|
scope?: RegionScope | null
|
||||||
map_note?: string | null
|
map_note?: string | null
|
||||||
areas?: Array<{ area_code: string; share?: number | null; edge?: string | null; note?: string | null }>
|
areas?: RegionArea[]
|
||||||
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
chapters?: Array<{ id: string; name: string; location_label?: string | null; logo?: string | null }>
|
||||||
region_id?: string | null
|
region_id?: string | null
|
||||||
region_name?: string | null
|
region_name?: string | null
|
||||||
|
|
@ -197,6 +220,14 @@ export type OrganizationRecord = {
|
||||||
events: OrgEvent[]
|
events: OrgEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row of GET /organizations: the card surface and details,
|
||||||
|
* without the sections only the org's own page loads. */
|
||||||
|
export type OrganizationListItem = Omit<OrganizationRecord, 'teams' | 'awards' | 'events'> & {
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrganizationsResponse = { organizations: OrganizationListItem[] }
|
||||||
|
|
||||||
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
export const useOrganization = (id?: string): Resource<OrganizationRecord> =>
|
||||||
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
useRecord<OrganizationRecord>(detailPath('/organizations', id), 'organization')
|
||||||
|
|
||||||
|
|
@ -214,7 +245,8 @@ export type TeamRecord = {
|
||||||
description: string[]
|
description: string[]
|
||||||
links: Link[]
|
links: Link[]
|
||||||
socials: Link[]
|
socials: Link[]
|
||||||
instagram?: Link | null
|
/** The instagram handle. See splitLinks in shape.js. */
|
||||||
|
instagram?: string | null
|
||||||
blocks: ContentBlock[]
|
blocks: ContentBlock[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { get, invalidate, ApiError } from './api.js'
|
import { get, invalidate, ApiError } from './api.ts'
|
||||||
import type { TimelineItem } from './timeline'
|
import type { TimelineItem } from './timeline'
|
||||||
|
|
||||||
const PATH = '/history'
|
const PATH = '/history'
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* pattern.
|
* pattern.
|
||||||
*
|
*
|
||||||
* Named for what it returns, and deliberately not useResource:
|
* Named for what it returns, and deliberately not useResource:
|
||||||
* src/lib/useResource.js is a different hook — it hands back the
|
* src/lib/useResource.ts is a different hook — it hands back the
|
||||||
* whole response body and takes an options object — and a .ts file
|
* whole response body and takes an options object — and a .ts file
|
||||||
* of the same name would sit one extension away from it. An import
|
* of the same name would sit one extension away from it. An import
|
||||||
* whose target goes missing then resolves to the other file without
|
* whose target goes missing then resolves to the other file without
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
* · `reload` invalidates before it refetches. Without that, the
|
* · `reload` invalidates before it refetches. Without that, the
|
||||||
* retry button inside the 60s TTL hands back the same settled
|
* retry button inside the 60s TTL hands back the same settled
|
||||||
* promise and looks like it did nothing. A *failed* request is
|
* promise and looks like it did nothing. A *failed* request is
|
||||||
* already evicted by api.js, so this matters for the refresh
|
* already evicted by api.ts, so this matters for the refresh
|
||||||
* case rather than the error case.
|
* case rather than the error case.
|
||||||
*
|
*
|
||||||
* · no AbortController. `get` shares one promise between callers,
|
* · no AbortController. `get` shares one promise between callers,
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { get, invalidate, ApiError } from './api.js'
|
import { get, invalidate, ApiError } from './api.ts'
|
||||||
|
|
||||||
export type Resource<T> = {
|
export type Resource<T> = {
|
||||||
data: T | null
|
data: T | null
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get } from "./api.js";
|
import { get } from "./api.ts";
|
||||||
|
|
||||||
export function useResource(path, { ttl, fallback } = {}) {
|
export function useResource(path, { ttl, fallback }: { ttl?: number; fallback?: any } = {}) {
|
||||||
// Seed with the fallback so the first paint has content when one
|
// Seed with the fallback so the first paint has content when one
|
||||||
// is available, rather than flashing a spinner and then the same
|
// is available, rather than flashing a spinner and then the same
|
||||||
// data a moment later.
|
// data a moment later.
|
||||||
const [state, setState] = useState(() => ({
|
const [state, setState] = useState<any>(() => ({
|
||||||
data: fallback,
|
data: fallback,
|
||||||
error: null,
|
error: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import PageShell from '../components/PageShell.tsx'
|
import PageShell, { type ShellSection } from '../components/PageShell.tsx'
|
||||||
import PageState from '../components/PageState.tsx'
|
import PageState from '../components/PageState.tsx'
|
||||||
import ContentBlocks from '../components/ContentBlocks.tsx'
|
import ContentBlocks from '../components/ContentBlocks.tsx'
|
||||||
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
import PeopleTiles, { type PeopleGroupInput } from '../components/PeopleTiles.tsx'
|
||||||
|
|
@ -86,7 +86,7 @@ export default function EventDetail() {
|
||||||
const accent = event.color || TEAL
|
const accent = event.color || TEAL
|
||||||
const groups = peopleGroups(event.people, accent)
|
const groups = peopleGroups(event.people, accent)
|
||||||
|
|
||||||
const sections = [
|
const sections: ShellSection[] = [
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
title: event.theme || 'About',
|
title: event.theme || 'About',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
The roster is not in this page's data. PeopleTiles fetches
|
The roster is not in this page's data. PeopleTiles fetches
|
||||||
/teams/:id/people itself, off v_org_leadership, which already
|
/teams/:id/people itself, off v_org_leadership, which already
|
||||||
decides who counts as current and public. Two requests, both
|
decides who counts as current and public. Two requests, both
|
||||||
cached for 60s by api.js, and one set of visibility rules.
|
cached for 60s by api.ts, and one set of visibility rules.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { del, get, patch, ApiError } from "../../lib/api.js";
|
import { del, get, patch, ApiError } from "../../lib/api.ts";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
import { canWrite as roleCanWrite, canDelete } from "../../lib/roles.ts";
|
||||||
import { feedbackTypeLabel } from "../../data/feedbackTypes.js";
|
import { feedbackTypeLabel } from "../../data/feedbackTypes.ts";
|
||||||
|
|
||||||
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
const STATUSES = ["new", "read", "actioned", "archived", "spam"];
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ function locationOf(row) {
|
||||||
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
function FeedbackCard({ row, onChange, onRemove, canWrite, canRemove }) {
|
||||||
const [note, setNote] = useState(row.admin_note ?? "");
|
const [note, setNote] = useState(row.admin_note ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<any>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
|
||||||
const noteDirty = note !== (row.admin_note ?? "");
|
const noteDirty = note !== (row.admin_note ?? "");
|
||||||
|
|
@ -232,11 +232,11 @@ export default function AdminFeedback() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [search, setSearch] = useState(""); // applied, not typed
|
const [search, setSearch] = useState(""); // applied, not typed
|
||||||
|
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const [counts, setCounts] = useState({});
|
const [counts, setCounts] = useState<any>({});
|
||||||
const [cursor, setCursor] = useState(null);
|
const [cursor, setCursor] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<any>(null);
|
||||||
|
|
||||||
// Minimums, not equality — see lib/roles.ts.
|
// Minimums, not equality — see lib/roles.ts.
|
||||||
const canWrite = roleCanWrite(user);
|
const canWrite = roleCanWrite(user);
|
||||||
|
|
@ -298,7 +298,7 @@ export default function AdminFeedback() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs: any[] = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
...STATUSES.map((s) => ({ id: s, label: s, count: counts[s] })),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
the header deliberately drops its tab row here so the same links
|
the header deliberately drops its tab row here so the same links
|
||||||
aren't drawn twice — and a standing info panel on the right.
|
aren't drawn twice — and a standing info panel on the right.
|
||||||
|
|
||||||
The cards come from adminNav.js, the same list the CMS header
|
The cards come from adminNav.ts, the same list the CMS header
|
||||||
reads, so a new entity shows up here the moment it's registered.
|
reads, so a new entity shows up here the moment it's registered.
|
||||||
Forms sit in their own block below: they're submissions coming
|
Forms sit in their own block below: they're submissions coming
|
||||||
in rather than content going out, and there'll be more of them
|
in rather than content going out, and there'll be more of them
|
||||||
|
|
@ -22,7 +22,7 @@ import { Link } from "react-router-dom";
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { SITE_VERSION } from "../../lib/version.ts";
|
import { SITE_VERSION } from "../../lib/version.ts";
|
||||||
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
import { ROLE_LABELS, isSuper } from "../../lib/roles.ts";
|
||||||
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.js";
|
import { CMS_NAV, FORMS_NAV, PANEL_NAV, target } from "./adminNav.ts";
|
||||||
|
|
||||||
/* One card. The title link is stretched over the whole card with
|
/* One card. The title link is stretched over the whole card with
|
||||||
`after:absolute`, which makes the card clickable without nesting
|
`after:absolute`, which makes the card clickable without nesting
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
matches,
|
matches,
|
||||||
navFor,
|
navFor,
|
||||||
target,
|
target,
|
||||||
} from "./adminNav.js";
|
} from "./adminNav.ts";
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
@ -56,7 +56,7 @@ export default function AdminLayout() {
|
||||||
// What the page below has published about itself — a record
|
// What the page below has published about itself — a record
|
||||||
// name, or null on a list. setDetail is stable so publishing
|
// name, or null on a list. setDetail is stable so publishing
|
||||||
// can't loop.
|
// can't loop.
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState<any>(null);
|
||||||
const stableSet = useCallback((value) => setDetail(value), []);
|
const stableSet = useCallback((value) => setDetail(value), []);
|
||||||
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
const titleContext = useMemo(() => ({ setDetail: stableSet }), [stableSet]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { ApiError } from "../../lib/api.js";
|
import { ApiError } from "../../lib/api.ts";
|
||||||
|
|
||||||
const GOOGLE_ENABLED = false;
|
const GOOGLE_ENABLED = false;
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default function AdminLogin() {
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<any>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const destination = location.state?.from?.pathname ?? "/admin/home";
|
const destination = location.state?.from?.pathname ?? "/admin/home";
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { del, get, patch } from "../../lib/api.js";
|
import { del, get, patch } from "../../lib/api.ts";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
import { ROLES, ROLE_LABELS, ROLE_NOTES } from "../../lib/roles.ts";
|
||||||
|
|
@ -50,7 +50,7 @@ function uptime(seconds) {
|
||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Block({ title, note, children }) {
|
function Block({ title, note, children }: any) {
|
||||||
return (
|
return (
|
||||||
<section className="mt-8 first:mt-0">
|
<section className="mt-8 first:mt-0">
|
||||||
<div className="flex items-baseline gap-3">
|
<div className="flex items-baseline gap-3">
|
||||||
|
|
@ -77,21 +77,21 @@ export default function AdminPanel() {
|
||||||
const { user: me } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [data, setData] = useState(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<any>(null);
|
||||||
|
|
||||||
// Which row is mid-request, and what went wrong on it. Scoped to
|
// Which row is mid-request, and what went wrong on it. Scoped to
|
||||||
// the row so a failure on one account doesn't blank the table.
|
// the row so a failure on one account doesn't blank the table.
|
||||||
const [busyId, setBusyId] = useState(null);
|
const [busyId, setBusyId] = useState<any>(null);
|
||||||
const [rowError, setRowError] = useState(null);
|
const [rowError, setRowError] = useState<any>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
setData(await get("/admin/panel/overview", { ttl: 0 }));
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
setError(err.message || "Couldn't load the panel.");
|
setError(err.message || "Couldn't load the panel.");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -121,7 +121,7 @@ export default function AdminPanel() {
|
||||||
setRowError(null);
|
setRowError(null);
|
||||||
try {
|
try {
|
||||||
mergeUser(await work());
|
mergeUser(await work());
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
if (isUnauthorized(err)) return navigate("/admin/login", { replace: true });
|
||||||
setRowError({ id, message: err.message || "That didn't work." });
|
setRowError({ id, message: err.message || "That didn't work." });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { get, post, patch, del, ApiError } from "../../lib/api.js";
|
import { get, post, patch, del, ApiError } from "../../lib/api.ts";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
import { useAdminDetail } from "../../lib/adminTitle.tsx";
|
||||||
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.js";
|
import { ADMIN_ENTITIES, slugify } from "../../lib/adminSchema.ts";
|
||||||
import { atLeast } from "../../lib/roles.ts";
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
import { Field, FieldGrid, Repeater, getPath, setPath } from "../../components/admin/fields.tsx";
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ function friendly(message, singular) {
|
||||||
|
|
||||||
export default function EntityEdit() {
|
export default function EntityEdit() {
|
||||||
const { entity: entityKey, id } = useParams();
|
const { entity: entityKey, id } = useParams();
|
||||||
const manifest = ADMIN_ENTITIES[entityKey];
|
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
|
@ -120,17 +120,17 @@ export default function EntityEdit() {
|
||||||
// names the field to read instead.
|
// names the field to read instead.
|
||||||
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
const headingPath = slugPaths[slugPaths.length - 1] ?? manifest?.titleFrom;
|
||||||
|
|
||||||
const [form, setForm] = useState(null);
|
const [form, setForm] = useState<any>(null);
|
||||||
const [options, setOptions] = useState({});
|
const [options, setOptions] = useState<any>({});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState<any>({});
|
||||||
const [message, setMessage] = useState(null);
|
const [message, setMessage] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [slugTouched, setSlugTouched] = useState(false);
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
|
||||||
// The last state the server confirmed. Everything else compares
|
// The last state the server confirmed. Everything else compares
|
||||||
// against this to decide whether there's anything to lose.
|
// against this to decide whether there's anything to lose.
|
||||||
const baseline = useRef(null);
|
const baseline = useRef<any>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,22 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, Navigate, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { get, ApiError } from "../../lib/api.js";
|
import { get, ApiError } from "../../lib/api.ts";
|
||||||
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
import { isUnauthorized, useAuth } from "../../lib/auth.tsx";
|
||||||
import { ADMIN_ENTITIES } from "../../lib/adminSchema.js";
|
import { ADMIN_ENTITIES } from "../../lib/adminSchema.ts";
|
||||||
import { atLeast } from "../../lib/roles.ts";
|
import { atLeast } from "../../lib/roles.ts";
|
||||||
|
|
||||||
export default function EntityList() {
|
export default function EntityList() {
|
||||||
const { entity: entityKey } = useParams();
|
const { entity: entityKey } = useParams();
|
||||||
const manifest = ADMIN_ENTITIES[entityKey];
|
const manifest = ADMIN_ENTITIES[entityKey ?? ""];
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState<any[]>([]);
|
||||||
const [options, setOptions] = useState({});
|
const [options, setOptions] = useState<any>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<any>(null);
|
||||||
const [query, setQuery] = useState(params.get("q") ?? "");
|
const [query, setQuery] = useState(params.get("q") ?? "");
|
||||||
|
|
||||||
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
// Minimum rank, never equality. POST /api/admin/:entity is gated
|
||||||
|
|
@ -79,7 +79,7 @@ export default function EntityList() {
|
||||||
setParams(next, { replace: true });
|
setParams(next, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(filter, value) {
|
function labelFor(filter) {
|
||||||
if (filter.optionsFrom) {
|
if (filter.optionsFrom) {
|
||||||
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
return (options[filter.optionsFrom] ?? []).map((o) => [o.id, o.label]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@
|
||||||
|
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { Navigate, Outlet } from "react-router-dom";
|
||||||
import { useAuth } from "../../lib/auth.tsx";
|
import { useAuth } from "../../lib/auth.tsx";
|
||||||
import { atLeast } from "../../lib/roles.ts";
|
import { atLeast, type Role } from "../../lib/roles.ts";
|
||||||
import { ADMIN_HOME } from "./adminNav.js";
|
import { ADMIN_HOME } from "./adminNav.ts";
|
||||||
|
|
||||||
export default function RequireRole({ role = "superadmin" }) {
|
export default function RequireRole({ role = "superadmin" }: { role?: Role }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
// RequireAuth is already showing its own placeholder above this.
|
// RequireAuth is already showing its own placeholder above this.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ADMIN NAVIGATION — src/pages/admin/adminNav.js
|
ADMIN NAVIGATION — src/pages/admin/adminNav.ts
|
||||||
|
|
||||||
Lifted out of AdminLayout because two things read it now: the
|
Lifted out of AdminLayout because two things read it now: the
|
||||||
header tabs and the card grid on the home page. Adding an entity
|
header tabs and the card grid on the home page. Adding an entity
|
||||||
|
|
@ -99,7 +99,7 @@ export const PANEL_NAV = {
|
||||||
superOnly: true,
|
superOnly: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NAV = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
|
export const NAV: any[] = [...CMS_NAV, FORMS_NAV, PANEL_NAV];
|
||||||
|
|
||||||
/* What this user may see. Call it with the user from useAuth. */
|
/* What this user may see. Call it with the user from useAuth. */
|
||||||
export function navFor(user) {
|
export function navFor(user) {
|
||||||
|
|
@ -42,9 +42,8 @@
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
import { typesPresent, useEvents } from '../../data/eventData.js'
|
import { typesPresent, useEvents } from '../../data/eventData.ts'
|
||||||
import type { EventFilter } from '../../data/eventData.js'
|
import { EVENT_TYPES, eventTypeLabel, type EventType } from '../../lib/eventTypes.ts'
|
||||||
import { EVENT_TYPES, eventTypeLabel } from '../../lib/eventTypes.ts'
|
|
||||||
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
|
import { firstOccurrenceFrom, occurrencesBetween, seriesTimes } from '../../lib/eventSeries.ts'
|
||||||
import { eventHref } from '../../lib/hrefs.ts'
|
import { eventHref } from '../../lib/hrefs.ts'
|
||||||
import type { EventListItem } from '../../lib/useContent.ts'
|
import type { EventListItem } from '../../lib/useContent.ts'
|
||||||
|
|
@ -63,7 +62,11 @@ const BODY = '#4a6b72'
|
||||||
|
|
||||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
type EventCalendarProps = EventFilter & {
|
type EventCalendarProps = {
|
||||||
|
section?: string
|
||||||
|
host?: string
|
||||||
|
status?: EventListItem['status']
|
||||||
|
type?: EventType | EventType[]
|
||||||
accent?: string
|
accent?: string
|
||||||
/** Which visitor controls render. A pinned prop hides its own. */
|
/** Which visitor controls render. A pinned prop hides its own. */
|
||||||
controls?: CalendarControl[]
|
controls?: CalendarControl[]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.ts";
|
||||||
import { eventHref } from "../../lib/hrefs.ts";
|
import { eventHref } from "../../lib/hrefs.ts";
|
||||||
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||||
import { seriesLabel } from "../../lib/eventSeries.ts";
|
import { seriesLabel } from "../../lib/eventSeries.ts";
|
||||||
|
|
@ -454,7 +454,7 @@ export default function EventListCards({
|
||||||
accent = TEAL,
|
accent = TEAL,
|
||||||
defaultColor,
|
defaultColor,
|
||||||
empty = "· Events coming soon, stay connected for announcements ·",
|
empty = "· Events coming soon, stay connected for announcements ·",
|
||||||
}) {
|
}: any) {
|
||||||
const { events: fetched, loading, error } = useEvents({
|
const { events: fetched, loading, error } = useEvents({
|
||||||
section,
|
section,
|
||||||
host,
|
host,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { post, ApiError } from "../../lib/api.js";
|
import { post, ApiError } from "../../lib/api.ts";
|
||||||
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.js";
|
import { PAGE_LINKS, PAGE_SECTIONS } from "../../navConfig.ts";
|
||||||
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.js";
|
import { FEEDBACK_TYPES } from "../../data/feedbackTypes.ts";
|
||||||
|
|
||||||
const ACCENT = "#138ba0";
|
const ACCENT = "#138ba0";
|
||||||
const MUTED = "#4a6b72";
|
const MUTED = "#4a6b72";
|
||||||
|
|
@ -217,7 +217,7 @@ function describeLocation(page, section) {
|
||||||
/* ── The form ────────────────────────────────────────────────── */
|
/* ── The form ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function FeedbackForm() {
|
export default function FeedbackForm() {
|
||||||
const [type, setType] = useState(null);
|
const [type, setType] = useState<any>(null);
|
||||||
const [page, setPage] = useState(SITE_WIDE);
|
const [page, setPage] = useState(SITE_WIDE);
|
||||||
const [section, setSection] = useState(WHOLE_PAGE);
|
const [section, setSection] = useState(WHOLE_PAGE);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
@ -229,8 +229,8 @@ export default function FeedbackForm() {
|
||||||
|
|
||||||
// idle → sending → sent, or back to idle with an error to show.
|
// idle → sending → sent, or back to idle with an error to show.
|
||||||
const [status, setStatus] = useState("idle");
|
const [status, setStatus] = useState("idle");
|
||||||
const [formError, setFormError] = useState(null);
|
const [formError, setFormError] = useState<any>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState({});
|
const [fieldErrors, setFieldErrors] = useState<any>({});
|
||||||
|
|
||||||
const sending = status === "sending";
|
const sending = status === "sending";
|
||||||
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
const ready = Boolean(type) && message.trim().length >= MIN_MESSAGE;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
initialsFor,
|
initialsFor,
|
||||||
orgPath,
|
orgPath,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "../../data/organizations.js";
|
} from "../../data/organizations.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — CARDS
|
ORGANIZATION LIST — CARDS
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useCommunity } from "../../data/chapters.js";
|
import { useCommunity } from "../../data/chapters.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import ArrowLink from "../../components/ArrowLink.tsx";
|
import ArrowLink from "../../components/ArrowLink.tsx";
|
||||||
import { initialsFor, orgPath } from "../../data/organizations.js";
|
import { initialsFor, orgPath } from "../../data/organizations.ts";
|
||||||
import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
import { AREAS, AREA_NAMES } from "../../data/mapGrid.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — MAP
|
ORGANIZATION LIST — MAP
|
||||||
|
|
@ -25,7 +25,7 @@ import { AREAS, AREA_NAMES } from "../../data/mapGrid.js";
|
||||||
two colors. If you later want true geography, the swap point is
|
two colors. If you later want true geography, the swap point is
|
||||||
<RegionMap> — everything else works off the data.
|
<RegionMap> — everything else works off the data.
|
||||||
|
|
||||||
The tile layout comes from mapGrid.js; who paints what comes
|
The tile layout comes from mapGrid.ts; who paints what comes
|
||||||
from the API. A state and the Canada band are the same shape
|
from the API. A state and the Canada band are the same shape
|
||||||
now, a tile with a span, so the map is one loop.
|
now, a tile with a span, so the map is one loop.
|
||||||
═══════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
@ -59,7 +59,7 @@ const INTL_TITLE = "International Unity Regions";
|
||||||
vanishing. When organization and person pages arrive this should
|
vanishing. When organization and person pages arrive this should
|
||||||
move to a shared component; small enough to live here until then.
|
move to a shared component; small enough to live here until then.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
function Blocks({ blocks = [], color }) {
|
function Blocks({ blocks = [] as any[], color }) {
|
||||||
if (blocks.length === 0) return null;
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -145,7 +145,7 @@ function Tile({
|
||||||
size,
|
size,
|
||||||
width = size,
|
width = size,
|
||||||
label,
|
label,
|
||||||
slices = [],
|
slices = [] as any[],
|
||||||
count = 0,
|
count = 0,
|
||||||
selected,
|
selected,
|
||||||
hovered,
|
hovered,
|
||||||
|
|
@ -248,7 +248,7 @@ function Tile({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RegionMap({ slices, chapterCounts, ...props }) {
|
function RegionMap({ slices, chapterCounts, ...props }: any) {
|
||||||
const size = TILE - PAD * 2;
|
const size = TILE - PAD * 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -306,7 +306,7 @@ function LegendButton({ region, selected, setSelected, hovered, setHovered }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Legend({ domestic, international, onMapIds, ...props }) {
|
function Legend({ domestic, international, onMapIds, ...props }: any) {
|
||||||
const { selected, setSelected } = props;
|
const { selected, setSelected } = props;
|
||||||
|
|
||||||
// A region is on the map if it paints a tile. West Central used
|
// A region is on the map if it paints a tile. West Central used
|
||||||
|
|
@ -615,7 +615,7 @@ function ChapterDetail({ chapter, region, onClose }) {
|
||||||
<div className="mt-5 flex flex-wrap gap-3">
|
<div className="mt-5 flex flex-wrap gap-3">
|
||||||
{orgPath(chapter) && (
|
{orgPath(chapter) && (
|
||||||
<Link
|
<Link
|
||||||
to={orgPath(chapter)}
|
to={orgPath(chapter)!}
|
||||||
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="py-2 px-5 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
style={{ background: colorOf(region), color: "#ffffff" }}
|
style={{ background: colorOf(region), color: "#ffffff" }}
|
||||||
>
|
>
|
||||||
|
|
@ -769,14 +769,14 @@ export default function OrgListMap({ view = "map", accent = FALLBACK_COLOR }) {
|
||||||
subtextFor,
|
subtextFor,
|
||||||
} = useCommunity();
|
} = useCommunity();
|
||||||
|
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState<any>(null);
|
||||||
const [hovered, setHovered] = useState(null);
|
const [hovered, setHovered] = useState<any>(null);
|
||||||
const [openId, setOpenId] = useState(null); // no card open on arrival
|
const [openId, setOpenId] = useState<any>(null); // no card open on arrival
|
||||||
|
|
||||||
// The list scrolls itself to whatever the map or legend points at.
|
// The list scrolls itself to whatever the map or legend points at.
|
||||||
const listRef = useRef(null);
|
const listRef = useRef<any>(null);
|
||||||
const regionRefs = useRef({});
|
const regionRefs = useRef<any>({});
|
||||||
const chapterRefs = useRef({});
|
const chapterRefs = useRef<any>({});
|
||||||
|
|
||||||
const scrollListTo = el => {
|
const scrollListTo = el => {
|
||||||
const box = listRef.current;
|
const box = listRef.current;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
initialsFor,
|
initialsFor,
|
||||||
orgPath,
|
orgPath,
|
||||||
useOrganizations,
|
useOrganizations,
|
||||||
} from "../../data/organizations.js";
|
} from "../../data/organizations.ts";
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
ORGANIZATION LIST — VERTICAL
|
ORGANIZATION LIST — VERTICAL
|
||||||
|
|
@ -47,7 +47,7 @@ const FALLBACK_COLOR = "#4a6b72";
|
||||||
it — a link nested in a button is invalid, and a screen reader
|
it — a link nested in a button is invalid, and a screen reader
|
||||||
announces the whole row as one confused control.
|
announces the whole row as one confused control.
|
||||||
───────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────── */
|
||||||
function RowButton({ as: As = "button", color, children, ...rest }) {
|
function RowButton({ as: As = "button", color, children, ...rest }: any) {
|
||||||
return (
|
return (
|
||||||
<As
|
<As
|
||||||
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
className="shrink-0 whitespace-nowrap py-2 px-4 rounded-xl font-700 text-sm transition-transform duration-200 hover:scale-105"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue