From 6cd14a58aaa2f31468604a6c3e62f321fea5308d Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 21 Jul 2026 10:21:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(web):=20React=20frontend=20scaffolding=20?= =?UTF-8?q?=E2=80=94=20Phase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Vite 6 + TypeScript build replacing esbuild, React 19, React Router 7 - LitBridge wraps unconverted lit-html pages inside React app lifecycle - Shared components: SortableTable, Pagination, FilterForm, StatCard, JsonTree, NodeDisplay, ObserverBadges, RouteTypeBadge, icons, ErrorBoundary, Alerts - Hooks: useAutoRefresh, usePageTitle; utils: api, format, clipboard - i18n via react-i18next mirroring existing translation keys - Native React pages: NotFound, Maintenance; all other routes via LitBridge - Jinja2 shell (spa.html) preserved for navbar, SEO, vendor globals, theme - build.js generates legacy-compatible assets.json from Vite manifest - REACT_MIGRATION.md documents full plan and conversion patterns --- Dockerfile | 2 +- REACT_MIGRATION.md | 210 ++ build.js | 38 +- package-lock.json | 2247 ++++++++++++++--- package.json | 22 +- src/meshcore_hub/web/app.py | 8 +- .../web/static/js/spa-react/App.tsx | 276 ++ .../static/js/spa-react/components/Alerts.tsx | 47 + .../js/spa-react/components/AuthSection.tsx | 85 + .../js/spa-react/components/ErrorBoundary.tsx | 47 + .../js/spa-react/components/FilterForm.tsx | 76 + .../js/spa-react/components/JsonTree.tsx | 156 ++ .../js/spa-react/components/LitBridge.tsx | 80 + .../js/spa-react/components/MobileNav.tsx | 63 + .../js/spa-react/components/NodeDisplay.tsx | 47 + .../spa-react/components/ObserverBadges.tsx | 108 + .../js/spa-react/components/Pagination.tsx | 93 + .../spa-react/components/RouteTypeBadge.tsx | 18 + .../js/spa-react/components/SortableTable.tsx | 121 + .../js/spa-react/components/StatCard.tsx | 32 + .../components/TimezoneIndicator.tsx | 7 + .../js/spa-react/components/icons/index.tsx | 463 ++++ .../js/spa-react/context/AppConfigContext.tsx | 59 + .../js/spa-react/hooks/useAutoRefresh.ts | 58 + .../static/js/spa-react/hooks/usePageTitle.ts | 18 + .../web/static/js/spa-react/i18n/index.ts | 57 + .../web/static/js/spa-react/index.html | 8 + .../web/static/js/spa-react/legacy.d.ts | 7 + .../web/static/js/spa-react/main.tsx | 43 + .../static/js/spa-react/pages/Maintenance.tsx | 21 + .../static/js/spa-react/pages/NotFound.tsx | 33 + .../web/static/js/spa-react/types/config.ts | 70 + .../web/static/js/spa-react/utils/api.ts | 100 + .../static/js/spa-react/utils/clipboard.ts | 59 + .../web/static/js/spa-react/utils/format.ts | 157 ++ .../web/static/js/spa-react/vite-env.d.ts | 1 + src/meshcore_hub/web/templates/spa.html | 3 + tsconfig.json | 23 + vite.config.ts | 38 + 39 files changed, 4598 insertions(+), 403 deletions(-) create mode 100644 REACT_MIGRATION.md create mode 100644 src/meshcore_hub/web/static/js/spa-react/App.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Alerts.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/AuthSection.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ErrorBoundary.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/FilterForm.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/JsonTree.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/LitBridge.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/MobileNav.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/NodeDisplay.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/ObserverBadges.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/Pagination.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/RouteTypeBadge.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/SortableTable.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/StatCard.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/TimezoneIndicator.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/components/icons/index.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/context/AppConfigContext.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/useAutoRefresh.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/hooks/usePageTitle.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/i18n/index.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/index.html create mode 100644 src/meshcore_hub/web/static/js/spa-react/legacy.d.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/main.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx create mode 100644 src/meshcore_hub/web/static/js/spa-react/types/config.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/api.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/utils/format.ts create mode 100644 src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/Dockerfile b/Dockerfile index 3b453e8..613e62c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci -COPY build.js ./ +COPY build.js vite.config.ts tsconfig.json ./ COPY src/meshcore_hub/web/static/css/input.css ./src/meshcore_hub/web/static/css/input.css COPY src/meshcore_hub/web/templates/ ./src/meshcore_hub/web/templates/ COPY src/meshcore_hub/web/static/js/ ./src/meshcore_hub/web/static/js/ diff --git a/REACT_MIGRATION.md b/REACT_MIGRATION.md new file mode 100644 index 0000000..c18ac19 --- /dev/null +++ b/REACT_MIGRATION.md @@ -0,0 +1,210 @@ +# React Migration Plan + +Migration from lit-html (functional templates) to React 19 + TypeScript + Vite. + +## Status + +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Infrastructure (Vite, React shell, router, LitBridge, build pipeline, shared components) | **Complete** | +| 2 | Convert pages one-by-one from LitBridge to native React | Not started | +| 3 | Chart & map components (react-chartjs-2, react-leaflet) | Not started | +| 4 | Cleanup (remove lit-html, old spa/, build.js esbuild remnants) | Not started | +| 5 | Optional enhancements (tests, react-query, Storybook) | Not started | + +## Architecture Decisions + +- **TypeScript** strict mode, `@/` alias → `spa-react/`, `@legacy/` alias → `spa/` +- **Vite 6** replaces esbuild; outputs to `static/dist/` with content-hashed filenames +- **Jinja2 shell preserved** — server renders navbar, SEO meta, config JSON; React owns `
` +- **LitBridge** wraps unconverted pages: dynamic import → `render(container, params, router)` → cleanup +- **react-i18next** loads same locale JSONs from `/static/locales/`; exposes `window.t` for legacy scripts +- **Vendor scripts kept** (leaflet, chart.js, qrcodejs as globals) until Phase 3 converts map/charts +- **DaisyUI + Tailwind v4** unchanged; `@source "../js/"` in input.css scans both spa/ and spa-react/ + +## File Structure + +``` +vite.config.ts # Vite config (root=project, input=spa-react/index.html) +tsconfig.json # Strict TS, path aliases +build.js # Tailwind → vendor copy → vite build → assets.json +package.json # React 19, react-router 7, react-i18next, vite, typescript + +src/meshcore_hub/web/static/js/spa-react/ +├── index.html # Vite HTML entry (not served; Jinja2 is the real shell) +├── main.tsx # Bootstrap: initI18n → render App, AuthSection, MobileNav +├── App.tsx # BrowserRouter, all routes, feature flags, LitBridge wiring +├── vite-env.d.ts +├── legacy.d.ts # TS declarations for @legacy/*.js modules +├── types/config.ts # AppConfig interface, window.__APP_CONFIG__ declaration +├── context/AppConfigContext.tsx # useAppConfig(), useFeatures(), hasRole(), channel labels +├── i18n/index.ts # initI18n() with i18next + language detector +├── hooks/ +│ ├── useAutoRefresh.ts # Timer-based refresh with pause/play +│ └── usePageTitle.ts # Set document.title from entity key +├── utils/ +│ ├── api.ts # Typed apiGet, apiPost, apiPut, apiDelete, apiPostForm +│ ├── format.ts # parseAppDate, formatDateTime, formatRelativeTime, emojis +│ └── clipboard.ts # copyToClipboard with fallback +├── components/ +│ ├── icons/index.tsx # 30+ SVG icon components (IconDashboard, IconNodes, etc.) +│ ├── Alerts.tsx # Loading, ErrorAlert, InfoAlert, SuccessAlert, WarningBadge +│ ├── AuthSection.tsx # Navbar auth dropdown (login button or user menu) +│ ├── MobileNav.tsx # Mobile hamburger nav items +│ ├── ErrorBoundary.tsx # React error boundary with fallback UI +│ ├── LitBridge.tsx # Wraps old lit-html page modules in React lifecycle +│ ├── Pagination.tsx # URL-driven pagination (page param) +│ ├── StatCard.tsx # Dashboard stat card with icon/color +│ ├── NodeDisplay.tsx # Node emoji + name + description +│ ├── FilterForm.tsx # FilterForm + FilterToggle (URL query driven) +│ ├── SortableTable.tsx # SortableTableHeader + MobileSortSelect +│ ├── TimezoneIndicator.tsx # Timezone abbreviation badge +│ ├── ObserverBadges.tsx # Observer filter badges + localStorage helpers +│ ├── RouteTypeBadge.tsx # Flood/Relay/Zero-hop badge +│ └── JsonTree.tsx # Expandable JSON viewer +└── pages/ + ├── NotFound.tsx # ✅ Converted (native React) + └── Maintenance.tsx # ✅ Converted (native React) + +src/meshcore_hub/web/static/js/spa/ # OLD lit-html pages (still used via LitBridge) +├── app.js # Old entry (NO LONGER LOADED — replaced by spa-react/main.tsx) +├── router.js # Old router (replaced by react-router) +├── api.js # Old API client (replaced by utils/api.ts) +├── components.js # Old shared components (replaced by React components) +├── i18n.js # Old i18n (replaced by react-i18next) +├── icons.js # Old icons (replaced by components/icons/) +├── auto-refresh.js # Old auto-refresh (replaced by hooks/useAutoRefresh.ts) +├── json-tree.js # Old JSON tree (replaced by components/JsonTree.tsx) +└── pages/ # Old page modules (loaded via LitBridge until converted) + ├── home.js, dashboard.js, nodes.js, node-detail.js, ... +``` + +## Build Pipeline + +```bash +npm run build +# 1. npx @tailwindcss/cli build (input.css → tailwind.css) +# 2. Copy vendor files (leaflet, chart.js, qrcodejs, fonts) +# 3. npx vite build (bundles React + legacy lit-html pages → dist/assets/) +# 4. Remove stale dist/src/ artifact +# 5. Generate dist/assets.json (compatible format for Jinja2 template) +``` + +The Jinja2 template (`spa.html`) reads `assets.json` for the entry JS filename: +```json +{ "app.js": "assets/index-XXXX.js", "vendor": {...}, "locale_version": "..." } +``` + +Python (`app.py`) loads this manifest at startup and passes `asset_app_js` / `asset_app_css` to the template. + +## Phase 2: Page Conversion + +### Pattern for each page + +1. Create `pages/PageName.tsx`: + - Use `useSearchParams()` for filters/pagination/sort + - Use typed `apiGet()` with `useEffect` + `AbortController` + - Replace lit-html `html\`...\`` with JSX + - Use shared components (Pagination, FilterForm, StatCard, etc.) + - Call `usePageTitle('entities.xxx')` for document title +2. In `App.tsx`: replace ` import("@legacy/pages/xxx.js")} />` with `` +3. Delete `src/meshcore_hub/web/static/js/spa/pages/xxx.js` +4. Run `npm run build` to verify bundle compiles +5. Run `pytest --no-cov tests/test_web/` to verify server tests still pass + +### Conversion order (simplest → most complex) + +| # | Page | File | Complexity | Notes | +|---|------|------|-----------|-------| +| 1 | NotFound | `not-found.js` | Done | Already native React | +| 2 | Maintenance | `maintenance.js` | Done | Already native React | +| 3 | Home | `home.js` | Low | Stats + nav cards + activity chart (uses `window.createActivityChart`) | +| 4 | CustomPage | `custom-page.js` | Low | Fetches markdown HTML → `dangerouslySetInnerHTML` | +| 5 | Profile | `profile.js` | Medium | Form + PUT + QR code (uses `window.QRCode`) | +| 6 | Members | `members.js` | Medium | Table + filters + pagination | +| 7 | Channels | `channels.js` | Medium | Table + admin CRUD modals | +| 8 | Advertisements | `advertisements.js` | Medium | Table + filters + auto-refresh + observer badges | +| 9 | Messages | `messages.js` | Medium | Table + filters + auto-refresh + observer badges | +| 10 | Routes | `routes.js` | Med-High | Table + filters + history + chart strips | +| 11 | Nodes | `nodes.js` | Med-High | Table + filters + pagination + auto-refresh + observer filter | +| 12 | NodeDetail | `node-detail.js` | High | Tabs, charts, tables, QR, adopt/tags CRUD | +| 13 | Packets | `packets.js` | Medium | Table + filters + auto-refresh | +| 14 | PacketDetail | `packet-detail.js` | Med-High | JSON tree + raw data | +| 15 | PacketGroupDetail | `packet-group-detail.js` | Medium | Grouped packet list | +| 16 | Dashboard | `dashboard.js` | High | Multiple charts, stat cards, auto-refresh | +| 17 | Map | `map.js` | High | Leaflet map, markers, popups, layers | + +### Key patterns in old pages → React equivalents + +| Old pattern | React equivalent | +|-------------|-----------------| +| `render(container, params, router)` | Component with hooks | +| `params.query` | `useSearchParams()` | +| `params.signal` (AbortController) | `useEffect` cleanup + `AbortController` | +| `router.navigate(url)` | `useNavigate()(url)` | +| `litRender(html\`...\`, container)` | JSX return | +| `apiGet(path, params, { signal })` | `apiGet(path, params, { signal })` | +| `getConfig()` | `useAppConfig()` | +| `t('key')` | `useTranslation().t('key')` or `window.t('key')` | +| `createAutoRefresh({ fetchAndRender, toggleContainer })` | `useAutoRefresh({ onRefresh })` | +| `pagination(page, totalPages, basePath, params)` | `` | +| `renderFilterForm({ fields, basePath, navigate })` | `...` | +| `renderStatCard({ icon, color, title, value })` | `` | +| `return () => { chart.destroy(); }` (cleanup) | `useEffect` return cleanup | +| `window.createActivityChart(...)` | Keep as-is until Phase 3 (react-chartjs-2) | +| `window.L.map(...)` (Leaflet) | Keep as-is until Phase 3 (react-leaflet) | +| `window.QRCode(...)` | Keep as-is or use `react-qr-code` package | + +## Phase 3: Charts & Maps + +- Install `react-chartjs-2` (already in package.json) — create typed wrapper components +- Install `react-leaflet` (already in package.json) — create `` component +- Port `charts.js` global functions into React chart components +- Remove leaflet/chart.js vendor ` + + diff --git a/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts b/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts new file mode 100644 index 0000000..c3a7768 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/legacy.d.ts @@ -0,0 +1,7 @@ +declare module "@legacy/pages/*.js" { + export function render( + container: HTMLElement, + params: Record, + router: { navigate: (url: string, replace?: boolean) => void }, + ): Promise<(() => void) | void>; +} diff --git a/src/meshcore_hub/web/static/js/spa-react/main.tsx b/src/meshcore_hub/web/static/js/spa-react/main.tsx new file mode 100644 index 0000000..d784dd2 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/main.tsx @@ -0,0 +1,43 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { AppConfigProvider } from "@/context/AppConfigContext"; +import { initI18n } from "@/i18n"; +import { App } from "@/App"; +import { AuthSection } from "@/components/AuthSection"; +import { MobileNav } from "@/components/MobileNav"; +import type { AppConfig } from "@/types/config"; + +async function bootstrap() { + await initI18n(); + + const config: AppConfig = window.__APP_CONFIG__; + + try { + localStorage.removeItem("meshcore-observers-disabled"); + } catch { + // ignore + } + + const appContainer = document.getElementById("app"); + if (!appContainer) return; + + const wrap = (ui: React.ReactNode) => ( + + {ui} + + ); + + createRoot(appContainer).render(wrap()); + + const authContainer = document.getElementById("auth-section"); + if (authContainer) { + createRoot(authContainer).render(wrap()); + } + + const mobileNavContainer = document.getElementById("mobile-nav"); + if (mobileNavContainer) { + createRoot(mobileNavContainer).render(wrap()); + } +} + +bootstrap(); diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx new file mode 100644 index 0000000..0075793 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/Maintenance.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +export function Maintenance() { + const { t } = useTranslation(); + usePageTitle(); + + return ( +
+
+
+
🔧
+

