mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-06 08:52:50 +02:00
Merge pull request #180 from ipnet-mesh/chore/ui-refactoring
Refactor SPA frontend: eliminate inline SVGs, extract shared components
This commit is contained in:
@@ -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`** | 69–191 (123 lines) | Hero/logo, nav buttons, stats panel, info card, activity chart — extract to `renderHeroSection`, `renderStatsPanel`, `renderActivityChart` |
|
||||
| **`admin/node-tags.js`** | 149–275 (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` (L128–170), `messages.js` (L309–336), `advertisements.js` (L182–213) | Nearly identical filter form in a card — extract to `renderFilterCard()` in `components.js` |
|
||||
| **Stat card** | `home.js` (L111–142), `dashboard.js` (L141–169) | Same stat-card pattern (icon, title, value, description) — extract to `renderStatCard()` in `components.js` |
|
||||
| **Chart cards** | `dashboard.js` (L172–214) | 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()` (L369–374)** — 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()` (L381–386)** — 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()` (L393–398)** — 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()` (L595–597), `_svgSettings()` (L599–601), `_svgLogout()` (L603–605).
|
||||
|
||||
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 32–33 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 (L128–170) 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 (L179–236) 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 (L306–360) 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 (L30–209) contains a single 123-line `litRender(html\`...\`)` call. Extract these sub-renderers:
|
||||
|
||||
#### 6.1 `renderHeroSection()`
|
||||
|
||||
Extract L72–77 (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 L111–142 (stats cards — nodes, adverts, messages, with icons and values). Replace inline stat-card markup with `renderStatCard()` from Phase 5.2.
|
||||
|
||||
#### 6.3 `renderActivityChart()`
|
||||
|
||||
Extract L178–190 (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 (L149–275, 127 lines) into separate render functions:
|
||||
|
||||
| Function | Lines | Dialog ID |
|
||||
|----------|-------|-----------|
|
||||
| `renderEditModal()` | L149–176 | `#editModal` |
|
||||
| `renderMoveModal()` | L178–208 | `#moveModal` |
|
||||
| `renderDeleteModal()` | L210–226 | `#deleteModal` |
|
||||
| `renderCopyAllModal()` | L228–256 | `#copyAllModal` |
|
||||
| `renderDeleteAllModal()` | L258–275 | `#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 (L141–169) with `renderStatCard()` calls.
|
||||
|
||||
Also extract `renderChartCards()` (L172–214) 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 1–4 eliminate all inline SVGs. Phases 5–9 extract large templates and adopt shared components. Phase 10 validates.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (Deferred)
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| Messages dedup logic extraction | `messages.js` L109–170 (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` L32–33 (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()` (L595–597), `_svgSettings()` (L599–601), `_svgLogout()` (L603–605) raw-string SVG helpers
|
||||
- [ ] **2.6** `src/meshcore_hub/web/static/js/spa/components.js` — Rewrite `renderAuthSection()` (L607–667) 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 (L125–171) 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 (L306–337) 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 (L179–214) 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 L70–108 (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 L111–142 (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 L178–190 (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 L149–176 (#editModal dialog)
|
||||
- [ ] **8.2** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderMoveModal()` from L178–208 (#moveModal dialog)
|
||||
- [ ] **8.3** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderDeleteModal()` from L210–226 (#deleteModal dialog)
|
||||
- [ ] **8.4** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderCopyAllModal()` from L228–256 (#copyAllModal dialog)
|
||||
- [ ] **8.5** `src/meshcore_hub/web/static/js/spa/pages/admin/node-tags.js` — Extract `renderDeleteAllModal()` from L258–275 (#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 (L141–169) 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 L172–214 (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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user