feat(web): React frontend scaffolding — Phase 1

- 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
This commit is contained in:
Louis King
2026-07-21 10:21:39 +01:00
parent 53c1b36262
commit 6cd14a58aa
39 changed files with 4598 additions and 403 deletions
+1 -1
View File
@@ -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/
+210
View File
@@ -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 `<main id="app">`
- **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<T>, 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<T>()` 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 `<LitPage loader={() => import("@legacy/pages/xxx.js")} />` with `<PageName />`
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<T>(path, params, { signal })` |
| `getConfig()` | `useAppConfig()` |
| `t('key')` | `useTranslation().t('key')` or `window.t('key')` |
| `createAutoRefresh({ fetchAndRender, toggleContainer })` | `useAutoRefresh({ onRefresh })` |
| `pagination(page, totalPages, basePath, params)` | `<Pagination page={...} totalPages={...} basePath={...} />` |
| `renderFilterForm({ fields, basePath, navigate })` | `<FilterForm basePath={...}>...</FilterForm>` |
| `renderStatCard({ icon, color, title, value })` | `<StatCard 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 `<MeshMap>` component
- Port `charts.js` global functions into React chart components
- Remove leaflet/chart.js vendor `<script>` tags from `spa.html`
- Remove `charts.js` global script
## Phase 4: Cleanup
- Remove `lit-html` from package.json
- Delete `LitBridge.tsx`
- Delete entire `src/meshcore_hub/web/static/js/spa/` directory
- Remove `@legacy` alias from vite.config.ts and tsconfig.json
- Delete `legacy.d.ts`
- Remove vendor script tags from `spa.html` (leaflet, chart.js, qrcodejs, charts.js)
- Remove `build.js` vendor copy for leaflet/chart.js/qrcodejs (now bundled by Vite)
- Update `AGENTS.md` with new frontend conventions
## Phase 5: Optional Enhancements
- Add `vitest` + `@testing-library/react` for component tests
- Add Playwright for E2E browser tests
- Consider `@tanstack/react-query` for data fetching
- Consider moving navbar from Jinja2 to React (full SPA shell)
- Add Storybook for component development
## Running & Testing
```bash
# Build frontend (produces static/dist/)
npm run build
# Run Python web tests (verifies Jinja2 template, proxy, caching)
source .venv/bin/activate
pytest --no-cov tests/test_web/
# Quality checks
pre-commit run --all-files
# Docker build (user does this manually)
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core build
```
## Important Notes
- The old `spa/app.js` is NO LONGER LOADED. The Jinja2 template now loads the Vite-built React bundle.
- The Jinja2 template still renders the navbar, footer, banners, theme toggle, and vendor scripts.
- `window.__APP_CONFIG__` is still injected by Jinja2 and read by React on bootstrap.
- The theme toggle in the navbar is still vanilla JS (in spa.html). React doesn't manage it.
- Old lit-html pages loaded via LitBridge still use `window.Chart`, `window.L`, `window.QRCode` globals.
- Tailwind scans `static/js/` recursively — both `spa/` and `spa-react/` classes are included.
- The `dist/assets.json` format is unchanged from the esbuild era — Python code didn't need changes.
+20 -18
View File
@@ -62,30 +62,32 @@ vendor(
"fonts",
);
console.log("Bundling SPA with esbuild...");
mkdirSync(DIST, { recursive: true });
console.log("Bundling SPA with Vite...");
execSync("npx vite build", { stdio: "inherit" });
const metafilePath = join(DIST, "meta.json");
execSync(
`npx esbuild ${join(STATIC, "js", "spa", "app.js")}` +
` --bundle --format=esm --splitting --minify` +
` --outdir=${DIST}` +
` --entry-names=[name].[hash]` +
` --chunk-names=chunks/[name].[hash]` +
` --metafile=${metafilePath}`,
{ stdio: "inherit" },
);
// Vite emits a copy of the input HTML preserving its path relative to the
// project root (dist/src/…/index.html). The Jinja2 template is the real
// HTML shell, so remove the artifact.
import { rmSync } from "node:fs";
const staleHtmlDir = join(DIST, "src");
if (existsSync(staleHtmlDir)) {
rmSync(staleHtmlDir, { recursive: true, force: true });
}
console.log("Generating assets manifest...");
const meta = JSON.parse(readFileSync(metafilePath, "utf-8"));
const viteManifestPath = join(DIST, ".vite", "manifest.json");
const assets = {};
for (const [outputPath, info] of Object.entries(meta.outputs)) {
if (!info.entryPoint) continue;
const entryName = info.entryPoint.split("/").pop().replace(/\.js$/, ".js");
const fileName = outputPath.split("/").pop();
assets[entryName] = fileName;
if (existsSync(viteManifestPath)) {
const viteManifest = JSON.parse(readFileSync(viteManifestPath, "utf-8"));
for (const [, info] of Object.entries(viteManifest)) {
if (!info.isEntry) continue;
assets["app.js"] = info.file;
if (info.css && info.css.length > 0) {
assets["app.css"] = info.css[0];
}
}
}
const vendorFiles = {
+1867 -380
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -2,10 +2,16 @@
"private": true,
"type": "module",
"scripts": {
"build": "node build.js"
"build": "node build.js",
"dev": "vite --config vite.config.ts"
},
"devDependencies": {
"esbuild": "^0.28.0"
"@types/leaflet": "^1.9.17",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"typescript": "^5.8",
"vite": "^6"
},
"dependencies": {
"@fontsource-variable/ibm-plex-sans": "^5",
@@ -13,9 +19,21 @@
"@tailwindcss/cli": "^4",
"chart.js": "^4",
"daisyui": "^5",
"i18next": "^25",
"i18next-browser-languagedetector": "^8",
"leaflet": "^1.9.4",
"lit-html": "^3",
"qrcodejs": "^1.0.0",
"react": "^19",
"react-chartjs-2": "^5",
"react-dom": "^19",
"react-i18next": "^15",
"react-leaflet": "^5",
"react-router": "^7",
"tailwindcss": "^4"
},
"allowScripts": {
"esbuild@0.25.12": true,
"@parcel/watcher@2.5.1": true
}
}
+6 -2
View File
@@ -65,7 +65,9 @@ def _sanitize_header_value(value: str) -> str:
def _load_asset_manifest() -> dict[str, Any]:
"""Load the esbuild asset manifest from dist/assets.json.
"""Load the asset manifest from dist/assets.json.
Supports both Vite-generated and legacy esbuild manifest formats.
Returns:
Manifest dict with entry names, vendor hashes, and locale version.
@@ -706,10 +708,11 @@ def create_app(
page_loader.load_pages()
app.state.page_loader = page_loader
# Load esbuild asset manifest for cache-busted filenames
# Load asset manifest for cache-busted filenames (Vite or legacy esbuild)
manifest = _load_asset_manifest()
app.state.asset_manifest = manifest
app.state.asset_app_js = manifest.get("app.js", "")
app.state.asset_app_css = manifest.get("app.css", "")
app.state.vendor_hashes = manifest.get("vendor", {})
app.state.locale_version = manifest.get("locale_version", "")
@@ -1267,6 +1270,7 @@ def create_app(
"default_theme": request.app.state.web_theme,
"config_json": config_json,
"asset_app_js": request.app.state.asset_app_js,
"asset_app_css": request.app.state.asset_app_css,
"vendor_hashes": request.app.state.vendor_hashes,
},
)
@@ -0,0 +1,276 @@
import { useEffect, useCallback } from "react";
import {
BrowserRouter,
Routes,
Route,
Navigate,
useLocation,
useParams,
} from "react-router";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { LitBridge } from "@/components/LitBridge";
import { NotFound } from "@/pages/NotFound";
import { Maintenance } from "@/pages/Maintenance";
function useNavActiveState() {
const location = useLocation();
const config = useAppConfig();
useEffect(() => {
const pathname = location.pathname;
document.querySelectorAll("[data-nav-link]").forEach((link) => {
const href = link.getAttribute("href");
let isActive = false;
if (href === "/") {
isActive = pathname === "/";
} else if (href === "/nodes") {
isActive = pathname.startsWith("/nodes");
} else if (href) {
isActive = pathname === href || pathname.startsWith(href + "/");
}
link.classList.toggle("active", isActive);
});
const loader = document.getElementById("nav-loading");
if (loader) loader.classList.add("hidden");
if (document.activeElement?.closest(".dropdown")) {
(document.activeElement as HTMLElement).blur();
}
window.scrollTo(0, 0);
const networkName = config.network_name || "MeshCore Network";
const features = config.features ?? {};
const t = window.t;
const compose = (key: string) => `${t(key)} - ${networkName}`;
const titles: Record<string, string> = { "/": networkName };
if (features.dashboard !== false) titles["/dashboard"] = compose("entities.dashboard");
if (features.nodes !== false) titles["/nodes"] = compose("entities.nodes");
if (features.channels !== false) titles["/channels"] = compose("entities.channels");
if (features.routes !== false) titles["/routes"] = compose("entities.routes");
if (features.messages !== false) titles["/messages"] = compose("entities.messages");
if (features.advertisements !== false) titles["/advertisements"] = compose("entities.advertisements");
if (features.packets !== false) titles["/packets"] = compose("entities.packets");
if (features.map !== false) titles["/map"] = compose("entities.map");
if (features.members !== false) titles["/members"] = compose("entities.members");
titles["/profile"] = compose("links.profile");
if (titles[pathname]) {
document.title = titles[pathname];
} else if (pathname.startsWith("/nodes/")) {
document.title = compose("entities.node_detail");
} else {
document.title = networkName;
}
}, [location.pathname, config]);
}
function ShortLinkRedirect() {
const { prefix } = useParams();
return <Navigate to={`/nodes/${prefix}`} replace />;
}
function LitPage({
loader,
}: {
loader: () => Promise<{
render: (
container: HTMLElement,
params: Record<string, unknown>,
router: { navigate: (url: string, replace?: boolean) => void },
) => Promise<(() => void) | void>;
}>;
}) {
return (
<ErrorBoundary>
<LitBridge loader={loader} />
</ErrorBoundary>
);
}
function AppRoutes() {
const config = useAppConfig();
const features = config.features ?? {};
const maintenanceMode = config.system_maintenance === true;
useNavActiveState();
if (maintenanceMode) {
return (
<Routes>
<Route path="*" element={<Maintenance />} />
</Routes>
);
}
return (
<Routes>
<Route
path="/"
element={
<LitPage loader={() => import("@legacy/pages/home.js")} />
}
/>
{features.dashboard !== false && (
<Route
path="/dashboard"
element={
<LitPage
loader={() => import("@legacy/pages/dashboard.js")}
/>
}
/>
)}
{features.nodes !== false && (
<>
<Route
path="/nodes"
element={
<LitPage loader={() => import("@legacy/pages/nodes.js")} />
}
/>
<Route
path="/nodes/:publicKey"
element={
<LitPage
loader={() => import("@legacy/pages/node-detail.js")}
/>
}
/>
<Route path="/n/:prefix" element={<ShortLinkRedirect />} />
</>
)}
{features.channels !== false && (
<Route
path="/channels"
element={
<LitPage
loader={() => import("@legacy/pages/channels.js")}
/>
}
/>
)}
{features.routes !== false && (
<Route
path="/routes"
element={
<LitPage loader={() => import("@legacy/pages/routes.js")} />
}
/>
)}
{features.messages !== false && (
<Route
path="/messages"
element={
<LitPage
loader={() => import("@legacy/pages/messages.js")}
/>
}
/>
)}
{features.advertisements !== false && (
<Route
path="/advertisements"
element={
<LitPage
loader={() => import("@legacy/pages/advertisements.js")}
/>
}
/>
)}
{features.packets !== false && (
<>
<Route
path="/packets"
element={
<LitPage
loader={() => import("@legacy/pages/packets.js")}
/>
}
/>
<Route
path="/packets/hash/:hash"
element={
<LitPage
loader={() =>
import("@legacy/pages/packet-group-detail.js")
}
/>
}
/>
<Route
path="/packets/:id"
element={
<LitPage
loader={() =>
import("@legacy/pages/packet-detail.js")
}
/>
}
/>
</>
)}
{features.map !== false && (
<Route
path="/map"
element={
<LitPage loader={() => import("@legacy/pages/map.js")} />
}
/>
)}
{features.members !== false && (
<Route
path="/members"
element={
<LitPage
loader={() => import("@legacy/pages/members.js")}
/>
}
/>
)}
{features.pages !== false && (
<Route
path="/pages/:slug"
element={
<LitPage
loader={() => import("@legacy/pages/custom-page.js")}
/>
}
/>
)}
{config.oidc_enabled && (
<>
<Route
path="/profile"
element={
<LitPage
loader={() => import("@legacy/pages/profile.js")}
/>
}
/>
<Route
path="/profile/:id"
element={
<LitPage
loader={() => import("@legacy/pages/profile.js")}
/>
}
/>
</>
)}
<Route path="*" element={<NotFound />} />
</Routes>
);
}
export function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { IconError, IconInfo, IconSuccess, IconAlert } from "@/components/icons";
export function Loading() {
return (
<div className="flex justify-center py-12">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
export function ErrorAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-error mb-4">
<IconError className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function InfoAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-info mb-4">
<IconInfo className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function SuccessAlert({ message }: { message: string }) {
return (
<div role="alert" className="alert alert-success mb-4">
<IconSuccess className="stroke-current shrink-0 h-6 w-6" />
<span>{message}</span>
</div>
);
}
export function WarningBadge({ message }: { message: string }) {
return (
<span className="tooltip tooltip-bottom" data-tip={message}>
<span className="badge badge-warning badge-sm">
<IconAlert className="h-4 w-4" />
</span>
</span>
);
}
@@ -0,0 +1,85 @@
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import { IconUser, IconLogout } from "@/components/icons";
export function AuthSection() {
const { t } = useTranslation();
const config = useAppConfig();
if (!config.oidc_enabled) return null;
const user = config.user;
if (!user) {
return (
<a href="/auth/login" className="btn btn-sm btn-outline">
{t("auth.login")}
</a>
);
}
const displayName = user.name || user.email || "User";
const initials = displayName
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
const roleBadges = (config.roles ?? []).map((r) => {
const key = `auth.role_${r}`;
const label = t(key);
const name = label !== key ? label : r;
return (
<span key={r} className="badge badge-primary badge-xs">
{name}
</span>
);
});
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-ghost btn-circle btn-sm avatar"
>
{user.picture ? (
<img
src={user.picture}
alt={displayName}
className="w-8 h-8 rounded-full"
/>
) : (
<span className="text-sm font-bold">{initials}</span>
)}
</div>
<ul
tabIndex={0}
className="dropdown-content menu z-50 p-2 shadow-sm bg-base-100 rounded-box w-56 mt-3"
>
<li className="menu-title">
<div className="flex flex-col gap-1">
<span className="font-medium">{displayName}</span>
{config.debug && user.sub && (
<span className="text-xs opacity-40 font-mono">{user.sub}</span>
)}
{roleBadges.length > 0 && (
<div className="flex flex-wrap gap-1">{roleBadges}</div>
)}
</div>
</li>
<hr className="my-1 opacity-20" />
<li>
<a href="/profile">
<IconUser className="h-5 w-5" /> {t("links.profile")}
</a>
</li>
<li>
<a href="/auth/logout">
<IconLogout className="h-5 w-5" /> {t("auth.logout")}
</a>
</li>
</ul>
</div>
);
}
@@ -0,0 +1,47 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("React ErrorBoundary caught:", error, errorInfo);
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center py-20">
<h1 className="text-4xl font-bold mb-4">
{window.t("common.error")}
</h1>
<p className="text-lg opacity-70 mb-6">
{window.t("common.failed_to_load_page")}
</p>
<p className="text-sm opacity-50 mb-6">
{this.state.error?.message ?? "Unknown error"}
</p>
<a href="/" className="btn btn-primary">
{window.t("common.go_home")}
</a>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { IconFilter } from "@/components/icons";
interface FilterFormProps {
basePath: string;
children: React.ReactNode;
submitLabel?: string;
clearLabel?: string;
}
export function FilterForm({
basePath,
children,
submitLabel,
clearLabel,
}: FilterFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const params = new URLSearchParams();
const keys = new Set(formData.keys());
for (const k of keys) {
for (const v of formData.getAll(k)) {
if (v) params.append(k, v as string);
}
}
const queryStr = params.toString();
navigate(queryStr ? `${basePath}?${queryStr}` : basePath);
};
return (
<form
method="GET"
action={basePath}
className="flex flex-col gap-4"
onSubmit={handleSubmit}
>
<div className="flex gap-4 flex-wrap items-start">{children}</div>
<div className="flex gap-2">
<button type="submit" className="btn btn-primary btn-sm">
{submitLabel || t("common.filter")}
</button>
<a href={basePath} className="btn btn-ghost btn-sm">
{clearLabel || t("common.clear")}
</a>
</div>
</form>
);
}
interface FilterToggleProps {
open: boolean;
onChange: () => void;
}
export function FilterToggle({ open, onChange }: FilterToggleProps) {
const { t } = useTranslation();
return (
<label className="label cursor-pointer gap-2" title={t("common.filters")}>
<span className="text-sm opacity-80 flex items-center gap-1">
<IconFilter className="w-4 h-4" /> {t("common.filters")}
</span>
<input
type="checkbox"
id="filter-toggle"
className="toggle toggle-sm toggle-primary"
checked={open}
onChange={onChange}
/>
</label>
);
}
@@ -0,0 +1,156 @@
import { useState, useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IconChevronRight } from "@/components/icons";
function primitiveClass(val: unknown): string {
if (val === null) return "italic opacity-50";
switch (typeof val) {
case "string":
return "text-success";
case "number":
return "text-warning";
case "boolean":
return "text-info";
default:
return "";
}
}
function formatPrimitive(val: unknown): string {
if (val === null) return "null";
if (typeof val === "string") return `"${val}"`;
return String(val);
}
function KeyLabel({ k }: { k: string | number | null }) {
if (k === null) return null;
if (typeof k === "number") {
return <span className="text-primary/50">{k}:</span>;
}
return <span className="text-primary/70">"{k}":</span>;
}
function JsonNode({
value,
k,
depth,
openDepth,
expandSignal,
}: {
value: unknown;
k: string | number | null;
depth: number;
openDepth: number;
expandSignal: boolean | null;
}) {
const [expanded, setExpanded] = useState(depth < openDepth);
const isExpanded = expandSignal !== null ? expandSignal : expanded;
const isContainer = value !== null && typeof value === "object";
if (!isContainer) {
return (
<div className="flex gap-2 py-0.5">
<KeyLabel k={k} />
<span className={primitiveClass(value)}>
{formatPrimitive(value)}
</span>
</div>
);
}
const isArray = Array.isArray(value);
const entries: [string | number, unknown][] = isArray
? (value as unknown[]).map((v, i) => [i, v])
: Object.entries(value as Record<string, unknown>);
const open = isArray ? "[" : "{";
const close = isArray ? "]" : "}";
if (entries.length === 0) {
return (
<div className="flex gap-2 py-0.5">
<KeyLabel k={k} />
<span className="opacity-60">
{open}
{close}
</span>
</div>
);
}
return (
<div className="json-node">
<button
type="button"
className="json-toggle inline-flex items-center gap-1 hover:opacity-70"
onClick={() => setExpanded(!isExpanded)}
>
<span
className={`json-caret inline-block transition-transform ${isExpanded ? "rotate-90" : ""}`}
>
<IconChevronRight className="h-3 w-3" />
</span>
<KeyLabel k={k} />
<span className="opacity-50 text-[10px]">
{open}
{entries.length}
{close}
</span>
</button>
<div
className={`json-children ml-2 border-l border-base-200 pl-2 ${isExpanded ? "" : "hidden"}`}
>
{entries.map(([ek, ev]) => (
<JsonNode
key={ek}
value={ev}
k={ek}
depth={depth + 1}
openDepth={openDepth}
expandSignal={expandSignal}
/>
))}
</div>
</div>
);
}
export function JsonTree({
value,
openDepth = 1,
}: {
value: unknown;
openDepth?: number;
}) {
const { t } = useTranslation();
const [expandSignal, setExpandSignal] = useState<boolean | null>(null);
const expandAll = useCallback(() => setExpandSignal(true), []);
const collapseAll = useCallback(() => setExpandSignal(false), []);
return (
<div className="json-tree-root font-mono text-xs">
<div className="flex items-center gap-2 mb-2">
<button type="button" className="btn btn-xs btn-ghost" onClick={expandAll}>
{t("packets.expand_all")}
</button>
<button
type="button"
className="btn btn-xs btn-ghost"
onClick={collapseAll}
>
{t("packets.collapse_all")}
</button>
</div>
<div className="overflow-x-auto">
<JsonNode
value={value}
k={null}
depth={0}
openDepth={openDepth}
expandSignal={expandSignal}
/>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import { useEffect, useRef } from "react";
import { useParams, useSearchParams, useNavigate } from "react-router";
interface LitBridgeProps {
loader: () => Promise<{
render: (
container: HTMLElement,
params: Record<string, unknown>,
router: { navigate: (url: string, replace?: boolean) => void },
) => Promise<(() => void) | void>;
}>;
}
export function LitBridge({ loader }: LitBridgeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const params = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const cleanupRef = useRef<(() => void) | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const query: Record<string, string | string[]> = {};
for (const [k, v] of searchParams.entries()) {
if (k in query) {
const existing = query[k];
query[k] = Array.isArray(existing) ? [...existing, v] : [existing, v];
} else {
query[k] = v;
}
}
const routerAdapter = {
navigate(url: string, replace = false) {
navigate(url, { replace });
},
};
let cancelled = false;
loader()
.then((module) => {
if (cancelled) return;
return module.render(
container,
{ ...params, query, signal: controller.signal },
routerAdapter,
);
})
.then((cleanup) => {
if (cancelled) {
if (typeof cleanup === "function") cleanup();
return;
}
if (typeof cleanup === "function") {
cleanupRef.current = cleanup;
}
})
.catch((e) => {
if (e?.name === "AbortError") return;
console.error("LitBridge page load error:", e);
});
return () => {
cancelled = true;
controller.abort();
cleanupRef.current?.();
cleanupRef.current = null;
};
}, [loader, params, searchParams, navigate]);
return <div ref={containerRef} />;
}
@@ -0,0 +1,63 @@
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@/context/AppConfigContext";
import {
IconHome,
IconDashboard,
IconNodes,
IconAdvertisements,
IconMessages,
IconPackets,
IconMap,
IconMembers,
IconPage,
IconChannel,
IconPath,
} from "@/components/icons";
export function MobileNav() {
const { t } = useTranslation();
const config = useAppConfig();
const features = config.features ?? {};
const customPages = config.custom_pages ?? [];
const items: { href: string; icon: React.ReactNode; label: string }[] = [
{ href: "/", icon: <IconHome className="h-5 w-5" />, label: t("entities.home") },
];
if (features.dashboard !== false)
items.push({ href: "/dashboard", icon: <IconDashboard className="h-5 w-5 nav-icon-dashboard" />, label: t("entities.dashboard") });
if (features.nodes !== false)
items.push({ href: "/nodes", icon: <IconNodes className="h-5 w-5 nav-icon-nodes" />, label: t("entities.nodes") });
if (features.advertisements !== false)
items.push({ href: "/advertisements", icon: <IconAdvertisements className="h-5 w-5 nav-icon-adverts" />, label: t("entities.advertisements") });
if (features.routes !== false)
items.push({ href: "/routes", icon: <IconPath className="h-5 w-5 nav-icon-routes" />, label: t("entities.routes") });
if (features.channels !== false)
items.push({ href: "/channels", icon: <IconChannel className="h-5 w-5 nav-icon-channels" />, label: t("entities.channels") });
if (features.messages !== false)
items.push({ href: "/messages", icon: <IconMessages className="h-5 w-5 nav-icon-messages" />, label: t("entities.messages") });
if (features.packets !== false)
items.push({ href: "/packets", icon: <IconPackets className="h-5 w-5 nav-icon-packets" />, label: t("entities.packets") });
if (features.map !== false)
items.push({ href: "/map", icon: <IconMap className="h-5 w-5 nav-icon-map" />, label: t("entities.map") });
if (features.members !== false)
items.push({ href: "/members", icon: <IconMembers className="h-5 w-5 nav-icon-members" />, label: t("entities.members") });
if (features.pages !== false) {
for (const page of customPages) {
items.push({ href: page.url, icon: <IconPage className="h-5 w-5" />, label: page.title });
}
}
return (
<>
{items.map((item) => (
<li key={item.href}>
<a href={item.href} data-nav-link>
{item.icon} {item.label}
</a>
</li>
))}
</>
);
}
@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { getNodeEmoji } from "@/utils/format";
interface NodeDisplayProps {
name: string | null;
description?: string | null;
publicKey: string;
advType: string | null;
size?: "sm" | "base";
}
export function NodeDisplay({
name,
description,
publicKey,
advType,
size = "base",
}: NodeDisplayProps) {
const { t } = useTranslation();
const emoji = getNodeEmoji(name, advType);
const nameSize = size === "sm" ? "text-sm" : "text-base";
return (
<div className="flex items-center gap-2 min-w-0">
<span
className="text-lg flex-shrink-0"
title={advType || t("node_types.unknown")}
>
{emoji}
</span>
<div className="min-w-0">
{name ? (
<>
<div className={`font-medium ${nameSize} truncate`}>{name}</div>
{description && (
<div className="text-xs opacity-70 truncate">{description}</div>
)}
</>
) : (
<div className={`font-mono ${nameSize} truncate`}>
{publicKey.slice(0, 16)}...
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { useTranslation } from "react-i18next";
import { formatNumber, truncateKey, extractFirstEmoji } from "@/utils/format";
interface Observer {
tag_name?: string;
name?: string;
public_key: string;
}
export function ObserverIcons({ observers }: { observers: Observer[] }) {
if (!observers || observers.length === 0) return null;
const names = observers.map(
(o) => o.tag_name || o.name || truncateKey(o.public_key, 8),
);
const tooltip = names.join(", ");
return (
<span
className="badge badge-sm badge-primary observer-badge"
title={tooltip}
>
{formatNumber(observers.length)}
</span>
);
}
const OBSERVER_FILTER_KEY = "meshcore-observer-areas-disabled";
export function getDisabledObserverAreas(): Set<string> {
try {
const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return Array.isArray(arr) ? new Set(arr) : new Set();
} catch {
return new Set();
}
}
export function setDisabledObserverAreas(disabled: Set<string>): void {
try {
localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
} catch {
// ignore
}
}
export function toggleObserverArea(
area: string,
totalAreaCount: number,
): Set<string> {
const disabled = getDisabledObserverAreas();
if (disabled.has(area)) {
disabled.delete(area);
} else {
if (totalAreaCount - disabled.size <= 1) return disabled;
disabled.add(area);
}
setDisabledObserverAreas(disabled);
return disabled;
}
interface ObserverFilterBadgesProps {
areas: string[];
disabled: Set<string>;
onToggle: (area: string) => void;
extraClass?: string;
}
export function ObserverFilterBadges({
areas,
disabled,
onToggle,
extraClass = "flex",
}: ObserverFilterBadgesProps) {
const { t } = useTranslation();
if (!areas || areas.length === 0) return null;
return (
<div className={`flex-wrap items-center gap-2 ${extraClass}`}>
<span className="opacity-80 text-sm">
{t("common.filter_observer_label")}:
</span>
{areas.map((area) => {
const enabled = !disabled.has(area);
const cls = enabled
? "badge badge-primary"
: "badge badge-ghost opacity-50";
const title = enabled
? t("common.filter_observer_disable")
: t("common.filter_observer_enable");
const emoji = extractFirstEmoji(area);
const label = emoji ? area.replace(emoji, "").trim() || area : area;
return (
<button
key={area}
type="button"
className={`${cls} cursor-pointer`}
title={title}
onClick={() => onToggle(area)}
>
{emoji && <span className="mr-1">{emoji}</span>}
{label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,93 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
interface PaginationProps {
page: number;
totalPages: number;
basePath: string;
params?: Record<string, string | string[]>;
}
export function Pagination({
page,
totalPages,
basePath,
params = {},
}: PaginationProps) {
const { t } = useTranslation();
if (totalPages <= 1) return null;
const queryParts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (k === "page" || v === null || v === undefined || v === "") continue;
if (Array.isArray(v)) {
v.forEach((item) =>
queryParts.push(
`${encodeURIComponent(k)}=${encodeURIComponent(item)}`,
),
);
} else {
queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
}
}
const extraQuery = queryParts.length > 0 ? "&" + queryParts.join("&") : "";
const pageUrl = (p: number) => `${basePath}?page=${p}${extraQuery}`;
const pageNumbers: React.ReactNode[] = [];
for (let p = 1; p <= totalPages; p++) {
if (p === page) {
pageNumbers.push(
<button key={p} className="join-item btn btn-sm btn-active">
{p}
</button>,
);
} else if (
p === 1 ||
p === totalPages ||
(p >= page - 2 && p <= page + 2)
) {
pageNumbers.push(
<Link key={p} to={pageUrl(p)} className="join-item btn btn-sm">
{p}
</Link>,
);
} else if (p === 2 || p === totalPages - 1) {
pageNumbers.push(
<button
key={p}
className="join-item btn btn-sm btn-disabled"
disabled
>
...
</button>,
);
}
}
return (
<div className="flex justify-center mt-6">
<div className="join">
{page > 1 ? (
<Link to={pageUrl(page - 1)} className="join-item btn btn-sm">
{t("common.previous")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
{t("common.previous")}
</button>
)}
{pageNumbers}
{page < totalPages ? (
<Link to={pageUrl(page + 1)} className="join-item btn btn-sm">
{t("common.next")}
</Link>
) : (
<button className="join-item btn btn-sm btn-disabled" disabled>
{t("common.next")}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
export function RouteTypeBadge({ routeType }: { routeType: string | null }) {
if (!routeType) return null;
if (routeType === "flood" || routeType === "transport_flood") {
return (
<span className="badge badge-sm badge-info">
{routeType === "flood" ? "Flood" : "Relay"}
</span>
);
}
if (routeType === "direct" || routeType === "transport_direct") {
return (
<span className="badge badge-sm badge-success">
{routeType === "direct" ? "Zero-hop" : "Direct relay"}
</span>
);
}
return null;
}
@@ -0,0 +1,121 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
function buildSortUrl(
basePath: string,
params: Record<string, string | string[]>,
nextSort: string,
nextOrder: string,
): string {
const sp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== "") {
if (Array.isArray(value)) {
value.forEach((item) => sp.append(key, String(item)));
} else {
sp.set(key, String(value));
}
}
}
if (nextSort && nextOrder) {
sp.set("sort", nextSort);
sp.set("order", nextOrder);
}
const qs = sp.toString();
return qs ? `${basePath}?${qs}` : basePath;
}
interface SortableTableHeaderProps {
label: string;
sortKey: string;
currentSort: string;
currentOrder: string;
basePath: string;
params?: Record<string, string | string[]>;
}
export function SortableTableHeader({
label,
sortKey,
currentSort,
currentOrder,
basePath,
params = {},
}: SortableTableHeaderProps) {
let indicator = "";
let nextOrder: string;
if (currentSort !== sortKey) {
nextOrder = "asc";
} else if (currentOrder === "asc") {
nextOrder = "desc";
indicator = " \u25B4";
} else {
nextOrder = "asc";
indicator = " \u25BE";
}
const url = buildSortUrl(basePath, params, sortKey, nextOrder);
return (
<th>
<Link
to={url}
className="link link-hover inline-flex items-center gap-1 no-underline"
onClick={(e) => e.stopPropagation()}
>
{label}
<span className="text-xs opacity-50">{indicator}</span>
</Link>
</th>
);
}
interface SortOption {
value: string;
label: string;
}
interface MobileSortSelectProps {
currentSort: string;
currentOrder: string;
basePath: string;
params?: Record<string, string | string[]>;
options: SortOption[];
}
export function MobileSortSelect({
currentSort,
currentOrder,
basePath,
params = {},
options,
}: MobileSortSelectProps) {
const { t } = useTranslation();
const currentValue = `${currentSort}:${currentOrder}`;
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const [sort, order] = e.target.value.split(":");
const url = buildSortUrl(basePath, params, sort, order);
window.location.href = url;
};
return (
<div className="lg:hidden mb-5">
<div className="flex items-center gap-2">
<span className="text-xs opacity-60">{t("common.sort_by")}</span>
<select
className="select select-sm flex-1"
value={currentValue}
onChange={handleChange}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { formatNumber } from "@/utils/format";
interface StatCardProps {
icon: ReactNode;
color: string;
title: string;
value: number | string;
description?: string;
}
export function StatCard({
icon,
color,
title,
value,
description,
}: StatCardProps) {
return (
<div
className="stat bg-base-200 rounded-box shadow-sm panel-accent !py-2"
style={{ "--panel-color": color } as React.CSSProperties}
>
<div className="stat-figure" style={{ color }}>
{icon}
</div>
<div className="stat-title">{title}</div>
<div className="stat-value text-3xl">{formatNumber(value)}</div>
{description && <div className="stat-desc">{description}</div>}
</div>
);
}
@@ -0,0 +1,7 @@
import { useAppConfig } from "@/context/AppConfigContext";
export function TimezoneIndicator() {
const config = useAppConfig();
const tz = config.timezone || "UTC";
return <span className="text-xs opacity-50 ml-2">({tz})</span>;
}
@@ -0,0 +1,463 @@
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function base(props: IconProps) {
return {
xmlns: "http://www.w3.org/2000/svg",
fill: "none",
viewBox: "0 0 24 24",
stroke: "currentColor",
className: "h-5 w-5",
...props,
};
}
export function IconDashboard(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
);
}
export function IconMap(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
);
}
export function IconNodes(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
);
}
export function IconAdvertisements(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"
/>
</svg>
);
}
export function IconMessages(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
);
}
export function IconPackets(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
);
}
export function IconHome(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
);
}
export function IconMembers(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
}
export function IconPage(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
);
}
export function IconInfo(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function IconAlert(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
);
}
export function IconChart(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
/>
</svg>
);
}
export function IconRefresh(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
);
}
export function IconError(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function IconSuccess(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function IconChannel(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/>
</svg>
);
}
export function IconPath(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z"
/>
</svg>
);
}
export function IconUser(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
);
}
export function IconLogout(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
);
}
export function IconFilter(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
}
export function IconChevronRight(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
);
}
export function IconEdit(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
);
}
export function IconTrash(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
);
}
export function IconPlus(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
);
}
export function IconAntenna(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z"
/>
</svg>
);
}
export function IconUsers(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
);
}
export function IconSettings(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
}
export function IconFrequency(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2 12C3.333 4 6.667 4 8 12c1.333 8 4.667 8 6 0 1.333-8 4.667-8 6 0"
/>
</svg>
);
}
export function IconBandwidth(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
/>
</svg>
);
}
export function IconSpreadingFactor(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
/>
</svg>
);
}
export function IconCodingRate(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
}
export function IconTxPower(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
);
}
export function IconClock(props: IconProps) {
return (
<svg {...base(props)}>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export function IconGithub(props: IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
{...props}
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
@@ -0,0 +1,59 @@
import { createContext, useContext, type ReactNode } from "react";
import type { AppConfig } from "@/types/config";
const AppConfigContext = createContext<AppConfig | null>(null);
export function AppConfigProvider({
config,
children,
}: {
config: AppConfig;
children: ReactNode;
}) {
return (
<AppConfigContext.Provider value={config}>
{children}
</AppConfigContext.Provider>
);
}
export function useAppConfig(): AppConfig {
const ctx = useContext(AppConfigContext);
if (!ctx) throw new Error("useAppConfig must be used within AppConfigProvider");
return ctx;
}
export function useFeatures(): Record<string, boolean> {
return useAppConfig().features;
}
export function hasRole(roleName: string): boolean {
const config = window.__APP_CONFIG__;
if (!config?.oidc_enabled) return false;
const actualRole = config.role_names?.[roleName] ?? roleName;
return (config.roles ?? []).includes(actualRole);
}
export function getChannelLabelsMap(
config: AppConfig = window.__APP_CONFIG__,
): Map<number, string> {
return new Map(
Object.entries(config.channel_labels ?? {})
.map(([idx, label]) => [
parseInt(idx, 10),
typeof label === "string" ? label.trim() : "",
])
.filter(
([idx, label]) => Number.isInteger(idx) && (label as string).length > 0,
) as [number, string][],
);
}
export function resolveChannelLabel(
channelIdx: number | string,
channelLabels: Map<number, string> = getChannelLabelsMap(),
): string | null {
const parsed = parseInt(String(channelIdx), 10);
if (!Number.isInteger(parsed)) return null;
return channelLabels.get(parsed) ?? null;
}
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useAppConfig } from "@/context/AppConfigContext";
interface UseAutoRefreshOptions {
onRefresh: () => Promise<void>;
}
interface UseAutoRefreshReturn {
paused: boolean;
toggle: () => void;
intervalSeconds: number;
}
export function useAutoRefresh({
onRefresh,
}: UseAutoRefreshOptions): UseAutoRefreshReturn {
const config = useAppConfig();
const intervalSeconds = config.auto_refresh_seconds || 0;
const [paused, setPaused] = useState(false);
const isPendingRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh;
const toggle = useCallback(() => setPaused((p) => !p), []);
useEffect(() => {
if (!intervalSeconds || paused) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
return;
}
const tick = async () => {
if (isPendingRef.current) return;
isPendingRef.current = true;
try {
await onRefreshRef.current();
} catch {
// handled by caller
} finally {
isPendingRef.current = false;
}
};
timerRef.current = setInterval(tick, intervalSeconds * 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [intervalSeconds, paused]);
return { paused, toggle, intervalSeconds };
}
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { useAppConfig } from "@/context/AppConfigContext";
const titleMap: Record<string, string> = {};
export function usePageTitle(entityKey?: string) {
const config = useAppConfig();
const networkName = config.network_name || "MeshCore Network";
useEffect(() => {
if (entityKey) {
const entity = window.t(entityKey);
document.title = `${entity} - ${networkName}`;
} else {
document.title = networkName;
}
}, [entityKey, networkName]);
}
@@ -0,0 +1,57 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
let initialized = false;
export async function initI18n(): Promise<typeof i18n> {
if (initialized) return i18n;
const config = window.__APP_CONFIG__;
const storedLocale = localStorage.getItem("meshcore-locale");
const locale = storedLocale || config?.locale || "en";
const version = config?.locale_version || "";
let resources: Record<string, { translation: Record<string, unknown> }> = {};
try {
const res = await fetch(
`/static/locales/${locale}.json${version ? "?v=" + version : ""}`,
);
if (res.ok) {
const data = await res.json();
resources = { [locale]: { translation: data } };
}
} catch (e) {
console.warn(`Failed to load locale '${locale}':`, e);
}
await i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
lng: locale,
fallbackLng: "en",
interpolation: {
escapeValue: false,
prefix: "{{",
suffix: "}}",
},
detection: {
order: ["localStorage", "navigator"],
lookupLocalStorage: "meshcore-locale",
caches: ["localStorage"],
},
react: {
useSuspense: false,
},
});
window.t = (key: string, params?: Record<string, unknown>) =>
i18n.t(key, params ?? {});
initialized = true;
return i18n;
}
export { i18n };
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8" /></head>
<body>
<div id="app"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
declare module "@legacy/pages/*.js" {
export function render(
container: HTMLElement,
params: Record<string, unknown>,
router: { navigate: (url: string, replace?: boolean) => void },
): Promise<(() => void) | void>;
}
@@ -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) => (
<StrictMode>
<AppConfigProvider config={config}>{ui}</AppConfigProvider>
</StrictMode>
);
createRoot(appContainer).render(wrap(<App />));
const authContainer = document.getElementById("auth-section");
if (authContainer) {
createRoot(authContainer).render(wrap(<AuthSection />));
}
const mobileNavContainer = document.getElementById("mobile-nav");
if (mobileNavContainer) {
createRoot(mobileNavContainer).render(wrap(<MobileNav />));
}
}
bootstrap();
@@ -0,0 +1,21 @@
import { useTranslation } from "react-i18next";
import { usePageTitle } from "@/hooks/usePageTitle";
export function Maintenance() {
const { t } = useTranslation();
usePageTitle();
return (
<div className="hero min-h-[60vh]">
<div className="hero-content text-center">
<div className="max-w-md">
<div className="text-7xl mb-4">🔧</div>
<h1 className="text-4xl font-bold mb-4">
{t("maintenance.title")}
</h1>
<p className="text-lg opacity-70">{t("maintenance.description")}</p>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="hero min-h-[60vh]">
<div className="hero-content text-center">
<div className="max-w-md">
<div className="text-9xl font-bold text-primary opacity-20">404</div>
<h1 className="text-4xl font-bold -mt-8">
{t("common.page_not_found")}
</h1>
<p className="py-6 opacity-70">{t("not_found.description")}</p>
<div className="flex gap-4 justify-center">
<Link to="/" className="btn btn-primary">
<IconHome className="h-5 w-5 mr-2" />
{t("common.go_home")}
</Link>
<Link to="/nodes" className="btn btn-outline">
<IconNodes className="h-5 w-5 mr-2" />
{t("common.view_entity", { entity: t("entities.nodes") })}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -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<string, boolean>;
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<string, string>;
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<string, string>;
}
declare global {
interface Window {
__APP_CONFIG__: AppConfig;
t: (key: string, params?: Record<string, unknown>) => 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 {};
@@ -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<T = unknown>(
path: string,
params: Record<string, unknown> = {},
{ signal }: { signal?: AbortSignal } = {},
): Promise<T> {
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<T = unknown>(
path: string,
body: unknown,
): Promise<T | null> {
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<T = unknown>(
path: string,
body: unknown,
): Promise<T | null> {
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<void> {
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<T = unknown>(
path: string,
data: Record<string, string>,
): Promise<T | null> {
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();
}
@@ -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);
}
@@ -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();
}
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+3
View File
@@ -217,6 +217,9 @@
</script>
<!-- SPA Application (ES Module) -->
{% if asset_app_css %}
<link rel="stylesheet" href="/static/dist/{{ asset_app_css }}">
{% endif %}
{% if asset_app_js %}
<script type="module" src="/static/dist/{{ asset_app_js }}"></script>
{% else %}
+23
View File
@@ -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"]
}
+38
View File
@@ -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"],
},
},
},
},
});