mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-03 15:33:20 +02:00
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:
+1
-1
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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 = {
|
||||
|
||||
Generated
+1867
-380
File diff suppressed because it is too large
Load Diff
+20
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user