Merge pull request #180 from ipnet-mesh/chore/ui-refactoring

Refactor SPA frontend: eliminate inline SVGs, extract shared components
This commit is contained in:
JingleManSweep
2026-05-02 16:55:31 +01:00
committed by GitHub
11 changed files with 1170 additions and 384 deletions
@@ -0,0 +1,595 @@
# UI Frontend Refactor — Inline SVGs & Template Extraction
**Date:** 2026-05-02
**Status:** Draft
## Overview
Refactor the SPA frontend to eliminate inline SVG markup and extract large lit-html templates into reusable functions. The codebase already has a well-structured `icons.js` module (25 lit-html icon functions) and a `components.js` module (shared UI functions), but they are inconsistently used — 13 inline SVG instances exist across the codebase, 7 of which duplicate existing icons. Additionally, two page files have monolithic render functions exceeding 100 lines, and three list pages duplicate a nearly identical filter card pattern.
No API changes. No CSS changes needed (CSS already uses `app.css` custom properties cleanly). No build changes. Pure JavaScript refactoring.
## Decisions
1. **All SVGs must use `icons.js` lit-html functions** — No raw `<svg>` strings anywhere. All icon functions accept a `cls` parameter for sizing, enabling consistent `class=${cls}` usage.
2. **New icon additions OK** — Add `iconSettings` (gear/cog), `iconLogout` (door-arrow), `iconPause`, `iconPlay` to `icons.js`. The pause/play icons use 20x20 viewBox with `fill` (Tailwind-style small icons, matching their usage context in the auto-refresh button). Consider prefix naming or a separate collection if future 20x20 icons accumulate.
3. **Convert `renderAuthSection()` to lit-html** — Currently uses `innerHTML =` with a 60-line raw HTML template string and three raw-string SVG helpers. Rewrite using lit-html `litRender()` and `html\`...\``, using `icons.js` functions for all SVGs.
4. **Extract filter form pattern and adopt in pages** — Three pages (nodes, ads, messages) share a nearly identical filter card. Extract a `renderFilterCard()` component into `components.js` that accepts field definitions, basePath, and a navigate function. Retrofit all three pages to use it.
5. **Extract stat card pattern**`home.js` and `dashboard.js` duplicate the same stat-card template structure. Extract `renderStatCard()` into `components.js`.
6. **Extract modal dialogs from node-tags.js** — Five `<dialog>` modals (edit, move, delete, copy-all, delete-all) are defined inline across 127 lines. Extract each to a separate render function.
7. **Extract home.js hero + nav sections** — The 123-line monolithic `render()` function in `home.js` should be split into `renderHeroSection()`, `renderStatsPanel()`, and `renderActivityChart()`.
8. **No backend changes** — This is a pure frontend refactor. No Python, schema, or template changes.
## Terminology
| Term | Meaning |
|---|---|
| Lit-html | JavaScript tagged template library (`html\`...\``) used for all SPA rendering |
| `icons.js` | Module exporting icon functions that return lit-html `TemplateResult` with configurable CSS classes |
| `components.js` | Module exporting shared UI components (alerts, pagination, etc.) and utility functions |
| Raw HTML SVG | An SVG defined as a plain string (e.g., `'<svg>...</svg>'`) rather than via a lit-html `html\`...\`` template or `icons.js` function |
| Inline SVG | An `<svg>` element directly inside a lit-html `html\`...\`` template literal, not using an `icons.js` function |
| Filter card pattern | A `<div class="card shadow mb-6 panel-solid">` containing a filter form with selects, submit, and clear buttons |
## Current State
### File Size Overview
| File | Lines | Key Concern |
|---|---|---|
| `icons.js` | 107 | Complete; missing 4 icons |
| `components.js` | 667 | 3 alerts with duplicate inline SVGs; 3 raw-string SVG helpers; large `renderAuthSection` |
| `auto-refresh.js` | 87 | 2 inline SVGs (pause/play, 20x20 fill) |
| `home.js` | 209 | **123-line monolithic render()** |
| `dashboard.js` | 252 | 96-line render(); duplicate stat cards from home.js |
| `nodes.js` | 207 | Filter form pattern duplicate |
| `node-detail.js` | 358 | Well-factored with sub-renderers |
| `messages.js` | 375 | Filter form pattern duplicate; dedupe logic (62 lines, non-UI) |
| `advertisements.js` | 251 | Filter form pattern duplicate |
| `map.js` | 349 | 81-line filter+map+legend template |
| `members.js` | 92 | Clean |
| `profile.js` | 174 | Clean |
| `admin/index.js` | 65 | Clean |
| `admin/node-tags.js` | 526 | **5 inline SVGs (all duplicates); 127 lines of modal dialogs** |
| `app.js` | 195 | Clean |
| `router.js` | 163 | Clean |
| `api.js` | 114 | Clean |
| `i18n.js` | 76 | Clean |
| `charts.js` | 231 | Clean |
### Inline SVG Inventory — 13 Instances, 9 Unique Icons
#### Already exists in `icons.js` (9 instances, 5 unique)
| # | File | Line | SVG | Should Use |
|---|------|------|-----|------------|
| 1 | `components.js:errorAlert()` | 371 | X-circle | `iconError('stroke-current shrink-0 h-6 w-6')` |
| 2 | `components.js:infoAlert()` | 383 | Info circle-i | `iconInfo('stroke-current shrink-0 h-6 w-6')` |
| 3 | `components.js:successAlert()` | 395 | Check-circle | `iconSuccess('stroke-current shrink-0 h-6 w-6')` |
| 4 | `components.js:_svgUser()` | 596 | Person icon (raw string) | `iconUser('h-4 w-4')` — requires lit-html usage |
| 5 | `admin/node-tags.js` | 198 | Warning triangle | `iconAlert('stroke-current shrink-0 h-6 w-6')` |
| 6 | `admin/node-tags.js` | 216 | Warning triangle | `iconAlert(...)` |
| 7 | `admin/node-tags.js` | 246 | Info circle | `iconInfo('stroke-current shrink-0 h-6 w-6')` |
| 8 | `admin/node-tags.js` | 265 | Warning triangle | `iconAlert(...)` |
| 9 | `admin/node-tags.js` | 279 | Warning triangle | `iconAlert(...)` |
#### Missing from `icons.js` (4 instances, 4 unique)
| # | File | Line | SVG | New Function Name |
|---|------|------|-----|------------------|
| 1 | `components.js:_svgSettings()` | 600 | Gear/cog (24x24 stroke) | `iconSettings` |
| 2 | `components.js:_svgLogout()` | 604 | Door-arrow-out (24x24 stroke) | `iconLogout` |
| 3 | `auto-refresh.js` | 32 | Pause bars (20x20 fill) | `iconPause` |
| 4 | `auto-refresh.js` | 33 | Play triangle (20x20 fill) | `iconPlay` |
### Large Template Extraction Candidates
#### High Priority (100+ lines of monolithic rendering)
| File | Lines | Content |
|------|-------|---------|
| **`home.js`** | 69191 (123 lines) | Hero/logo, nav buttons, stats panel, info card, activity chart — extract to `renderHeroSection`, `renderStatsPanel`, `renderActivityChart` |
| **`admin/node-tags.js`** | 149275 (127 lines) | Five `<dialog>` modals (edit, move, delete, copy-all, delete-all) — extract each to separate render function |
#### Medium Priority (shared patterns)
| Pattern | Files | Description |
|---------|-------|-------------|
| **Filter card** | `nodes.js` (L128170), `messages.js` (L309336), `advertisements.js` (L182213) | Nearly identical filter form in a card — extract to `renderFilterCard()` in `components.js` |
| **Stat card** | `home.js` (L111142), `dashboard.js` (L141169) | Same stat-card pattern (icon, title, value, description) — extract to `renderStatCard()` in `components.js` |
| **Chart cards** | `dashboard.js` (L172214) | Chart containers with canvas elements — extract to `renderChartCard()` |
---
## Implementation
### Phase 1: Add Missing Icons to `icons.js`
**File:** `src/meshcore_hub/web/static/js/spa/icons.js`
Add 4 new icon functions at the end of the file (after L107):
```javascript
// Phase 1 additions:
export function iconSettings(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>`;
}
export function iconLogout(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 iconPause(cls = 'w-4 h-4') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 20 20" fill="currentColor"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z" /></svg>`;
}
export function iconPlay(cls = 'w-4 h-4') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 20 20" fill="currentColor"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z" /></svg>`;
}
```
### Phase 2: Replace Inline SVGs in `components.js`
**File:** `src/meshcore_hub/web/static/js/spa/components.js`
#### 2.1 Update import from `icons.js`
Change line 11 from:
```javascript
import { iconAlert } from './icons.js';
```
to:
```javascript
import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconSettings, iconLogout } from './icons.js';
```
#### 2.2 Replace inline SVGs in alert functions
**`errorAlert()` (L369374)** — replace inline SVG on L371:
```javascript
// Before (L371):
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
// After:
${iconError('stroke-current shrink-0 h-6 w-6')}
```
**`infoAlert()` (L381386)** — replace inline SVG on L383:
```javascript
// Before (L383):
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
// After:
${iconInfo('stroke-current shrink-0 h-6 w-6')}
```
**`successAlert()` (L393398)** — replace inline SVG on L395:
```javascript
// Before (L395):
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
// After:
${iconSuccess('stroke-current shrink-0 h-6 w-6')}
```
#### 2.3 Remove raw-string SVG helpers and convert `renderAuthSection()` to lit-html
Remove `_svgUser()` (L595597), `_svgSettings()` (L599601), `_svgLogout()` (L603605).
Rewrite `renderAuthSection()` to use `litRender()` with `html\`...\`` instead of `innerHTML =`. The DaisyUI dropdown (CSS-based via `dropdown` class + `tabindex` attributes) works identically with lit-html rendered DOM since no JS event handlers are required. For the login case, use a standard `<a>` element. For the logged-in case, render the DaisyUI dropdown structure:
```javascript
export function renderAuthSection(container, config) {
if (!container) return;
if (!config.oidc_enabled) {
litRender(nothing, container);
return;
}
const user = config.user;
if (!user) {
litRender(html`
<a href="/auth/login" class="btn btn-sm btn-outline">${t('auth.login')}</a>
`, container);
return;
}
const displayName = user.name || user.email || 'User';
const initials = displayName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const pictureHtml = user.picture
? html`<img src=${user.picture} alt=${displayName} class="w-8 h-8 rounded-full" />`
: html`<span class="text-sm font-bold">${initials}</span>`;
const roleBadges = (config.roles || []).map(r => {
const key = `auth.role_${r}`;
const label = t(key);
const name = label !== key ? label : r;
return html`<span class="badge badge-primary badge-xs">${name}</span>`;
});
const adminItem = hasRole('admin')
? html`<li><a href="/admin/">${iconSettings('h-4 w-4')} ${t('entities.admin')}</a></li>`
: nothing;
const profileItem = html`<li><a href="/profile">${iconUser('h-4 w-4')} ${t('links.profile')}</a></li>`;
const debugId = config.debug && user.sub
? html`<span class="text-xs opacity-40 font-mono">${user.sub}</span>`
: nothing;
litRender(html`
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle btn-sm avatar">
${pictureHtml}
</div>
<ul tabindex="0" class="dropdown-content menu menu-sm z-[1] p-2 shadow bg-base-100 rounded-box w-52 mt-3">
<li class="menu-title">
<div class="flex flex-col gap-1">
<span class="font-medium">${displayName}</span>
${debugId}
${roleBadges.length > 0 ? html`<div class="flex flex-wrap gap-1">${roleBadges}</div>` : nothing}
</div>
</li>
<hr class="my-1 opacity-20">
${adminItem}
${profileItem}
<li><a href="/auth/logout">${iconLogout('h-4 w-4')} ${t('auth.logout')}</a></li>
</ul>
</div>
`, container);
}
```
Key differences from the `innerHTML` version:
- Uses `litRender(..., container)` — clearing + rendering in one call (no manual `innerHTML = ''` needed)
- Uses lit-html conditionals (`nothing`, ternary) instead of string concatenation
- Uses `iconUser`, `iconSettings`, `iconLogout` from `icons.js` instead of raw SVG strings
- DaisyUI dropdown: identical DOM structure, CSS `:focus-within` behavior works the same with lit-html rendered content
### Phase 3: Replace Inline SVGs in `auto-refresh.js`
**File:** `src/meshcore_hub/web/static/js/spa/auto-refresh.js`
#### 3.1 Add import
Add at top of file:
```javascript
import { iconPause, iconPlay, iconInfo } from './icons.js';
```
#### 3.2 Replace inline SVGs
Lines 3233 currently define inline SVG strings in lit-html templates. Replace with icon functions:
```javascript
// Before (L32-33):
const pauseIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z" /></svg>`;
const playIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z" /></svg>`;
// After:
const pauseIcon = iconPause('w-4 h-4');
const playIcon = iconPlay('w-4 h-4');
```
### Phase 4: Replace Inline SVGs in `admin/node-tags.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js`
#### 4.1 Update import
Add `iconAlert, iconInfo` to imports from `../../icons.js`.
#### 4.2 Replace 5 inline SVG instances
| Line | Replace with |
|------|-------------|
| 198 | `${iconAlert('stroke-current shrink-0 h-6 w-6')}` (in #moveModal alert) |
| 216 | `${iconAlert('stroke-current shrink-0 h-6 w-6')}` (in #deleteModal alert) |
| 246 | `${iconInfo('stroke-current shrink-0 h-6 w-6')}` (in #copyAllModal alert) |
| 265 | `${iconAlert('stroke-current shrink-0 h-6 w-6')}` (in #deleteAllModal alert) |
| 279 | `${iconAlert('stroke-current shrink-0 h-6 w-6')}` (node-not-found alert) |
Each instance is a full `<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path ... /></svg>` block that must be replaced with the corresponding icon function call inside the lit-html template.
### Phase 5: Extract Shared Components into `components.js`
**File:** `src/meshcore_hub/web/static/js/spa/components.js`
#### 5.1 Add `renderFilterCard()`
Extract the shared filter form pattern used by nodes, advertisements, and messages pages.
```javascript
/**
* Render a filter card with configurable form fields, submit, and clear buttons.
* @param {Object} options
* @param {Array<Function>} options.fields - Array of render functions returning lit-html form controls
* @param {string} options.basePath - Base URL path for the page
* @param {Function} options.navigate - Router navigate function
* @param {string} [options.submitLabel] - Text for submit button (default: "Filter")
* @param {string} [options.clearLabel] - Text for clear button (default: "Clear")
* @returns {TemplateResult}
*/
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel }) {
return html`
<div class="card shadow mb-6 panel-solid">
<div class="card-body py-4">
<form @submit=${createFilterHandler(basePath, navigate)} class="flex gap-4 flex-wrap items-end">
${fields.map(f => f())}
<div class="flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">${submitLabel || t('common.filter')}</button>
<a href=${basePath} class="btn btn-ghost btn-sm">${clearLabel || t('common.clear')}</a>
</div>
</form>
</div>
</div>
`;
}
```
**Note:** Page-specific filter fields (select dropdowns, text inputs) are passed as render functions, keeping page-specific logic in the page files. The component is adopted immediately in all three pages (Phase 6).
#### 5.2 Add `renderStatCard()`
Extract the stat card pattern duplicated in `home.js` and `dashboard.js`:
```javascript
/**
* Render a single stat card for dashboard/home pages.
* @param {Object} options
* @param {TemplateResult} options.icon - lit-html icon (from icons.js)
* @param {string} options.color - CSS color value for the glow (e.g., pageColors.dashboard)
* @param {string} options.title - Stat title (e.g., "Total Nodes")
* @param {string|number} options.value - Stat value
* @param {string} [options.description] - Optional stat description
* @returns {TemplateResult}
*/
export function renderStatCard({ icon, color, title, value, description }) {
return html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${color}">
<div class="stat-figure">${icon}</div>
<div class="stat-title">${title}</div>
<div class="stat-value">${value}</div>
${description ? html`<div class="stat-desc">${description}</div>` : nothing}
</div>`;
}
```
### Phase 6: Adopt `renderFilterCard()` in List Pages
**Files:** `nodes.js`, `messages.js`, `advertisements.js`
Retrofit all three list pages to use the extracted `renderFilterCard()` component from `components.js`. Each page defines its own filter fields as render functions and passes them to `renderFilterCard()`.
#### 6.1 `nodes.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
The filter card (L128170) contains a search text input, an `adv_type` select, and a conditional `adopted_by` select (shown when OIDC is enabled). Replace the inline filter card HTML with `renderFilterCard()`.
```javascript
import { renderFilterCard } from '../components.js';
// Inside fetchAndRenderData():
const filterHtml = renderFilterCard({
fields: [
() => html`
<div class="form-control">
<label class="label"><span class="label-text">${t('node_types.type')}</span></label>
<select name="adv_type" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="" ...>${t('common.all')}</option>
${advTypes.map(t => html`<option value=${t} ...>${t}</option>`)}
</select>
</div>`,
() => html`
<div class="form-control">
<label class="label"><span class="label-text">${t('filters.search')}</span></label>
<input type="text" name="search" value=${searchVal} class="input input-bordered input-sm w-40"
@keydown=${submitOnEnter} />
</div>`,
],
basePath: '/nodes',
navigate: (url) => router.navigate(url, true),
});
```
#### 6.2 `advertisements.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
The filter card (L179236) contains a type select and search input. Same pattern as nodes — replace inline filter card with `renderFilterCard()`.
#### 6.3 `messages.js`
**File:** `src/meshcore_hub/web/static/js/spa/pages/messages.js`
The filter card (L306360) contains a type select, channel direction select, and channel index select. Replace inline filter card with `renderFilterCard()`.
### Phase 7: Refactor `home.js` — Extract Sub-Renderers
**File:** `src/meshcore_hub/web/static/js/spa/pages/home.js`
The current `render()` function (L30209) contains a single 123-line `litRender(html\`...\`)` call. Extract these sub-renderers:
#### 6.1 `renderHeroSection()`
Extract L7277 (logo + site title + description):
```javascript
function renderHeroSection(config) {
const logoSrc = window.__LOGO__ || '/static/images/logo.svg';
return html`
<div class="text-center mb-8">
<img src=${logoSrc} alt=${config.network_name || 'MeshCore Hub'} class="mx-auto mb-4 w-24 h-24" />
<h1 class="text-3xl font-bold mb-2">${config.network_name || 'MeshCore Hub'}</h1>
</div>`;
}
```
#### 6.2 `renderStatsPanel()`
Extract L111142 (stats cards — nodes, adverts, messages, with icons and values). Replace inline stat-card markup with `renderStatCard()` from Phase 5.2.
#### 6.3 `renderActivityChart()`
Extract L178190 (the activity chart card with canvas element).
#### 6.4 Updated `render()`
After extraction, the `render()` function orchestrates the sub-renderers:
```javascript
export async function render(container, params, router) {
const config = getConfig();
// fetch data, then:
litRender(html`
<div class="py-6">
${renderHeroSection(config)}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
${renderStatsPanel(statsData)}
</div>
<!-- info card, etc. -->
${renderActivityChart()}
</div>
`, container);
}
```
### Phase 8: Refactor `admin/node-tags.js` — Extract Modal Dialogs
**File:** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js`
Extract the 5 `<dialog>` modals (L149275, 127 lines) into separate render functions:
| Function | Lines | Dialog ID |
|----------|-------|-----------|
| `renderEditModal()` | L149176 | `#editModal` |
| `renderMoveModal()` | L178208 | `#moveModal` |
| `renderDeleteModal()` | L210226 | `#deleteModal` |
| `renderCopyAllModal()` | L228256 | `#copyAllModal` |
| `renderDeleteAllModal()` | L258275 | `#deleteAllModal` |
Each function returns a lit-html `TemplateResult` containing the `<dialog>` element with its form. The modal JS logic (event listeners, `showModal()`, `close()`) stays in the page file.
Example extraction:
```javascript
function renderEditModal(tag) {
return html`
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">${t('admin_node_tags.edit_tag')}</h3>
<form method="dialog" @submit=${handleEditSubmit}>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">${t('common.key')}</span>
</label>
<input type="text" name="key" class="input input-bordered w-full"
value=${tag.key} required maxlength="255" />
</div>
<!-- ... -->
</form>
</div>
</dialog>`;
}
```
**Note:** Modal event handler references (e.g., `handleEditSubmit`) are closure-bound functions defined in the page's `render()` scope. These can be passed as parameters or kept as closures if the functions are defined before the render call.
### Phase 9: Update `dashboard.js` — Use `renderStatCard()`
**File:** `src/meshcore_hub/web/static/js/spa/pages/dashboard.js`
Update `render()` to use the extracted `renderStatCard()` component from `components.js`:
```javascript
import { renderStatCard } from '../components.js';
// ... existing imports
```
Replace the 3 inline stat cards (L141169) with `renderStatCard()` calls.
Also extract `renderChartCards()` (L172214) as a separate helper within the page to reduce nesting in the main `render()` call.
### Phase 10: Integration Verification
After all phases, verify no regressions:
#### 9.1 File-by-file checklist
- [ ] `icons.js` — 4 new functions appended, no existing functions modified
- [ ] `components.js` — 3 alert functions use icon imports, 3 raw-string helpers removed, `renderAuthSection()` uses icon imports, 2 new exported functions
- [ ] `auto-refresh.js` — 2 inline SVGs replaced, icon imports added
- [ ] `admin/node-tags.js` — 5 inline SVGs replaced, 5 modal functions extracted, existing behavior preserved
- [ ] `home.js` — 3 sub-renderers extracted, monolithic render split, `renderStatCard()` used
- [ ] `dashboard.js` — Inline stat cards replaced with `renderStatCard()`
- [ ] `nodes.js` — Filter card uses `renderFilterCard()`
- [ ] `messages.js` — Filter card uses `renderFilterCard()`
- [ ] `advertisements.js` — Filter card uses `renderFilterCard()`
#### 10.2 Build & verify
```bash
# Frontend build (verifies bundling, no JS import errors)
npm run build
# Run web tests (catches runtime issues in the SPA)
source .venv/bin/activate
pytest tests/test_web/ -v
```
#### 10.3 Manual verification areas
- All alert components render correctly (error, info, success, warning)
- Auth dropdown: login button visible when unauthenticated; user menu opens/closes when logged in; icons and role badges display correctly
- Auto-refresh toggle shows correct pause/play icons
- Admin node-tags page: modals open/close, submit, delete, copy-all still work
- Home page: hero, stats, activity chart render identically
- Dashboard: stat cards and charts render identically
- Nodes/Ads/Messages: filter forms function correctly, filters apply and clear as before
---
## File Change Summary
| # | File | Action | Phase | Description |
|---|------|--------|-------|-------------|
| 1 | `web/static/js/spa/icons.js` | Modify | 1 | Add `iconSettings`, `iconLogout`, `iconPause`, `iconPlay` |
| 2 | `web/static/js/spa/components.js` | Modify | 2, 5 | Replace 3 alert inline SVGs; remove 3 raw-string helpers; update `renderAuthSection()`; add `renderFilterCard()`, `renderStatCard()` |
| 3 | `web/static/js/spa/auto-refresh.js` | Modify | 3 | Replace 2 inline SVGs with `iconPause`/`iconPlay`; add imports |
| 4 | `web/static/js/spa/pages/admin/node-tags.js` | Modify | 4, 8 | Replace 5 inline SVGs; extract 5 modal render functions |
| 5 | `web/static/js/spa/pages/nodes.js` | Modify | 6 | Replace inline filter card with `renderFilterCard()` |
| 6 | `web/static/js/spa/pages/messages.js` | Modify | 6 | Replace inline filter card with `renderFilterCard()` |
| 7 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 | Replace inline filter card with `renderFilterCard()` |
| 8 | `web/static/js/spa/pages/home.js` | Modify | 7 | Extract `renderHeroSection`, `renderStatsPanel`, `renderActivityChart`; use `renderStatCard()` |
| 9 | `web/static/js/spa/pages/dashboard.js` | Modify | 9 | Replace inline stat cards with `renderStatCard()`; extract `renderChartCards()` |
| 10 | `tests/test_web/` (relevant) | Verify | 10 | Ensure existing tests still pass; no new tests needed (pure refactor) |
---
## Execution Order
1. **Phase 1:** Add 4 missing icons to `icons.js`
2. **Phase 2:** Replace inline SVGs in `components.js` alerts + remove raw-string helpers
3. **Phase 3:** Replace inline SVGs in `auto-refresh.js`
4. **Phase 4:** Replace inline SVGs in `admin/node-tags.js`
5. **Phase 5:** Add `renderFilterCard()` and `renderStatCard()` to `components.js`
6. **Phase 6:** Adopt `renderFilterCard()` in `nodes.js`, `messages.js`, `advertisements.js`
7. **Phase 7:** Refactor `home.js` — extract sub-renderers, use `renderStatCard()`
8. **Phase 8:** Refactor `admin/node-tags.js` — extract modal dialogs
9. **Phase 9:** Refactor `dashboard.js` — use `renderStatCard()`, extract `renderChartCards()`
10. **Phase 10:** Build verification (`npm run build`), web test suite (`pytest tests/test_web/ -v`), manual smoke test
Phases 14 eliminate all inline SVGs. Phases 59 extract large templates and adopt shared components. Phase 10 validates.
---
## Out of Scope (Deferred)
| Item | Reason |
|------|--------|
| Messages dedup logic extraction | `messages.js` L109170 (62 lines) is pure data processing, not UI rendering. Could be moved to a shared utility module but not part of this UI refactor. |
| CSS refactoring | `app.css` is already well-organized (388 lines with clear sections). No changes needed. |
@@ -0,0 +1,102 @@
# UI Frontend Refactor — Inline SVGs & Template Extraction — Task Checklist
**Plan:** `docs/plans/20260502-1520-ui-refactor/plan.md`
**Status:** Not Started
---
## Phase 1: Add Missing Icons to `icons.js`
- [ ] **1.1** `src/meshcore_hub/web/static/js/spa/icons.js` — Add `iconSettings(gear/cog, 24x24 stroke)` function after `iconAntenna`
- [ ] **1.2** `src/meshcore_hub/web/static/js/spa/icons.js` — Add `iconLogout(door-arrow-out, 24x24 stroke)` function
- [ ] **1.3** `src/meshcore_hub/web/static/js/spa/icons.js` — Add `iconPause(pause bars, 20x20 fill)` function
- [ ] **1.4** `src/meshcore_hub/web/static/js/spa/icons.js` — Add `iconPlay(play triangle, 20x20 fill)` function
- [ ] **1.5** Verify SVG paths for iconPause/iconPlay match the existing inline SVGs in `auto-refresh.js` L3233 (paths: `M5.75 3a.75...` for pause, `M6.3 2.84A1.5...` for play)
## Phase 2: Replace Inline SVGs in `components.js`
- [ ] **2.1** `src/meshcore_hub/web/static/js/spa/components.js` — Update `icons.js` import (L11): add `iconError, iconInfo, iconSuccess, iconUser, iconSettings, iconLogout`
- [ ] **2.2** `src/meshcore_hub/web/static/js/spa/components.js` — Replace inline SVG in `errorAlert()` (L371) with `${iconError('stroke-current shrink-0 h-6 w-6')}`
- [ ] **2.3** `src/meshcore_hub/web/static/js/spa/components.js` — Replace inline SVG in `infoAlert()` (L383) with `${iconInfo('stroke-current shrink-0 h-6 w-6')}`
- [ ] **2.4** `src/meshcore_hub/web/static/js/spa/components.js` — Replace inline SVG in `successAlert()` (L395) with `${iconSuccess('stroke-current shrink-0 h-6 w-6')}`
- [ ] **2.5** `src/meshcore_hub/web/static/js/spa/components.js` — Remove `_svgUser()` (L595597), `_svgSettings()` (L599601), `_svgLogout()` (L603605) raw-string SVG helpers
- [ ] **2.6** `src/meshcore_hub/web/static/js/spa/components.js` — Rewrite `renderAuthSection()` (L607667) to use `litRender()` with `html\`...\`` instead of `innerHTML =`; use `iconUser`, `iconSettings`, `iconLogout` from `icons.js`; use `nothing` for empty state; use ternary for `adminItem`/`debugId` conditionals
- [ ] **2.7** Manually verify auth dropdown opens/closes correctly (CSS `:focus-within` behavior should be unchanged)
## Phase 3: Replace Inline SVGs in `auto-refresh.js`
- [ ] **3.1** `src/meshcore_hub/web/static/js/spa/auto-refresh.js` — Add `import { iconPause, iconPlay, iconInfo } from './icons.js';` at top
- [ ] **3.2** `src/meshcore_hub/web/static/js/spa/auto-refresh.js` — Replace inline pause SVG (L32) with `iconPause('w-4 h-4')`
- [ ] **3.3** `src/meshcore_hub/web/static/js/spa/auto-refresh.js` — Replace inline play SVG (L33) with `iconPlay('w-4 h-4')`
## Phase 4: Replace Inline SVGs in `admin/node-tags.js`
- [ ] **4.1** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Add `iconAlert, iconInfo` to imports from `../../icons.js`
- [ ] **4.2** Replace inline SVG L198 (moveModal warning) with `${iconAlert('stroke-current shrink-0 h-6 w-6')}`
- [ ] **4.3** Replace inline SVG L216 (deleteModal warning) with `${iconAlert('stroke-current shrink-0 h-6 w-6')}`
- [ ] **4.4** Replace inline SVG L246 (copyAllModal info) with `${iconInfo('stroke-current shrink-0 h-6 w-6')}`
- [ ] **4.5** Replace inline SVG L265 (deleteAllModal warning) with `${iconAlert('stroke-current shrink-0 h-6 w-6')}`
- [ ] **4.6** Replace inline SVG L279 (node-not-found warning) with `${iconAlert('stroke-current shrink-0 h-6 w-6')}`
## Phase 5: Extract Shared Components into `components.js`
- [ ] **5.1** `src/meshcore_hub/web/static/js/spa/components.js` — Add `renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel })` exported function — renders a `.card.panel-solid` wrapper with a form, field render functions, and submit/clear buttons
- [ ] **5.2** `src/meshcore_hub/web/static/js/spa/components.js` — Add `renderStatCard({ icon, color, title, value, description })` exported function — renders a `.stat.panel-glow` card with icon figure, title, value, and optional description
## Phase 6: Adopt `renderFilterCard()` in List Pages
- [ ] **6.1** `src/meshcore_hub/web/static/js/spa/pages/nodes.js` — Import `renderFilterCard`; replace inline filter card HTML (L125171) with `renderFilterCard()` call passing 3 field render fns (search input, adv_type select, adopted_by select conditional), `basePath: '/nodes'`, and `navigate`
- [ ] **6.2** `src/meshcore_hub/web/static/js/spa/pages/messages.js` — Import `renderFilterCard`; replace inline filter card HTML (L306337) with `renderFilterCard()` call passing 2 field render fns (message_type select, channel_idx select), `basePath: '/messages'`, and `navigate`
- [ ] **6.3** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — Import `renderFilterCard`; replace inline filter card HTML (L179214) with `renderFilterCard()` call passing 3 field render fns (search input, nodesFilter fragment, adopted_by select conditional), `basePath: '/advertisements'`, and `navigate`
## Phase 7: Refactor `home.js` — Extract Sub-Renderers
- [ ] **7.1** `src/meshcore_hub/web/static/js/spa/pages/home.js` — Extract `renderHeroSection(config)` function from L70108 (logo, network name, city/country, welcome text, nav buttons)
- [ ] **7.2** `src/meshcore_hub/web/static/js/spa/pages/home.js` — Extract `renderStatsPanel(stats)` function from L111142 (3 stat cards using `renderStatCard()` from Phase 5.2)
- [ ] **7.3** `src/meshcore_hub/web/static/js/spa/pages/home.js` — Extract `renderActivityChart()` function from L178190 (activity chart card with canvas element)
- [ ] **7.4** `src/meshcore_hub/web/static/js/spa/pages/home.js` — Update `render()` to orchestrate sub-renderers: call `renderHeroSection()`, `renderStatsPanel()`, and `renderActivityChart()` as lit-html interpolations
## Phase 8: Refactor `admin/node-tags.js` — Extract Modal Dialogs
- [ ] **8.1** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderEditModal()` from L149176 (#editModal dialog)
- [ ] **8.2** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderMoveModal()` from L178208 (#moveModal dialog)
- [ ] **8.3** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderDeleteModal()` from L210226 (#deleteModal dialog)
- [ ] **8.4** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderCopyAllModal()` from L228256 (#copyAllModal dialog)
- [ ] **8.5** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderDeleteAllModal()` from L258275 (#deleteAllModal dialog)
- [ ] **8.6** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Update `render()` to reference extracted modal functions; ensure event handler references (e.g., `handleEditSubmit`) remain accessible via closure or parameters
## Phase 9: Update `dashboard.js` — Use `renderStatCard()`
- [ ] **9.1** `src/meshcore_hub/web/static/js/spa/pages/dashboard.js` — Import `renderStatCard` from `../components.js`
- [ ] **9.2** `src/meshcore_hub/web/static/js/spa/pages/dashboard.js` — Replace 3 inline stat cards (L141169) with `renderStatCard()` calls, each providing icon, color, title, value, and description
- [ ] **9.3** `src/meshcore_hub/web/static/js/spa/pages/dashboard.js` — Extract `renderChartCards()` helper from L172214 (3 conditional chart card containers)
## Phase 10: Integration Verification
- [ ] **10.1** Run `npm run build` — verify no JS import errors
- [ ] **10.2** Run `source .venv/bin/activate && pytest tests/test_web/ -v` — verify existing web tests still pass
- [ ] **10.3** Run `source .venv/bin/activate && pre-commit run --all-files` — verify lint, type check, and format
- [ ] **10.4** Manual smoke test — verify alerts render correctly (error, info, success, warning)
- [ ] **10.5** Manual smoke test — verify auth dropdown (login button when unauthenticated; user menu opens/closes when logged in; icons and role badges display)
- [ ] **10.6** Manual smoke test — verify auto-refresh toggle shows correct pause/play icons
- [ ] **10.7** Manual smoke test — verify admin node-tags page modals open/close, submit, delete, copy-all still work
- [ ] **10.8** Manual smoke test — verify home page hero, stats, activity chart render identically
- [ ] **10.9** Manual smoke test — verify dashboard stat cards and charts render identically
- [ ] **10.10** Manual smoke test — verify nodes/ads/messages filter forms function correctly (filters apply, clear works)
---
## File Change Summary
| # | File | Action | Phase(s) |
|---|------|--------|----------|
| 1 | `web/static/js/spa/icons.js` | Modify | 1 |
| 2 | `web/static/js/spa/components.js` | Modify | 2, 5 |
| 3 | `web/static/js/spa/auto-refresh.js` | Modify | 3 |
| 4 | `web/static/js/spa/pages/admin/node-tags.js` | Modify | 4, 8 |
| 5 | `web/static/js/spa/pages/nodes.js` | Modify | 6 |
| 6 | `web/static/js/spa/pages/messages.js` | Modify | 6 |
| 7 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 |
| 8 | `web/static/js/spa/pages/home.js` | Modify | 7 |
| 9 | `web/static/js/spa/pages/dashboard.js` | Modify | 9 |
| 10 | `tests/test_web/` | Verify | 10 |
@@ -7,6 +7,7 @@
*/
import { html, litRender, getConfig, t } from './components.js';
import { iconPause, iconPlay, iconInfo } from './icons.js';
/**
* Create an auto-refresh controller.
@@ -29,8 +30,8 @@ export function createAutoRefresh({ fetchAndRender, toggleContainer }) {
let timerId = null;
function renderToggle() {
const pauseIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z"/></svg>`;
const playIcon = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z"/></svg>`;
const pauseIcon = iconPause('w-4 h-4');
const playIcon = iconPlay('w-4 h-4');
const tooltip = paused ? t('auto_refresh.resume') : t('auto_refresh.pause');
const icon = paused ? playIcon : pauseIcon;
@@ -8,7 +8,7 @@ import { html, nothing } from 'lit-html';
import { render } from 'lit-html';
import { unsafeHTML } from 'lit-html/directives/unsafe-html.js';
import { t } from './i18n.js';
import { iconAlert } from './icons.js';
import { iconAlert, iconError, iconInfo, iconSuccess, iconUser, iconSettings, iconLogout } from './icons.js';
// Re-export lit-html utilities for page modules
export { html, nothing, unsafeHTML };
@@ -368,7 +368,7 @@ export function loading() {
*/
export function errorAlert(message) {
return html`<div role="alert" class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
${iconError('stroke-current shrink-0 h-6 w-6')}
<span>${message}</span>
</div>`;
}
@@ -380,7 +380,7 @@ export function errorAlert(message) {
*/
export function infoAlert(message) {
return html`<div role="alert" class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
${iconInfo('stroke-current shrink-0 h-6 w-6')}
<span>${message}</span>
</div>`;
}
@@ -392,7 +392,7 @@ export function infoAlert(message) {
*/
export function successAlert(message) {
return html`<div role="alert" class="alert alert-success mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
${iconSuccess('stroke-current shrink-0 h-6 w-6')}
<span>${message}</span>
</div>`;
}
@@ -592,59 +592,45 @@ export function submitOnEnter(e) {
* @param {HTMLElement} container - The #auth-section element
* @param {Object} config - App configuration object
*/
function _svgUser() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>';
}
function _svgSettings() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>';
}
function _svgLogout() {
return '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 renderAuthSection(container, config) {
if (!container) return;
if (!config.oidc_enabled) {
container.innerHTML = '';
render(nothing, container);
return;
}
const user = config.user;
if (!user) {
container.innerHTML = `
render(html`
<a href="/auth/login" class="btn btn-sm btn-outline">${t('auth.login')}</a>
`;
`, container);
return;
}
const displayName = user.name || user.email || 'User';
const initials = displayName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
const pictureHtml = user.picture
? `<img src="${user.picture}" alt="${displayName}" class="w-8 h-8 rounded-full" />`
: `<span class="text-sm font-bold">${initials}</span>`;
? html`<img src=${user.picture} alt=${displayName} class="w-8 h-8 rounded-full" />`
: html`<span class="text-sm font-bold">${initials}</span>`;
const roleBadges = (config.roles || [])
.map(r => {
const key = `auth.role_${r}`;
const label = t(key);
const name = label !== key ? label : r;
return `<span class="badge badge-primary badge-xs">${name}</span>`;
})
.join('');
const roleBadges = (config.roles || []).map(r => {
const key = `auth.role_${r}`;
const label = t(key);
const name = label !== key ? label : r;
return html`<span class="badge badge-primary badge-xs">${name}</span>`;
});
const adminItem = hasRole('admin')
? `<li><a href="/admin/">${_svgSettings()} ${t('entities.admin')}</a></li>`
: '';
? html`<li><a href="/admin/">${iconSettings('h-4 w-4')} ${t('entities.admin')}</a></li>`
: nothing;
const profileItem = `<li><a href="/profile">${_svgUser()} ${t('links.profile')}</a></li>`;
const profileItem = html`<li><a href="/profile">${iconUser('h-4 w-4')} ${t('links.profile')}</a></li>`;
const debugId = config.debug && user.sub
? `<span class="text-xs opacity-40 font-mono">${user.sub}</span>`
: '';
? html`<span class="text-xs opacity-40 font-mono">${user.sub}</span>`
: nothing;
container.innerHTML = `
render(html`
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle btn-sm avatar">
${pictureHtml}
@@ -654,14 +640,58 @@ export function renderAuthSection(container, config) {
<div class="flex flex-col gap-1">
<span class="font-medium">${displayName}</span>
${debugId}
${roleBadges ? `<div class="flex flex-wrap gap-1">${roleBadges}</div>` : ''}
${roleBadges.length > 0 ? html`<div class="flex flex-wrap gap-1">${roleBadges}</div>` : nothing}
</div>
</li>
<hr class="my-1 opacity-20">
${adminItem}
${profileItem}
<li><a href="/auth/logout">${_svgLogout()} ${t('auth.logout')}</a></li>
<li><a href="/auth/logout">${iconLogout('h-4 w-4')} ${t('auth.logout')}</a></li>
</ul>
</div>
`, container);
}
/**
* Render a filter card with configurable form fields, submit, and clear buttons.
* @param {Array<Function>} options.fields - Array of render functions returning lit-html form controls
* @param {string} options.basePath - Base URL path for the page (e.g., '/nodes')
* @param {Function} options.navigate - Router navigate function
* @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter")
* @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear")
* @returns {TemplateResult}
*/
export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel }) {
return html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action=${basePath} class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler(basePath, navigate)}>
${fields.map(f => f())}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${submitLabel || t('common.filter')}</button>
<a href=${basePath} class="btn btn-ghost btn-sm">${clearLabel || t('common.clear')}</a>
</div>
</form>
</div>
</div>
`;
}
/**
* Render a single stat card for dashboard/home pages.
* @param {TemplateResult} options.icon - lit-html icon (from icons.js)
* @param {string} options.color - CSS color value for glow (e.g., pageColors.dashboard)
* @param {string} options.title - Stat title
* @param {string|number} options.value - Stat value
* @param {string} [options.description] - Optional description
* @returns {TemplateResult}
*/
export function renderStatCard({ icon, color, title, value, description }) {
return html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${color}">
<div class="stat-figure" style="color: ${color}">${icon}</div>
<div class="stat-title">${title}</div>
<div class="stat-value" style="color: ${color}">${value}</div>
${description ? html`<div class="stat-desc">${description}</div>` : nothing}
</div>`;
}
@@ -105,3 +105,19 @@ export function iconUsers(cls = 'h-5 w-5') {
export function iconAntenna(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 iconSettings(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>`;
}
export function iconLogout(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 iconPause(cls = 'w-4 h-4') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 20 20" fill="currentColor"><path d="M5.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75A.75.75 0 0 0 7.25 3h-1.5ZM12.75 3a.75.75 0 0 0-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 0 0 .75-.75V3.75a.75.75 0 0 0-.75-.75h-1.5Z" /></svg>`;
}
export function iconPlay(cls = 'w-4 h-4') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} viewBox="0 0 20 20" fill="currentColor"><path d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.84Z" /></svg>`;
}
@@ -4,7 +4,148 @@ import {
getConfig, hasRole, typeEmoji, formatDateTimeShort, errorAlert,
successAlert, truncateKey, t, escapeHtml,
} from '../../components.js';
import { iconTag, iconLock } from '../../icons.js';
import { iconTag, iconLock, iconAlert, iconInfo } from '../../icons.js';
function renderEditModal() {
return html`
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
<form id="edit-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" id="editValue" class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select id="editValueType" class="select select-bordered w-full">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
}
function renderMoveModal(otherNodes) {
return html`
<dialog id="moveModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
<form id="move-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.tag_key')}</span></label>
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="moveDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-warning mb-4">
${iconAlert('stroke-current shrink-0 h-6 w-6')}
<span>${t('admin_node_tags.move_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="moveCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-warning">${t('common.move_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
}
function renderDeleteModal() {
return html`
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
<div class="py-4">
<p class="py-4" id="delete_tag_confirm_message"></p>
<div class="alert alert-error mb-4">
${iconAlert('stroke-current shrink-0 h-6 w-6')}
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
}
function renderCopyAllModal(otherNodes, tags, nodeName) {
return html`
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
<form id="copy-all-form" class="py-4">
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: escapeHtml(nodeName) }))}</p>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="copyAllDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-info mb-4">
${iconInfo('stroke-current shrink-0 h-6 w-6')}
<span>${t('admin_node_tags.copy_all_info')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
}
function renderDeleteAllModal(tags, nodeName) {
return html`
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
<div class="py-4">
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: escapeHtml(nodeName) }))}</p>
<div class="alert alert-error mb-4">
${iconAlert('stroke-current shrink-0 h-6 w-6')}
<span>${t('admin_node_tags.delete_all_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteAllCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteAllConfirm">${t('common.delete_all_entity', { entity: t('entities.tags') })}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
}
export async function render(container, params, router) {
try {
@@ -146,137 +287,15 @@ export async function render(container, params, router) {
</div>
</div>
<dialog id="editModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.edit_entity', { entity: t('entities.tag') })}</h3>
<form id="edit-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.key')}</span></label>
<input type="text" id="editKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.value')}</span></label>
<input type="text" id="editValue" class="input input-bordered">
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('common.type')}</span></label>
<select id="editValueType" class="select select-bordered w-full">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" id="editCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save_changes')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="moveModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.move_entity_to_another_node', { entity: t('entities.tag') })}</h3>
<form id="move-tag-form" class="py-4">
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.tag_key')}</span></label>
<input type="text" id="moveKeyDisplay" class="input input-bordered" disabled>
</div>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="moveDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<span>${t('admin_node_tags.move_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="moveCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-warning">${t('common.move_entity', { entity: t('entities.tag') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_entity', { entity: t('entities.tag') })}</h3>
<div class="py-4">
<p class="py-4" id="delete_tag_confirm_message"></p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<span>${t('common.cannot_be_undone')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteConfirm">${t('common.delete')}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.copy_all_entity_to_another_node', { entity: t('entities.tags') })}</h3>
<form id="copy-all-form" class="py-4">
<!-- unsafeHTML needed for translation HTML tags; nodeName is pre-escaped -->
<p class="mb-4">${unsafeHTML(t('common.copy_all_entity_description', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: escapeHtml(nodeName) }))}</p>
<div class="form-control mb-4">
<label class="label"><span class="label-text">${t('admin_node_tags.destination_node')}</span></label>
<select id="copyAllDestination" class="select select-bordered w-full" required>
<option value="">${t('map.select_destination_node')}</option>
${otherNodes.map(n => {
const name = n.name || t('common.unnamed');
const keyPreview = n.public_key.slice(0, 8) + '...' + n.public_key.slice(-4);
return html`<option value=${n.public_key}>${name} (${keyPreview})</option>`;
})}
</select>
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>${t('admin_node_tags.copy_all_info')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="copyAllCancel">${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.copy_entity', { entity: t('entities.tags') })}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">${t('common.delete_all_entity', { entity: t('entities.tags') })}</h3>
<div class="py-4">
<!-- unsafeHTML needed for translation HTML tags; nodeName is pre-escaped -->
<p class="mb-4">${unsafeHTML(t('common.delete_all_entity_confirm', { count: tags.length, entity: t('entities.tags').toLowerCase(), name: escapeHtml(nodeName) }))}</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
<span>${t('admin_node_tags.delete_all_warning')}</span>
</div>
<div class="modal-action">
<button type="button" class="btn" id="deleteAllCancel">${t('common.cancel')}</button>
<button type="button" class="btn btn-error" id="deleteAllConfirm">${t('common.delete_all_entity', { entity: t('entities.tags') })}</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button>${t('common.close')}</button></form>
</dialog>`;
${renderEditModal()}
${renderMoveModal(otherNodes)}
${renderDeleteModal()}
${renderCopyAllModal(otherNodes, tags, nodeName)}
${renderDeleteAllModal(tags, nodeName)}`;
} else if (selectedPublicKey && !selectedNode) {
contentHtml = html`
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
${iconAlert('stroke-current shrink-0 h-6 w-6')}
<span>Node not found: ${selectedPublicKey}</span>
</div>`;
} else {
@@ -3,7 +3,7 @@ import {
html, litRender, nothing, t,
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
warningBadge,
pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
pagination, renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -176,18 +176,20 @@ ${displayContent}`, container);
search, public_key, adopted_by, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/advertisements" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/advertisements', navigate)}>
const filterFields = [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
${nodesFilter}
${config.oidc_enabled && profiles.length > 0 ? html`
</div>`,
];
if (sortedNodes.length > 0) {
filterFields.push(() => nodesFilter);
}
if (config.oidc_enabled && profiles.length > 0) {
filterFields.push(() => html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
@@ -203,15 +205,16 @@ ${displayContent}`, container);
${p.callsign ? p.name + ' (' + p.callsign + ')' : (p.name || p.callsign || p.user_id || p.id)}
</option>`)}
</select>
</div>
` : nothing}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/advertisements" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
</div>`);
}
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/advertisements',
navigate,
});
renderPage(html`${filterCard}
<div class="lg:hidden space-y-3">
${mobileCards}
@@ -2,7 +2,7 @@ import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, getChannelLabelsMap, resolveChannelLabel,
typeEmoji, errorAlert, pageColors, t, formatDateTime,
typeEmoji, errorAlert, pageColors, renderStatCard, t, formatDateTime,
} from '../components.js';
import {
iconNodes, iconAdvertisements, iconMessages, iconChannel,
@@ -107,69 +107,11 @@ function gridCols(count) {
return '';
}
export async function render(container, params, router) {
try {
const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
const features = config.features || {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
]);
// Top section: stats + charts
const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const topGrid = gridCols(topCount);
// Bottom section: recent adverts + recent channel messages
const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const bottomGrid = gridCols(bottomCount);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.dashboard')}</h1>
</div>
${topCount > 0 ? html`
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-6">
${showNodes ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">${t('dashboard.all_discovered_nodes')}</div>
</div>` : nothing}
${showAdverts ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${showMessages ? html`
<div class="stat bg-base-100 rounded-box shadow-xl panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-8">
function renderChartCards({ showNodes, showAdverts, showMessages }) {
const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
if (visibleCount === 0) return nothing;
return html`
<div class="grid grid-cols-1 ${gridCols(visibleCount)} gap-6 mb-8">
${showNodes ? html`
<div class="card bg-base-100 shadow-xl panel-glow" style="--panel-color: var(--color-neutral)">
<div class="card-body">
@@ -211,7 +153,66 @@ ${topCount > 0 ? html`
</div>
</div>
</div>` : nothing}
</div>` : nothing}
</div>`;
}
export async function render(container, params, router) {
try {
const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
const features = config.features || {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
]);
// Top section: stats + charts
const topCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const topGrid = gridCols(topCount);
// Bottom section: recent adverts + recent channel messages
const bottomCount = (showAdverts ? 1 : 0) + (showMessages ? 1 : 0);
const bottomGrid = gridCols(bottomCount);
litRender(html`
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">${t('entities.dashboard')}</h1>
</div>
${topCount > 0 ? html`
<div class="grid grid-cols-1 ${topGrid} gap-6 mb-6">
${showNodes ? renderStatCard({
icon: iconNodes('h-8 w-8'),
color: pageColors.nodes,
title: t('common.total_entity', { entity: t('entities.nodes') }),
value: stats.total_nodes,
description: t('dashboard.all_discovered_nodes'),
}) : nothing}
${showAdverts ? renderStatCard({
icon: iconAdvertisements('h-8 w-8'),
color: pageColors.adverts,
title: t('entities.advertisements'),
value: stats.advertisements_7d,
description: t('time.last_7_days'),
}) : nothing}
${showMessages ? renderStatCard({
icon: iconMessages('h-8 w-8'),
color: pageColors.messages,
title: t('entities.messages'),
value: stats.messages_7d,
description: t('time.last_7_days'),
}) : nothing}
</div>
${renderChartCards({ showNodes, showAdverts, showMessages })}` : nothing}
${bottomCount > 0 ? html`
<div class="grid grid-cols-1 ${bottomGrid} gap-6">
+121 -103
View File
@@ -1,7 +1,7 @@
import { apiGet } from '../api.js';
import {
html, litRender, nothing,
getConfig, errorAlert, pageColors, t,
getConfig, errorAlert, pageColors, renderStatCard, t,
} from '../components.js';
import {
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap,
@@ -27,6 +27,110 @@ function renderRadioConfig(rc) {
</div>`);
}
function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, networkCountry, networkWelcomeText, features, customPages }) {
const cityCountry = (networkCity && networkCountry)
? html`<p class="text-lg sm:text-2xl opacity-70 mt-2">${networkCity}, ${networkCountry}</p>`
: nothing;
const welcomeText = networkWelcomeText
? html`<p class="py-4 max-w-[70%]">${networkWelcomeText}</p>`
: html`<p class="py-4 max-w-[70%]">
${t('home.welcome_default', { network_name: networkName })}
</p>`;
const customPageButtons = features.pages !== false
? customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`)
: [];
return html`
<div class="flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="theme-logo ${logoInvertLight ? 'theme-logo--invert-light' : ''} h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
${t('entities.dashboard')}
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
${t('entities.nodes')}
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
${t('entities.advertisements')}
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
${t('entities.messages')}
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
${t('entities.map')}
</a>` : nothing}
${customPageButtons}
</div>
</div>`;
}
function renderStatsPanel({ features, stats }) {
return html`
<div class="flex flex-col gap-4">
${features.nodes !== false ? renderStatCard({
icon: iconNodes('h-8 w-8'),
color: pageColors.nodes,
title: t('common.total_entity', { entity: t('entities.nodes') }),
value: stats.total_nodes,
description: t('home.all_discovered_nodes'),
}) : nothing}
${features.advertisements !== false ? renderStatCard({
icon: iconAdvertisements('h-8 w-8'),
color: pageColors.adverts,
title: t('entities.advertisements'),
value: stats.advertisements_7d,
description: t('time.last_7_days'),
}) : nothing}
${features.messages !== false ? renderStatCard({
icon: iconMessages('h-8 w-8'),
color: pageColors.messages,
title: t('entities.messages'),
value: stats.messages_7d,
description: t('time.last_7_days'),
}) : nothing}
</div>`;
}
function renderActivityChartCard({ showAdvertSeries, showMessageSeries }) {
return html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${iconChart('h-6 w-6')}
${t('home.network_activity')}
</h2>
<p class="text-sm opacity-70 mb-2">${t('time.activity_per_day_last_7_days')}</p>
<div class="h-48">
<canvas id="activityChart"></canvas>
</div>
</div>
</div>`;
}
export async function render(container, params, router) {
try {
const config = getConfig();
@@ -43,103 +147,29 @@ export async function render(container, params, router) {
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
]);
const cityCountry = (config.network_city && config.network_country)
? html`<p class="text-lg sm:text-2xl opacity-70 mt-2">${config.network_city}, ${config.network_country}</p>`
: nothing;
const welcomeText = config.network_welcome_text
? html`<p class="py-4 max-w-[70%]">${config.network_welcome_text}</p>`
: html`<p class="py-4 max-w-[70%]">
${t('home.welcome_default', { network_name: networkName })}
</p>`;
const customPageButtons = features.pages !== false
? customPages.slice(0, 3).map(page => html`
<a href="${page.url}" class="btn btn-outline">
${iconPage('h-5 w-5 mr-2')}
${page.title}
</a>`)
: [];
const showStats = features.nodes !== false || features.advertisements !== false || features.messages !== false;
const showAdvertSeries = features.advertisements !== false;
const showMessageSeries = features.messages !== false;
const showActivityChart = showAdvertSeries || showMessageSeries;
const heroSection = renderHeroSection({
networkName, logoUrl, logoInvertLight,
networkCity: config.network_city,
networkCountry: config.network_country,
networkWelcomeText: config.network_welcome_text,
features, customPages,
});
const statsPanel = renderStatsPanel({ features, stats });
const activityChartCard = renderActivityChartCard({ showAdvertSeries, showMessageSeries });
litRender(html`
<div class="${showStats ? 'grid grid-cols-1 lg:grid-cols-3 gap-6' : ''} bg-base-100 rounded-box shadow-xl p-6">
<div class="${showStats ? 'lg:col-span-2' : ''} flex flex-col items-center text-center">
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 mb-4">
<img src="${logoUrl}" alt="${networkName}" class="theme-logo ${logoInvertLight ? 'theme-logo--invert-light' : ''} h-24 w-24 sm:h-36 sm:w-36" />
<div class="flex flex-col justify-center">
<h1 class="hero-title text-3xl sm:text-5xl lg:text-6xl font-black tracking-tight">${networkName}</h1>
${cityCountry}
</div>
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 mt-auto">
${features.dashboard !== false ? html`
<a href="/dashboard" class="btn btn-outline btn-info">
${iconDashboard('h-5 w-5 mr-2')}
${t('entities.dashboard')}
</a>` : nothing}
${features.nodes !== false ? html`
<a href="/nodes" class="btn btn-outline btn-primary">
${iconNodes('h-5 w-5 mr-2')}
${t('entities.nodes')}
</a>` : nothing}
${features.advertisements !== false ? html`
<a href="/advertisements" class="btn btn-outline btn-secondary">
${iconAdvertisements('h-5 w-5 mr-2')}
${t('entities.advertisements')}
</a>` : nothing}
${features.messages !== false ? html`
<a href="/messages" class="btn btn-outline btn-accent">
${iconMessages('h-5 w-5 mr-2')}
${t('entities.messages')}
</a>` : nothing}
${features.map !== false ? html`
<a href="/map" class="btn btn-outline btn-warning">
${iconMap('h-5 w-5 mr-2')}
${t('entities.map')}
</a>` : nothing}
${customPageButtons}
</div>
<div class="${showStats ? 'lg:col-span-2' : ''}">
${heroSection}
</div>
${showStats ? html`
<div class="flex flex-col gap-4">
${features.nodes !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.nodes}">
<div class="stat-figure" style="color: ${pageColors.nodes}">
${iconNodes('h-8 w-8')}
</div>
<div class="stat-title">${t('common.total_entity', { entity: t('entities.nodes') })}</div>
<div class="stat-value" style="color: ${pageColors.nodes}">${stats.total_nodes}</div>
<div class="stat-desc">${t('home.all_discovered_nodes')}</div>
</div>` : nothing}
${features.advertisements !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.adverts}">
<div class="stat-figure" style="color: ${pageColors.adverts}">
${iconAdvertisements('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.advertisements')}</div>
<div class="stat-value" style="color: ${pageColors.adverts}">${stats.advertisements_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
${features.messages !== false ? html`
<div class="stat bg-base-200 rounded-box shadow panel-glow" style="--panel-color: ${pageColors.messages}">
<div class="stat-figure" style="color: ${pageColors.messages}">
${iconMessages('h-8 w-8')}
</div>
<div class="stat-title">${t('entities.messages')}</div>
<div class="stat-value" style="color: ${pageColors.messages}">${stats.messages_7d}</div>
<div class="stat-desc">${t('time.last_7_days')}</div>
</div>` : nothing}
</div>` : nothing}
${showStats ? statsPanel : nothing}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 ${showActivityChart ? 'lg:grid-cols-3' : ''} gap-6 mt-6">
@@ -175,19 +205,7 @@ export async function render(container, params, router) {
</div>
</div>
${showActivityChart ? html`
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
${iconChart('h-6 w-6')}
${t('home.network_activity')}
</h2>
<p class="text-sm opacity-70 mb-2">${t('time.activity_per_day_last_7_days')}</p>
<div class="h-48">
<canvas id="activityChart"></canvas>
</div>
</div>
</div>` : nothing}
${showActivityChart ? activityChartCard : nothing}
</div>`, container);
let chart = null;
@@ -5,7 +5,7 @@ import {
getChannelLabelsMap, resolveChannelLabel,
truncateKey, warningBadge,
pagination, timezoneIndicator,
createFilterHandler, autoSubmit, submitOnEnter,
renderFilterCard, autoSubmit, submitOnEnter,
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -303,10 +303,9 @@ ${displayContent}`, container);
message_type, channel_idx, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/messages" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/messages', navigate)}>
const filterCard = renderFilterCard({
fields: [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
@@ -316,7 +315,8 @@ ${displayContent}`, container);
<option value="contact" ?selected=${message_type === 'contact'}>${t('messages.type_direct')}</option>
<option value="channel" ?selected=${message_type === 'channel'}>${t('messages.type_channel')}</option>
</select>
</div>
</div>`,
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('entities.channel')}</span>
@@ -327,14 +327,13 @@ ${displayContent}`, container);
html`<option value=${idx} ?selected=${channel_idx === String(idx)}>${label}</option>`
)}
</select>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/messages" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
</div>`,
],
basePath: '/messages',
navigate,
});
renderPage(html`${filterCard}
<div class="lg:hidden space-y-3">
${mobileCards}
@@ -4,7 +4,7 @@ import {
getConfig, formatDateTime, formatDateTimeShort,
warningBadge,
pagination,
createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -122,16 +122,15 @@ ${displayContent}`, container);
search, adv_type, adopted_by, limit,
});
renderPage(html`
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
<div class="card-body py-4">
<form method="GET" action="/nodes" class="flex gap-4 flex-wrap items-end" @submit=${createFilterHandler('/nodes', navigate)}>
const filterFields = [
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.search')}</span>
</label>
<input type="text" name="search" .value=${search} placeholder="${t('common.search_placeholder')}" class="input input-bordered input-sm w-80" @keydown=${submitOnEnter} />
</div>
</div>`,
() => html`
<div class="form-control">
<label class="label py-1">
<span class="label-text">${t('common.type')}</span>
@@ -143,8 +142,10 @@ ${displayContent}`, container);
<option value="companion" ?selected=${adv_type === 'companion'}>${t('node_types.companion')}</option>
<option value="room" ?selected=${adv_type === 'room'}>${t('node_types.room')}</option>
</select>
</div>
${config.oidc_enabled && profiles.length > 0 ? html`
</div>`,
];
if (config.oidc_enabled && profiles.length > 0) {
filterFields.push(() => html`
<div class="form-control max-w-56">
<label class="label py-1">
<span class="label-text">${t('common.filter_member_label')}</span>
@@ -160,15 +161,16 @@ ${displayContent}`, container);
${p.callsign ? p.name + ' (' + p.callsign + ')' : (p.name || p.callsign || p.user_id || p.id)}
</option>`)}
</select>
</div>
` : nothing}
<div class="flex gap-2 w-full sm:w-auto">
<button type="submit" class="btn btn-primary btn-sm">${t('common.filter')}</button>
<a href="/nodes" class="btn btn-ghost btn-sm">${t('common.clear')}</a>
</div>
</form>
</div>
</div>
</div>`);
}
const filterCard = renderFilterCard({
fields: filterFields,
basePath: '/nodes',
navigate,
});
renderPage(html`${filterCard}
<div class="lg:hidden space-y-3">
${mobileCards}