+ {t("maintenance.title")} +

+

{t("maintenance.description")}

+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx new file mode 100644 index 0000000..f169fe5 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/pages/NotFound.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { IconHome, IconNodes } from "@/components/icons"; +import { usePageTitle } from "@/hooks/usePageTitle"; + +export function NotFound() { + const { t } = useTranslation(); + usePageTitle(); + + return ( +
+
+
+
404
+

+ {t("common.page_not_found")} +

+

{t("not_found.description")}

+
+ + + {t("common.go_home")} + + + + {t("common.view_entity", { entity: t("entities.nodes") })} + +
+
+
+
+ ); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/types/config.ts b/src/meshcore_hub/web/static/js/spa-react/types/config.ts new file mode 100644 index 0000000..5a40cbb --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/types/config.ts @@ -0,0 +1,70 @@ +export interface RadioConfigDisplay { + profile?: string; + frequency?: string; + bandwidth?: string; + spreading_factor?: string; + coding_rate?: string; + tx_power?: string; +} + +export interface CustomPage { + slug: string; + title: string; + url: string; + menu_order: number; +} + +export interface OidcUser { + sub: string; + name?: string; + email?: string; + picture?: string; +} + +export interface AppConfig { + network_name: string; + network_city?: string; + network_country?: string; + network_radio_config?: RadioConfigDisplay; + network_contact_email?: string; + network_contact_discord?: string; + network_contact_github?: string; + network_contact_youtube?: string; + network_welcome_text?: string; + features: Record; + custom_pages: CustomPage[]; + logo_url: string; + version: string; + timezone: string; + timezone_iana: string; + default_theme: string; + locale: string; + datetime_locale: string; + auto_refresh_seconds: number; + channel_labels: Record; + logo_invert_light: boolean; + debug: boolean; + locale_version: string; + system_maintenance: boolean; + spam_score_threshold: number; + oidc_enabled: boolean; + user: OidcUser | null; + roles: string[]; + role_names: Record; +} + +declare global { + interface Window { + __APP_CONFIG__: AppConfig; + t: (key: string, params?: Record) => string; + formatNumber: (value: number | string | null | undefined) => string; + createActivityChart: (...args: unknown[]) => unknown; + createLineChart: (...args: unknown[]) => unknown; + createStackedBarChart: (...args: unknown[]) => unknown; + createRoutesTrendChart: (...args: unknown[]) => unknown; + createRouteDetailStrip: (...args: unknown[]) => unknown; + initDashboardCharts: (...args: unknown[]) => unknown; + } +} + +export {}; diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/api.ts b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts new file mode 100644 index 0000000..1b89017 --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/api.ts @@ -0,0 +1,100 @@ +import type { AppConfig } from "@/types/config"; + +export function isAbortError(e: unknown): boolean { + return e instanceof DOMException && e.name === "AbortError"; +} + +function checkAuthResponse(response: Response): void { + const config: AppConfig | undefined = window.__APP_CONFIG__; + if (config?.oidc_enabled && response.status === 401) { + const next = encodeURIComponent( + window.location.pathname + window.location.search, + ); + window.location.href = `/auth/login?next=${next}`; + } +} + +export async function apiGet( + path: string, + params: Record = {}, + { signal }: { signal?: AbortSignal } = {}, +): Promise { + const url = new URL(path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== "") { + if (Array.isArray(v)) { + v.forEach((item) => url.searchParams.append(k, String(item))); + } else { + url.searchParams.set(k, String(v)); + } + } + } + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export async function apiPost( + path: string, + body: unknown, +): Promise { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} + +export async function apiPut( + path: string, + body: unknown, +): Promise { + const response = await fetch(path, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} + +export async function apiDelete(path: string): Promise { + const response = await fetch(path, { method: "DELETE" }); + checkAuthResponse(response); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } +} + +export async function apiPostForm( + path: string, + data: Record, +): Promise { + const body = new URLSearchParams(data); + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: ${response.status} - ${text}`); + } + if (response.status === 204) return null; + return response.json(); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts new file mode 100644 index 0000000..fa2cb9b --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/clipboard.ts @@ -0,0 +1,59 @@ +export function copyToClipboard( + e: React.MouseEvent, + text: string, +): void { + e.preventDefault(); + e.stopPropagation(); + + const targetElement = e.currentTarget as HTMLElement; + + const showSuccess = (target: HTMLElement) => { + const originalText = target.textContent; + target.textContent = "Copied!"; + target.classList.add("text-success"); + setTimeout(() => { + target.textContent = originalText; + target.classList.remove("text-success"); + }, 1500); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(text) + .then(() => showSuccess(targetElement)) + .catch((err) => { + console.error("Clipboard API failed:", err); + fallbackCopy(text, targetElement); + }); + } else { + fallbackCopy(text, targetElement); + } +} + +function fallbackCopy(text: string, target: HTMLElement): void { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + try { + document.execCommand("copy"); + showSuccess(target); + } catch (err) { + console.error("Fallback copy failed:", err); + } + document.body.removeChild(textArea); +} + +function showSuccess(target: HTMLElement): void { + const originalText = target.textContent; + target.textContent = "Copied!"; + target.classList.add("text-success"); + setTimeout(() => { + target.textContent = originalText; + target.classList.remove("text-success"); + }, 1500); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/utils/format.ts b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts new file mode 100644 index 0000000..79845fe --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/utils/format.ts @@ -0,0 +1,157 @@ +import { useAppConfig } from "@/context/AppConfigContext"; + +export function parseAppDate(isoString: string | null): Date | null { + if (!isoString || typeof isoString !== "string") return null; + + let value = isoString.trim(); + if (!value) return null; + + if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}/.test(value)) { + value = value.replace(/\s+/, "T"); + } + + const hasTimePart = /T\d{2}:\d{2}/.test(value); + const hasTimezoneSuffix = /(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$/i.test(value); + if (hasTimePart && !hasTimezoneSuffix) { + value += "Z"; + } + + const parsed = new Date(value); + if (isNaN(parsed.getTime())) return null; + return parsed; +} + +export function formatNumber( + value: number | string | null | undefined, +): string { + if (value === null || value === undefined || value === "") return ""; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + return new Intl.NumberFormat().format(n); +} + +export function useFormatDateTime() { + const config = useAppConfig(); + const tz = config.timezone_iana || "UTC"; + const locale = config.datetime_locale || "en-US"; + + return { + formatDateTime( + isoString: string | null, + options?: Intl.DateTimeFormatOptions, + ): string { + if (!isoString) return "-"; + try { + const date = parseAppDate(isoString); + if (!date) return "-"; + const opts = options ?? { + timeZone: tz, + year: "numeric" as const, + month: "2-digit" as const, + day: "2-digit" as const, + hour: "2-digit" as const, + minute: "2-digit" as const, + second: "2-digit" as const, + hour12: false, + }; + if (!opts.timeZone) opts.timeZone = tz; + return date.toLocaleString(locale, opts); + } catch { + return isoString ? isoString.slice(0, 19).replace("T", " ") : "-"; + } + }, + + formatDateTimeShort(isoString: string | null): string { + if (!isoString) return "-"; + try { + const date = parseAppDate(isoString); + if (!date) return "-"; + return date.toLocaleString(locale, { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + } catch { + return isoString ? isoString.slice(0, 16).replace("T", " ") : "-"; + } + }, + }; +} + +export function formatRelativeTime(isoString: string | null): string { + if (!isoString) return ""; + const date = parseAppDate(isoString); + if (!date) return ""; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + const t = window.t; + if (diffDay > 0) return t("time.days_ago", { count: diffDay }); + if (diffHour > 0) return t("time.hours_ago", { count: diffHour }); + if (diffMin > 0) return t("time.minutes_ago", { count: diffMin }); + return t("time.less_than_minute"); +} + +export function truncateKey(key: string | null, length = 12): string { + if (!key) return "-"; + if (key.length <= length) return key; + return key.slice(0, length) + "..."; +} + +function inferNodeType(value: string | null): string | null { + const normalized = (value ?? "").toLowerCase(); + if (!normalized) return null; + if (normalized.includes("room")) return "room"; + if (normalized.includes("repeater") || normalized.includes("relay")) + return "repeater"; + if (normalized.includes("companion") || normalized.includes("observer")) + return "companion"; + if (normalized.includes("chat")) return "chat"; + return null; +} + +export function typeEmoji(advType: string | null): string { + switch (inferNodeType(advType) ?? (advType ?? "").toLowerCase()) { + case "chat": + return "\u{1F4AC}"; + case "repeater": + return "\u{1F4E1}"; + case "companion": + return "\u{1F4F1}"; + case "room": + return "\u{1FAA7}"; + default: + return "\u{1F4CD}"; + } +} + +export function extractFirstEmoji(str: string | null): string | null { + if (!str) return null; + const emojiRegex = + /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{231A}-\u{231B}\u{23E9}-\u{23FA}\u{25AA}-\u{25AB}\u{25B6}\u{25C0}\u{25FB}-\u{25FE}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}](?:\u{FE0F})?(?:\u{200D}[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}](?:\u{FE0F})?)*|\u{00A9}|\u{00AE}|\u{203C}|\u{2049}|\u{2122}|\u{2139}|\u{2194}-\u{2199}|\u{21A9}-\u{21AA}|\u{24C2}|\u{2934}-\u{2935}|\u{2B05}-\u{2B07}|\u{2B1B}-\u{2B1C}/u; + const match = str.match(emojiRegex); + return match ? match[0] : null; +} + +export function getNodeEmoji( + nodeName: string | null, + advType: string | null, +): string { + const nameEmoji = extractFirstEmoji(nodeName); + if (nameEmoji) return nameEmoji; + const inferred = inferNodeType(advType) ?? inferNodeType(nodeName); + return typeEmoji(inferred ?? advType); +} + +export function getPageColor(name: string): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(`--color-${name}`) + .trim(); +} diff --git a/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/meshcore_hub/web/static/js/spa-react/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 2cb5655..19364da 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -217,6 +217,9 @@ + {% if asset_app_css %} + + {% endif %} {% if asset_app_js %} {% else %} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..de5256f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowJs": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/meshcore_hub/web/static/js/spa-react/*"], + "@legacy/*": ["src/meshcore_hub/web/static/js/spa/*"] + } + }, + "include": ["src/meshcore_hub/web/static/js/spa-react"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..1486c80 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; + +const SPA_REACT = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa-react", +); +const SPA_LEGACY = resolve( + __dirname, + "src/meshcore_hub/web/static/js/spa", +); +const DIST = resolve(__dirname, "src/meshcore_hub/web/static/dist"); + +export default defineConfig({ + base: "/static/dist/", + plugins: [react()], + resolve: { + alias: { + "@": SPA_REACT, + "@legacy": SPA_LEGACY, + }, + }, + build: { + outDir: DIST, + emptyOutDir: true, + manifest: true, + rollupOptions: { + input: resolve(SPA_REACT, "index.html"), + output: { + manualChunks: { + vendor: ["react", "react-dom", "react-router"], + i18n: ["i18next", "react-i18next", "i18next-browser-languagedetector"], + }, + }, + }, + }, +});