From 1491c49ef75e68406fa270a4825009be872f0e5a Mon Sep 17 00:00:00 2001 From: Louis King Date: Wed, 20 May 2026 15:14:41 +0100 Subject: [PATCH] Refactor channels page layout and improve nav ordering - Group channel cards by visibility with section headings - Move channels before messages in all nav menus for logical grouping - Add optgroup labels (Standard/Custom) to message channel filter - Capitalize built-in "Test" channel name for consistency - Shorten "Advertisements" to "Adverts" in UI labels - Lay out channel cards with side-by-side QR codes - Shrink homepage nav cards for better fit --- docs/i18n.md | 36 +++++++-- .../collector/letsmesh_decoder.py | 2 +- src/meshcore_hub/web/static/js/spa/app.js | 14 ++-- .../web/static/js/spa/pages/channels.js | 79 ++++++++++++------- .../web/static/js/spa/pages/home.js | 18 ++--- .../web/static/js/spa/pages/messages.js | 16 ++-- src/meshcore_hub/web/static/locales/en.json | 8 +- src/meshcore_hub/web/templates/spa.html | 6 +- tests/test_collector/test_letsmesh_decoder.py | 2 +- tests/test_common/test_i18n.py | 7 +- tests/test_web/test_messages.py | 2 +- 11 files changed, 122 insertions(+), 68 deletions(-) diff --git a/docs/i18n.md b/docs/i18n.md index e13d7c3..1bc4749 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -32,8 +32,8 @@ Core entity names used throughout the application. These are referenced by other | `nodes` | Nodes | Mesh network nodes (plural) | | `node` | Node | Single mesh network node | | `node_detail` | Node Detail | Node details page | -| `advertisements` | Advertisements | Network advertisements (plural) | -| `advertisement` | Advertisement | Single advertisement | +| `advertisements` | Adverts | Network advertisements (plural, used in nav menus and hero cards) | +| `advertisement` | Advert | Single advertisement | | `messages` | Messages | Network messages (plural) | | `message` | Message | Single message | | `map` | Map | Network map page | @@ -251,7 +251,7 @@ Homepage-specific content: | `spreading_factor` | Spreading Factor | LoRa spreading factor label | | `coding_rate` | Coding Rate | LoRa coding rate label | | `tx_power` | TX Power | Transmit power label | -| `advertisements` | Advertisements | Homepage stat label | +| `advertisements` | Adverts | Homepage stat label | | `messages` | Messages | Homepage stat label | **Note:** MeshCore tagline "Off-Grid, Open-Source Encrypted Messaging" is hardcoded in English and should not be translated (trademark). @@ -376,7 +376,27 @@ Members page content: | `empty_state_description` | No members yet. | Empty state heading | | `empty_description` | Members will appear here once users log in and adopt nodes. | Empty state description | -### 14. `not_found` +### 14. `channels` + +Channel management and filter UI: + +| Key | English | Context | +|-----|---------|---------| +| `title` | Channels | Page title | +| `add_channel` | Add Channel | Add button label | +| `edit_channel` | Edit Channel | Edit modal title | +| `delete_channel` | Delete Channel | Delete modal title | +| `delete_confirm` | Are you sure you want to delete channel {{name}}? | Delete confirmation message | +| `name_label` | Channel Name | Form label | +| `key_label` | Channel Key (hex) | Form label | +| `visibility_label` | Visibility | Form label | +| `enabled_label` | Enabled | Form label | +| `channel_hash_label` | Hash | Column header | +| `disabled` | Disabled | Disabled channel badge | +| `optgroup_standard` | Standard | Optgroup label for built-in channels (Public, Test) in channel filter dropdown | +| `optgroup_custom` | Custom | Optgroup label for user-defined channels in channel filter dropdown | + +### 15. `not_found` 404 page content: @@ -384,7 +404,7 @@ Members page content: |-----|---------|---------| | `description` | The page you're looking for doesn't exist or has been moved. | 404 description | -### 15. `custom_page` +### 16. `custom_page` Custom markdown page errors: @@ -392,7 +412,7 @@ Custom markdown page errors: |-----|---------|---------| | `failed_to_load` | Failed to load page | Page load error | -### 16. `auth` +### 17. `auth` Authentication UI: @@ -408,7 +428,7 @@ Authentication UI: | `role_admin` | admin | Admin role badge text | | `role_member` | member | Member role badge text | -### 17. `footer` +### 18. `footer` Footer content: @@ -416,7 +436,7 @@ Footer content: |-----|---------|---------| | `powered_by` | Powered by | "Powered by" attribution | -### 18. `user_profile` +### 19. `user_profile` User profile page (OIDC authenticated users): diff --git a/src/meshcore_hub/collector/letsmesh_decoder.py b/src/meshcore_hub/collector/letsmesh_decoder.py index 51533e3..6de1ecb 100644 --- a/src/meshcore_hub/collector/letsmesh_decoder.py +++ b/src/meshcore_hub/collector/letsmesh_decoder.py @@ -32,7 +32,7 @@ class LetsMeshPacketDecoder: BUILTIN_CHANNEL_KEYS: tuple[tuple[str, str], ...] = ( ("Public", "8B3387E9C5CDEA6AC9E5EDBAA115CD72"), - ("test", "9CD8FCF22A47333B591D96A2B848B73F"), + ("Test", "9CD8FCF22A47333B591D96A2B848B73F"), ) TEST_CHANNEL_HASH: str = "D9" diff --git a/src/meshcore_hub/web/static/js/spa/app.js b/src/meshcore_hub/web/static/js/spa/app.js index 9b9855d..e1fef91 100644 --- a/src/meshcore_hub/web/static/js/spa/app.js +++ b/src/meshcore_hub/web/static/js/spa/app.js @@ -71,12 +71,12 @@ if (features.nodes !== false) { router.navigate(`/nodes/${params.prefix}`, true); }); } -if (features.messages !== false) { - router.addRoute('/messages', pageHandler(pages.messages)); -} if (features.channels !== false) { router.addRoute('/channels', pageHandler(pages.channels)); } +if (features.messages !== false) { + router.addRoute('/messages', pageHandler(pages.messages)); +} if (features.advertisements !== false) { router.addRoute('/advertisements', pageHandler(pages.advertisements)); } @@ -153,8 +153,8 @@ function updatePageTitle(pathname) { // Add feature-dependent titles if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard'); if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes'); - if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages'); if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels'); + if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages'); if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements'); if (features.map !== false) titles['/map'] = composePageTitle('entities.map'); if (features.members !== false) titles['/members'] = composePageTitle('entities.members'); @@ -203,12 +203,12 @@ function renderMobileNav(config) { if (features.advertisements !== false) { items.push(html`
  • ${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')}
  • `); } - if (features.messages !== false) { - items.push(html`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); - } if (features.channels !== false) { items.push(html`
  • ${iconChannel('h-5 w-5')} ${t('entities.channels')}
  • `); } + if (features.messages !== false) { + items.push(html`
  • ${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}
  • `); + } if (features.map !== false) { items.push(html`
  • ${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}
  • `); } diff --git a/src/meshcore_hub/web/static/js/spa/pages/channels.js b/src/meshcore_hub/web/static/js/spa/pages/channels.js index 3e95523..22bd1c6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/channels.js +++ b/src/meshcore_hub/web/static/js/spa/pages/channels.js @@ -2,17 +2,11 @@ import { apiGet, apiPost, apiPut, apiDelete } from '../api.js'; import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js'; import { iconChannel, iconPlus, iconEdit, iconTrash, iconLock } from '../icons.js'; -const VISIBILITY_COLORS = { - public: 'badge-success', - member: 'badge-warning', - operator: 'badge-orange', - admin: 'badge-error', -}; +const VISIBILITY_ORDER = ['public', 'member', 'operator', 'admin']; function renderVisibilityBadge(visibility, oidcEnabled) { if (!oidcEnabled) return nothing; - const colorClass = VISIBILITY_COLORS[visibility] || 'badge-ghost'; - return html`${visibility}`; + return html`${visibility}`; } function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) { @@ -21,8 +15,8 @@ function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, on ? html`${t('channels.disabled')}` : nothing; - const qrId = `qr-${channel.id}`; const channelIdx = parseInt(channel.channel_hash, 16); + const qrId = `qr-${channel.id}`; const adminButtons = isAdmin ? html`
    @@ -39,17 +33,24 @@ function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, on ? html`
    ${channel.key_hex.toLowerCase()}
    ` : nothing; + const qrPlaceholder = channel.key_hex + ? html`
    ` + : nothing; + return html`
    onNavigate(channelIdx)}> -
    -

    - ${iconChannel('h-5 w-5')} - ${channel.name} - ${visibilityBadge} - ${enabledBadge} -

    - ${keyDisplay} -
    - ${adminButtons} +
    +
    +

    + ${channel.name} + ${visibilityBadge} + ${enabledBadge} +

    + ${keyDisplay} + ${adminButtons} +
    +
    + ${qrPlaceholder} +
    `; } @@ -145,6 +146,36 @@ export async function render(container, params, router) {
    ` : nothing; + const groups = new Map(); + for (const vis of VISIBILITY_ORDER) { + groups.set(vis, []); + } + for (const ch of channelsList) { + const vis = ch.visibility || 'public'; + if (!groups.has(vis)) groups.set(vis, []); + groups.get(vis).push(ch); + } + + const cardOpts = { + oidcEnabled, + isAdmin, + onDelete: handleDeleteClick, + onEdit: handleEditClick, + onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`), + }; + + const groupedSections = []; + for (const vis of VISIBILITY_ORDER) { + const group = groups.get(vis); + if (!group || group.length === 0) continue; + groupedSections.push(html` +

    ${vis.charAt(0).toUpperCase() + vis.slice(1)}

    +
    + ${group.map(ch => renderChannelCard(ch, cardOpts))} +
    + `); + } + let modalHtml = nothing; if (modalState?.type === 'add' || modalState?.type === 'edit') { modalHtml = renderChannelModal({ @@ -170,15 +201,7 @@ export async function render(container, params, router) {
    ${adminHeader} ${emptyMessage} -
    - ${channelsList.map(ch => renderChannelCard(ch, { - oidcEnabled, - isAdmin, - onDelete: handleDeleteClick, - onEdit: handleEditClick, - onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`), - }))} -
    + ${groupedSections} ${modalHtml} `, container); 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 7d22b7e..e607aff 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/home.js +++ b/src/meshcore_hub/web/static/js/spa/pages/home.js @@ -35,14 +35,14 @@ function renderRadioTiles(rc) { function renderNavCard({ href, icon, label, colorVar }) { return html` - - ${icon} @@ -74,7 +74,7 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, ${welcomeText}
    -
    +
    ${features.dashboard !== false ? renderNavCard({ href: '/dashboard', icon: iconDashboard('w-full h-full'), @@ -93,18 +93,18 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity, label: t('entities.advertisements'), colorVar: '--color-adverts', }) : nothing} - ${features.messages !== false ? renderNavCard({ - href: '/messages', - icon: iconMessages('w-full h-full'), - label: t('entities.messages'), - colorVar: '--color-messages', - }) : nothing} ${features.channels !== false ? renderNavCard({ href: '/channels', icon: iconChannel('w-full h-full'), label: t('entities.channels'), colorVar: '--color-channels', }) : nothing} + ${features.messages !== false ? renderNavCard({ + href: '/messages', + icon: iconMessages('w-full h-full'), + label: t('entities.messages'), + colorVar: '--color-messages', + }) : nothing} ${features.members !== false ? renderNavCard({ href: '/members', icon: iconMembers('w-full h-full'), 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 c710ec8..a78fd99 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -211,12 +211,13 @@ ${displayContent}`, container); apiGet('/api/v1/nodes', { limit: 500, observer: true }), apiGet('/api/v1/channels'), ]); - channelLabels = new Map([ - ...getChannelLabelsMap(config), - ...(channelsData.items || []) + const builtinLabels = getChannelLabelsMap(config); + const customLabels = new Map( + (channelsData.items || []) .map(ch => [parseInt(ch.channel_hash, 16), ch.name]) .filter(([idx]) => Number.isInteger(idx)), - ]); + ); + channelLabels = new Map([...builtinLabels, ...customLabels]); const messages = dedupeBySignature(data.items || []); const allNodes = nodesData.items || []; @@ -363,9 +364,12 @@ ${displayContent}`, container);
    `, ]; diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 749eda2..7d67649 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -5,8 +5,8 @@ "nodes": "Nodes", "node": "Node", "node_detail": "Node Detail", - "advertisements": "Advertisements", - "advertisement": "Advertisement", + "advertisements": "Adverts", + "advertisement": "Advert", "messages": "Messages", "message": "Message", "map": "Map", @@ -235,7 +235,9 @@ "visibility_label": "Visibility", "enabled_label": "Enabled", "channel_hash_label": "Hash", - "disabled": "Disabled" + "disabled": "Disabled", + "optgroup_standard": "Standard", + "optgroup_custom": "Custom" }, "not_found": { "description": "The page you're looking for doesn't exist or has been moved." diff --git a/src/meshcore_hub/web/templates/spa.html b/src/meshcore_hub/web/templates/spa.html index 31af969..f65b41d 100644 --- a/src/meshcore_hub/web/templates/spa.html +++ b/src/meshcore_hub/web/templates/spa.html @@ -67,12 +67,12 @@ {% if features.advertisements %}
  • {{ t('entities.advertisements') }}
  • {% endif %} - {% if features.messages %} -
  • {{ t('entities.messages') }}
  • - {% endif %} {% if features.channels %}
  • {{ t('entities.channels') }}
  • {% endif %} + {% if features.messages %} +
  • {{ t('entities.messages') }}
  • + {% endif %} {% if features.map %}
  • {{ t('entities.map') }}
  • {% endif %} diff --git a/tests/test_collector/test_letsmesh_decoder.py b/tests/test_collector/test_letsmesh_decoder.py index 7c6d270..1e364ec 100644 --- a/tests/test_collector/test_letsmesh_decoder.py +++ b/tests/test_collector/test_letsmesh_decoder.py @@ -118,7 +118,7 @@ def test_channel_labels_by_index_includes_labeled_entries() -> None: labels = decoder.channel_labels_by_index() assert labels[17] == "Public" - assert labels[217] == "test" + assert labels[217] == "Test" assert labels[202] == "bot" assert labels[184] == "chat" diff --git a/tests/test_common/test_i18n.py b/tests/test_common/test_i18n.py index 863fac9..6ce205e 100644 --- a/tests/test_common/test_i18n.py +++ b/tests/test_common/test_i18n.py @@ -48,7 +48,7 @@ class TestTranslation: def test_nested_key(self): """Deeply nested keys resolve correctly.""" - assert t("entities.advertisements") == "Advertisements" + assert t("entities.advertisements") == "Adverts" def test_missing_key_returns_key(self): """Missing key returns the key itself as fallback.""" @@ -152,3 +152,8 @@ class TestEnJsonCompleteness: != "advertisements.route_type_unknown" ) assert t("advertisements.col_route_type") != "advertisements.col_route_type" + + def test_channels_optgroup_keys(self): + """Channel optgroup labels exist and resolve correctly.""" + assert t("channels.optgroup_standard") == "Standard" + assert t("channels.optgroup_custom") == "Custom" diff --git a/tests/test_web/test_messages.py b/tests/test_web/test_messages.py index b6b7091..008f7f3 100644 --- a/tests/test_web/test_messages.py +++ b/tests/test_web/test_messages.py @@ -103,4 +103,4 @@ class TestMessagesConfig: config = json.loads(text[config_start:config_end]) assert config["channel_labels"]["17"] == "Public" - assert config["channel_labels"]["217"] == "test" + assert config["channel_labels"]["217"] == "Test"