diff --git a/docs/plans/20260502-1520-ui-refactor/plan.md b/docs/plans/20260502-1520-ui-refactor/plan.md new file mode 100644 index 0000000..be9be3c --- /dev/null +++ b/docs/plans/20260502-1520-ui-refactor/plan.md @@ -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 `` 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 `` 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., `'...'`) rather than via a lit-html `html\`...\`` template or `icons.js` function | +| Inline SVG | An `` element directly inside a lit-html `html\`...\`` template literal, not using an `icons.js` function | +| Filter card pattern | A `
` 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 `` 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``; +} + +export function iconLogout(cls = 'h-5 w-5') { + return html``; +} + +export function iconPause(cls = 'w-4 h-4') { + return html``; +} + +export function iconPlay(cls = 'w-4 h-4') { + return html``; +} +``` + +### 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): + +// After: +${iconError('stroke-current shrink-0 h-6 w-6')} +``` + +**`infoAlert()` (L381–386)** — replace inline SVG on L383: +```javascript +// Before (L383): + +// After: +${iconInfo('stroke-current shrink-0 h-6 w-6')} +``` + +**`successAlert()` (L393–398)** — replace inline SVG on L395: +```javascript +// Before (L395): + +// 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 `` 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` + ${t('auth.login')} + `, 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`${displayName}` + : html`${initials}`; + + const roleBadges = (config.roles || []).map(r => { + const key = `auth.role_${r}`; + const label = t(key); + const name = label !== key ? label : r; + return html`${name}`; + }); + + const adminItem = hasRole('admin') + ? html`
  • ${iconSettings('h-4 w-4')} ${t('entities.admin')}
  • ` + : nothing; + + const profileItem = html`
  • ${iconUser('h-4 w-4')} ${t('links.profile')}
  • `; + + const debugId = config.debug && user.sub + ? html`${user.sub}` + : nothing; + + litRender(html` + + `, 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``; +const playIcon = html``; + +// 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 `` 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} 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` +
    +
    +
    + ${fields.map(f => f())} +
    + + ${clearLabel || t('common.clear')} +
    +
    +
    +
    + `; +} +``` + +**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` +
    +
    ${icon}
    +
    ${title}
    +
    ${value}
    + ${description ? html`
    ${description}
    ` : nothing} +
    `; +} +``` + +### 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` +
    + + +
    `, + () => html` +
    + + +
    `, + ], + 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` +
    + ${config.network_name +

    ${config.network_name || 'MeshCore Hub'}

    +
    `; +} +``` + +#### 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` +
    + ${renderHeroSection(config)} +
    + ${renderStatsPanel(statsData)} +
    + + ${renderActivityChart()} +
    + `, 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 `` 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 `` 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` + + + `; +} +``` + +**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. | diff --git a/docs/plans/20260502-1520-ui-refactor/tasks.md b/docs/plans/20260502-1520-ui-refactor/tasks.md new file mode 100644 index 0000000..5b12102 --- /dev/null +++ b/docs/plans/20260502-1520-ui-refactor/tasks.md @@ -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 | diff --git a/src/meshcore_hub/web/static/js/spa/auto-refresh.js b/src/meshcore_hub/web/static/js/spa/auto-refresh.js index 5ef6ba7..b247944 100644 --- a/src/meshcore_hub/web/static/js/spa/auto-refresh.js +++ b/src/meshcore_hub/web/static/js/spa/auto-refresh.js @@ -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``; - const playIcon = html``; + 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; diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 2309bf8..0023718 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -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``; } @@ -380,7 +380,7 @@ export function errorAlert(message) { */ export function infoAlert(message) { return html``; } @@ -392,7 +392,7 @@ export function infoAlert(message) { */ export function successAlert(message) { return html``; } @@ -592,59 +592,45 @@ export function submitOnEnter(e) { * @param {HTMLElement} container - The #auth-section element * @param {Object} config - App configuration object */ -function _svgUser() { - return ''; -} - -function _svgSettings() { - return ''; -} - -function _svgLogout() { - return ''; -} - 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` ${t('auth.login')} - `; + `, 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 - ? `${displayName}` - : `${initials}`; + ? html`${displayName}` + : html`${initials}`; - const roleBadges = (config.roles || []) - .map(r => { - const key = `auth.role_${r}`; - const label = t(key); - const name = label !== key ? label : r; - return `${name}`; - }) - .join(''); + const roleBadges = (config.roles || []).map(r => { + const key = `auth.role_${r}`; + const label = t(key); + const name = label !== key ? label : r; + return html`${name}`; + }); const adminItem = hasRole('admin') - ? `
  • ${_svgSettings()} ${t('entities.admin')}
  • ` - : ''; + ? html`
  • ${iconSettings('h-4 w-4')} ${t('entities.admin')}
  • ` + : nothing; - const profileItem = `
  • ${_svgUser()} ${t('links.profile')}
  • `; + const profileItem = html`
  • ${iconUser('h-4 w-4')} ${t('links.profile')}
  • `; const debugId = config.debug && user.sub - ? `${user.sub}` - : ''; + ? html`${user.sub}` + : nothing; - container.innerHTML = ` + render(html`
    - - - - - - - - - - - - - - - - - - - - - - - -`; +${renderEditModal()} +${renderMoveModal(otherNodes)} +${renderDeleteModal()} +${renderCopyAllModal(otherNodes, tags, nodeName)} +${renderDeleteAllModal(tags, nodeName)}`; } else if (selectedPublicKey && !selectedNode) { contentHtml = html`
    - + ${iconAlert('stroke-current shrink-0 h-6 w-6')} Node not found: ${selectedPublicKey}
    `; } else { diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index b3fdd3c..74634dc 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -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` -
    -
    -
    + const filterFields = [ + () => html`
    -
    - ${nodesFilter} - ${config.oidc_enabled && profiles.length > 0 ? html` +
    `, + ]; + if (sortedNodes.length > 0) { + filterFields.push(() => nodesFilter); + } + if (config.oidc_enabled && profiles.length > 0) { + filterFields.push(() => html`
    - ` : nothing} -
    - - ${t('common.clear')} -
    - -
    - + `); + } + + const filterCard = renderFilterCard({ + fields: filterFields, + basePath: '/advertisements', + navigate, + }); + + renderPage(html`${filterCard}
    ${mobileCards} diff --git a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js index 0ed6a9b..a9f3e8a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/dashboard.js +++ b/src/meshcore_hub/web/static/js/spa/pages/dashboard.js @@ -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` -
    -

    ${t('entities.dashboard')}

    -
    - -${topCount > 0 ? html` -
    - ${showNodes ? html` -
    -
    - ${iconNodes('h-8 w-8')} -
    -
    ${t('common.total_entity', { entity: t('entities.nodes') })}
    -
    ${stats.total_nodes}
    -
    ${t('dashboard.all_discovered_nodes')}
    -
    ` : nothing} - - ${showAdverts ? html` -
    -
    - ${iconAdvertisements('h-8 w-8')} -
    -
    ${t('entities.advertisements')}
    -
    ${stats.advertisements_7d}
    -
    ${t('time.last_7_days')}
    -
    ` : nothing} - - ${showMessages ? html` -
    -
    - ${iconMessages('h-8 w-8')} -
    -
    ${t('entities.messages')}
    -
    ${stats.messages_7d}
    -
    ${t('time.last_7_days')}
    -
    ` : nothing} -
    - -
    +function renderChartCards({ showNodes, showAdverts, showMessages }) { + const visibleCount = (showNodes ? 1 : 0) + (showAdverts ? 1 : 0) + (showMessages ? 1 : 0); + if (visibleCount === 0) return nothing; + return html` +
    ${showNodes ? html`
    @@ -211,7 +153,66 @@ ${topCount > 0 ? html`
    ` : nothing} -
    ` : nothing} +
    `; +} + +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` +
    +

    ${t('entities.dashboard')}

    +
    + +${topCount > 0 ? html` +
    + ${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} +
    + +${renderChartCards({ showNodes, showAdverts, showMessages })}` : nothing} ${bottomCount > 0 ? html`
    diff --git a/src/meshcore_hub/web/static/js/spa/pages/home.js b/src/meshcore_hub/web/static/js/spa/pages/home.js index abdc98b..99c0b09 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -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) {
    `); } +function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, networkCountry, networkWelcomeText, features, customPages }) { + const cityCountry = (networkCity && networkCountry) + ? html`

    ${networkCity}, ${networkCountry}

    ` + : nothing; + + const welcomeText = networkWelcomeText + ? html`

    ${networkWelcomeText}

    ` + : html`

    + ${t('home.welcome_default', { network_name: networkName })} +

    `; + + const customPageButtons = features.pages !== false + ? customPages.slice(0, 3).map(page => html` + + ${iconPage('h-5 w-5 mr-2')} + ${page.title} + `) + : []; + + return html` +
    +
    + +
    +

    ${networkName}

    + ${cityCountry} +
    +
    + ${welcomeText} +
    +
    + ${features.dashboard !== false ? html` + + ${iconDashboard('h-5 w-5 mr-2')} + ${t('entities.dashboard')} + ` : nothing} + ${features.nodes !== false ? html` + + ${iconNodes('h-5 w-5 mr-2')} + ${t('entities.nodes')} + ` : nothing} + ${features.advertisements !== false ? html` + + ${iconAdvertisements('h-5 w-5 mr-2')} + ${t('entities.advertisements')} + ` : nothing} + ${features.messages !== false ? html` + + ${iconMessages('h-5 w-5 mr-2')} + ${t('entities.messages')} + ` : nothing} + ${features.map !== false ? html` + + ${iconMap('h-5 w-5 mr-2')} + ${t('entities.map')} + ` : nothing} + ${customPageButtons} +
    +
    `; +} + +function renderStatsPanel({ features, stats }) { + return html` +
    + ${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} +
    `; +} + +function renderActivityChartCard({ showAdvertSeries, showMessageSeries }) { + return html` +
    +
    +

    + ${iconChart('h-6 w-6')} + ${t('home.network_activity')} +

    +

    ${t('time.activity_per_day_last_7_days')}

    +
    + +
    +
    +
    `; +} + 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`

    ${config.network_city}, ${config.network_country}

    ` - : nothing; - - const welcomeText = config.network_welcome_text - ? html`

    ${config.network_welcome_text}

    ` - : html`

    - ${t('home.welcome_default', { network_name: networkName })} -

    `; - - const customPageButtons = features.pages !== false - ? customPages.slice(0, 3).map(page => html` - - ${iconPage('h-5 w-5 mr-2')} - ${page.title} - `) - : []; - 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`
    -
    -
    - -
    -

    ${networkName}

    - ${cityCountry} -
    -
    - ${welcomeText} -
    -
    - ${features.dashboard !== false ? html` - - ${iconDashboard('h-5 w-5 mr-2')} - ${t('entities.dashboard')} - ` : nothing} - ${features.nodes !== false ? html` - - ${iconNodes('h-5 w-5 mr-2')} - ${t('entities.nodes')} - ` : nothing} - ${features.advertisements !== false ? html` - - ${iconAdvertisements('h-5 w-5 mr-2')} - ${t('entities.advertisements')} - ` : nothing} - ${features.messages !== false ? html` - - ${iconMessages('h-5 w-5 mr-2')} - ${t('entities.messages')} - ` : nothing} - ${features.map !== false ? html` - - ${iconMap('h-5 w-5 mr-2')} - ${t('entities.map')} - ` : nothing} - ${customPageButtons} -
    +
    + ${heroSection}
    - - ${showStats ? html` -
    - ${features.nodes !== false ? html` -
    -
    - ${iconNodes('h-8 w-8')} -
    -
    ${t('common.total_entity', { entity: t('entities.nodes') })}
    -
    ${stats.total_nodes}
    -
    ${t('home.all_discovered_nodes')}
    -
    ` : nothing} - - ${features.advertisements !== false ? html` -
    -
    - ${iconAdvertisements('h-8 w-8')} -
    -
    ${t('entities.advertisements')}
    -
    ${stats.advertisements_7d}
    -
    ${t('time.last_7_days')}
    -
    ` : nothing} - - ${features.messages !== false ? html` -
    -
    - ${iconMessages('h-8 w-8')} -
    -
    ${t('entities.messages')}
    -
    ${stats.messages_7d}
    -
    ${t('time.last_7_days')}
    -
    ` : nothing} -
    ` : nothing} + ${showStats ? statsPanel : nothing}
    @@ -175,19 +205,7 @@ export async function render(container, params, router) {
    - ${showActivityChart ? html` -
    -
    -

    - ${iconChart('h-6 w-6')} - ${t('home.network_activity')} -

    -

    ${t('time.activity_per_day_last_7_days')}

    -
    - -
    -
    -
    ` : nothing} + ${showActivityChart ? activityChartCard : nothing} `, container); let chart = null; diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index ad4bc2b..3a00c45 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -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` -
    -
    -
    + const filterCard = renderFilterCard({ + fields: [ + () => html`
    +
    `, + () => html`
    -
    - - ${t('common.clear')} -
    - -
    - + `, + ], + basePath: '/messages', + navigate, + }); + + renderPage(html`${filterCard}
    ${mobileCards} diff --git a/src/meshcore_hub/web/static/js/spa/pages/nodes.js b/src/meshcore_hub/web/static/js/spa/pages/nodes.js index 8276415..2750f3d 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/nodes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/nodes.js @@ -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` -
    -
    -
    + const filterFields = [ + () => html`
    -
    +
    `, + () => html`
    - ${config.oidc_enabled && profiles.length > 0 ? html` +
    `, + ]; + if (config.oidc_enabled && profiles.length > 0) { + filterFields.push(() => html`
    - ` : nothing} -
    - - ${t('common.clear')} -
    - -
    - + `); + } + + const filterCard = renderFilterCard({ + fields: filterFields, + basePath: '/nodes', + navigate, + }); + + renderPage(html`${filterCard}
    ${mobileCards}