diff --git a/.gitignore b/.gitignore index a98349f..7068201 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ next-env.d.ts # misc .DS_Store *.pem +/LNSITE/ # debug npm-debug.log* diff --git a/NOTES.md b/NOTES.md index e1d7147..1a99661 100644 --- a/NOTES.md +++ b/NOTES.md @@ -41,18 +41,44 @@ normal terminal. Defined in `tailwind.config.ts` + `app/globals.css`. -- **Palettes**: `ink` (neutrals), `gulf` (primary blue), `sand` (accent warm), - `coral` (alert/accent). -- **Theme variables**: `--bg`, `--bg-elevated`, `--fg`, `--muted`, `--line`, - `--accent`. Light + dark modes are real, not just a class swap — every - surface reads from these vars. -- **Background layers**: `bg-canvas` (radial wash) + `bg-grid` (subtle grid) - + `bg-noise` (svg grain) stacked behind everything. -- **Components**: `surface`, `shell` (glass panel), `btn-primary` / - `btn-ghost`, `pill`, `hairline`. Custom animations: `fade-up`, `pulse-ring`, - `drift`, `shimmer`. -- **Typography**: `font-display` for headings (Space Grotesk), `font-sans` - body, `font-mono` for kickers/labels. +The site is a non-profit public-information site first. Structure, type and +data carry the page; ornament is deliberate and scoped. The 2026 redesign +removed the glass/gradient/glow layer the site had accumulated, then added a +single technical texture back in two places on purpose. + +- **Palettes are sampled from `public/logo.svg`.** `gulf` runs from the logo's + bright cyan-teal (`#49c7cc`) and mint (`#6ed7c8`) down to its deep teal + (`#0d6070`) and hull navies. `ink` is a navy-tinted neutral built on the same + logo navies, so the greys read as brand rather than as grey. `sand` is + **status only** (advisories, "planned"); `coral` is errors. +- **Three accent tokens, because one value cannot do all three jobs at brand + brightness:** + - `--accent` — small text (links, 11px labels). The brightest point on the + logo's hue that still clears AA 4.5:1 on white. + - `--accent-bright` — display type only, which needs just 3:1, so headline + highlights go brighter than body copy is allowed to. + - `--accent-solid` — button fills. Always pairs with `ink-950` text (6.3:1); + white on a fill this bright would fail. Hover goes brighter, not darker. +- **Other theme variables**: `--bg`, `--bg-elevated`, `--bg-sunken`, `--fg`, + `--fg-muted`, `--line`, `--line-strong`. Light mode uses a pure `#ffffff` canvas + with `#f8fafc` sunken tones and `#e2e8f0` crisp slate borders; dark mode uses + deep obsidian slate `#0b0f19` canvas, `#111827` elevated surfaces, and `#1f2937` borders. +- **Background & Surfaces**: The site uses clean, natural contrast without artificial + mathematical grid lines (`.grid-tech`) or radial glow blobs (`.wash-brand`). + Surfaces (`surface`, `tile`) feature crisp 1px borders, solid fills, and no synthetic glass/backdrop blur. +- **Buttons & Badges**: + - `btn-primary` (brand fill, dark ink) and `btn-ghost` (bordered). + - `.badge` and `.badge-accent` for technical hardware and frequency specifications (`915 MHz`, `MeshCore`, `Open Source`). + - Steady status indicators instead of distracting pulsating radar dots. +- **Homepage Structure**: + - Authoritative hero copy focused on community and storm resilience. + - Live network telemetry and framed live explorer embed. + - "How It Actually Works" explaining 915 MHz ISM radio, multi-hop mesh routing, and hurricane resilience. + - Real hardware pathways (Companion Radio, Solar Rooftop Repeater, Room Server & Base Station). + - Community spotlight on the weekly Monday Voice Net (8:00 PM CST on Discord). + - Regional coverage directory with node counts and repeater hosting invitations. +- **Type**: `font-display` (Space Grotesk) headings, `font-sans` body, + `font-mono` for data, badges, and technical specs. Figures use `.tabular`. ## Pages diff --git a/README.md b/README.md index 2016911..c48d11c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Other scripts: ``` app/ layout.tsx Root layout: fonts, theme bootstrap, header, footer - page.tsx Homepage (hero with embedded Analyzer, live stats, network bento, Meshcore stack snapshot, how-it-works, regions) + page.tsx Homepage (mission, live network panel with map + stats, what we do, get started, coverage, get involved) meshmap/page.tsx Live maps (Meshcore Analyzer + Meshtastic Meshview) and MQTT instructions links/page.tsx Curated guides, community, and upstream resources emailsignup/page.tsx Newsletter signup (Listmonk-backed) @@ -37,8 +37,8 @@ app/ globals.css Design tokens, surface/utility classes icon.svg Browser tab icon components/ - site-header.tsx Floating glass nav with GitHub + theme toggle - site-footer.tsx Multi-column footer with supporters/partners + site-header.tsx Sticky solid nav with GitHub + theme toggle + site-footer.tsx Four-column footer with supporters/partners theme-toggle.tsx Light/dark switcher button theme-script.tsx Inline pre-paint script (no FOUC) live-map.tsx iframe wrapper for embedded maps (skeleton + error fallback) @@ -53,17 +53,17 @@ eslint.config.mjs Flat ESLint config wrapping next/core-web-vitals + next/ ## Design language -- **Type**: Inter (UI), Space Grotesk (display), JetBrains Mono (eyebrows / monospace details). -- **Color**: deep ink navy, vibrant gulf teal/cyan, warm sand amber, coral accents. -- **Surfaces**: subtle glass cards (`.surface`, `.surface-strong`, `.tile`, `.tile-accent`). -- **Motion**: pre-paint theme bootstrap (no flash), gentle hover lifts, animated skeleton on map embeds. -- **Accessibility**: focus rings, `prefers-reduced-motion` honored, semantic landmarks. +- **Type**: Inter (UI), Space Grotesk (display), JetBrains Mono (data + labels). +- **Color**: sampled from `public/logo.svg` — bright gulf teal on navy-tinted + neutrals. Warm sand is reserved for status, never decoration. +- **Contrast**: three accent tokens split the small-text / display / button-fill + jobs so brand brightness never costs legibility. All pairs clear WCAG AA. +- **Surfaces**: flat 1px-bordered cards (`.surface`, `.tile`) — no glass, + no glow. +- **Texture**: one masked grid + wash band, declared once in the root layout. +- **Motion**: pre-paint theme bootstrap (no flash), `fade-up`, and a live dot + on genuinely live data. `prefers-reduced-motion` honored. +- **Accessibility**: skip link, focus rings, semantic landmarks, tabular + numerals on data. -## Replacing content - -- **Newsletter** — list IDs and Listmonk endpoint live at the top of `app/emailsignup/page.tsx` (`ALERTS_LIST_ID`, `NEWS_LIST_ID`, and the `fetch()` URL). -- **Supporters / partners** — individual supporters in `components/site-footer.tsx`; partner logos in `lib/partners.ts` with host logos in `public/supporters/`. -- **Hero copy, stats, regions, how-it-works** — `app/page.tsx`. Regions support a `forceLive: true` flag to show as live even when the upstream API count is zero (currently used for Mississippi). -- **GitHub org link** — `components/site-header.tsx` and `components/site-footer.tsx` both point at [`github.com/GulfCoastMesh`](https://github.com/GulfCoastMesh). -- **Mesh Monitor API** — [`API.md`](./API.md) and [`openapi.yaml`](./openapi.yaml) document the API. `/mesh-monitor` hosts reserve, duplicates, and network reports (proxied via `app/api/mesh-monitor/*` and `lib/mesh-monitor.ts`). -- **Setup wizard** — repeater flow in `app/setup/page.tsx` reserves MeshBuddy prefixes via `/api/meshbuddy/*` (proxies to `MESHBUDDY_API_BASE_URL`, default `https://meshbuddy.gulfcoastmesh.org`). Email is required for reservation; firmware versions come from MeshCore GitHub releases. +See [`NOTES.md`](./NOTES.md) for the full token list and the reasoning. diff --git a/app/api/mesh-replay/route.ts b/app/api/mesh-replay/route.ts new file mode 100644 index 0000000..d38805b --- /dev/null +++ b/app/api/mesh-replay/route.ts @@ -0,0 +1,169 @@ +import { NextResponse } from "next/server"; + +type AnalyzerPacket = { + payload_type?: unknown; + timestamp?: unknown; + first_seen?: unknown; +}; + +type ReplayEvent = { + offsetMs: number; + payloadType: number; +}; + +type ReplayPayload = { + durationMs: number; + events: ReplayEvent[]; +}; + +const ANALYZER_URL = + process.env.ANALYZER_API_URL || "https://analyzer.gulfcoastmesh.org"; +const WINDOW_MS = 5 * 60 * 1000; +const REPLAY_DURATION_MS = 20 * 1000; +const MAX_REPLAY_EVENTS = 500; +const CACHE_DURATION_MS = 5 * 60 * 1000; + +let cachedReplay: { expiresAt: number; payload: ReplayPayload } | null = null; + +const asTimestamp = (value: unknown): number | null => { + if (typeof value !== "string") return null; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; +}; + +const busiestWindow = (timestamps: number[]): [number, number] | null => { + if (timestamps.length === 0) return null; + + let left = 0; + let bestLeft = 0; + let bestRight = 0; + + for (let right = 0; right < timestamps.length; right += 1) { + while (timestamps[right] - timestamps[left] > WINDOW_MS) left += 1; + if (right - left > bestRight - bestLeft) { + bestLeft = left; + bestRight = right; + } + } + + return [timestamps[bestLeft], timestamps[bestLeft] + WINDOW_MS]; +}; + +const sampleEvents = (events: ReplayEvent[]): ReplayEvent[] => { + if (events.length <= MAX_REPLAY_EVENTS) return events; + + return Array.from({ length: MAX_REPLAY_EVENTS }, (_, index) => { + const sourceIndex = Math.floor( + (index * (events.length - 1)) / (MAX_REPLAY_EVENTS - 1) + ); + return events[sourceIndex]; + }); +}; + +const loadReplay = async (): Promise => { + const now = Date.now(); + const since = new Date(now - 24 * 60 * 60 * 1000).toISOString(); + const timestampsUrl = new URL("/api/packets/timestamps", ANALYZER_URL); + timestampsUrl.searchParams.set("since", since); + + const timestampsResponse = await fetch(timestampsUrl, { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }); + if (!timestampsResponse.ok) { + throw new Error( + `Analyzer timestamps returned ${timestampsResponse.status}` + ); + } + + const rawTimestamps: unknown = await timestampsResponse.json(); + if (!Array.isArray(rawTimestamps)) throw new Error("Invalid timestamp list"); + + const timestamps = rawTimestamps + .map(asTimestamp) + .filter((timestamp): timestamp is number => timestamp !== null) + .sort((first, second) => first - second); + const window = busiestWindow(timestamps); + if (!window) return { durationMs: REPLAY_DURATION_MS, events: [] }; + + const packetsUrl = new URL("/api/packets", ANALYZER_URL); + packetsUrl.searchParams.set("since", new Date(window[0]).toISOString()); + packetsUrl.searchParams.set("until", new Date(window[1]).toISOString()); + packetsUrl.searchParams.set("limit", "10000"); + packetsUrl.searchParams.set("order", "asc"); + + const packetsResponse = await fetch(packetsUrl, { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }); + if (!packetsResponse.ok) { + throw new Error(`Analyzer packets returned ${packetsResponse.status}`); + } + + const rawPackets: unknown = await packetsResponse.json(); + if ( + typeof rawPackets !== "object" || + rawPackets === null || + !Array.isArray((rawPackets as { packets?: unknown }).packets) + ) { + throw new Error("Invalid packet list"); + } + + const packets = (rawPackets as { packets: AnalyzerPacket[] }).packets; + const events = packets + .map((packet): { timestamp: number; payloadType: number } | null => { + const timestamp = asTimestamp(packet.timestamp ?? packet.first_seen); + const payloadType = + typeof packet.payload_type === "number" + ? packet.payload_type + : Number.parseInt(String(packet.payload_type), 10); + if (timestamp === null || !Number.isFinite(payloadType)) return null; + return { timestamp, payloadType }; + }) + .filter( + (event): event is { timestamp: number; payloadType: number } => + event !== null + ) + .sort((first, second) => first.timestamp - second.timestamp); + + if (events.length === 0) { + return { durationMs: REPLAY_DURATION_MS, events: [] }; + } + + const sourceStart = events[0].timestamp; + const sourceDuration = Math.max( + events[events.length - 1].timestamp - sourceStart, + 1 + ); + const replayEvents = events.map((event) => ({ + offsetMs: Math.round( + ((event.timestamp - sourceStart) / sourceDuration) * REPLAY_DURATION_MS + ), + payloadType: event.payloadType, + })); + + return { + durationMs: REPLAY_DURATION_MS, + events: sampleEvents(replayEvents), + }; +}; + +export async function GET() { + try { + if (cachedReplay && cachedReplay.expiresAt > Date.now()) { + return NextResponse.json(cachedReplay.payload); + } + + const payload = await loadReplay(); + cachedReplay = { + expiresAt: Date.now() + CACHE_DURATION_MS, + payload, + }; + return NextResponse.json(payload); + } catch { + return NextResponse.json( + { durationMs: REPLAY_DURATION_MS, events: [] }, + { status: 200 } + ); + } +} diff --git a/app/docs/[slug]/page.tsx b/app/docs/[slug]/page.tsx index 27bb1e9..04ebda8 100644 --- a/app/docs/[slug]/page.tsx +++ b/app/docs/[slug]/page.tsx @@ -9,9 +9,6 @@ export const revalidate = 3600; // deploy still renders on first visit (and gets cached per `revalidate` // above), so new docs pages don't need a redeploy to go live. -const DOCS_REPO_RAW_HTML = - "https://github.com/GulfCoastMesh/louisianameshcommunity.github.io/blob/main/docs"; - export async function generateStaticParams() { return (await getAllSlugs()).map((slug) => ({ slug })); } @@ -63,13 +60,13 @@ export default async function DocsSlugPage({