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
This commit is contained in:
Louis King
2026-05-20 15:14:41 +01:00
parent 3ad288433d
commit 1491c49ef7
11 changed files with 122 additions and 68 deletions
+28 -8
View File
@@ -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):
@@ -32,7 +32,7 @@ class LetsMeshPacketDecoder:
BUILTIN_CHANNEL_KEYS: tuple[tuple[str, str], ...] = (
("Public", "8B3387E9C5CDEA6AC9E5EDBAA115CD72"),
("test", "9CD8FCF22A47333B591D96A2B848B73F"),
("Test", "9CD8FCF22A47333B591D96A2B848B73F"),
)
TEST_CHANNEL_HASH: str = "D9"
+7 -7
View File
@@ -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`<li><a href="/advertisements" data-nav-link>${iconAdvertisements('h-5 w-5 nav-icon-adverts')} ${t('entities.advertisements')}</a></li>`);
}
if (features.messages !== false) {
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
}
if (features.channels !== false) {
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5')} ${t('entities.channels')}</a></li>`);
}
if (features.messages !== false) {
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
}
if (features.map !== false) {
items.push(html`<li><a href="/map" data-nav-link>${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}</a></li>`);
}
@@ -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`<span class="badge ${colorClass} badge-sm">${visibility}</span>`;
return html`<span class="badge badge-primary badge-sm">${visibility}</span>`;
}
function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) {
@@ -21,8 +15,8 @@ function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, on
? html`<span class="badge badge-ghost badge-sm">${t('channels.disabled')}</span>`
: nothing;
const qrId = `qr-${channel.id}`;
const channelIdx = parseInt(channel.channel_hash, 16);
const qrId = `qr-${channel.id}`;
const adminButtons = isAdmin
? html`<div class="flex gap-2 mt-2">
@@ -39,17 +33,24 @@ function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, on
? html`<div class="font-mono text-xs opacity-70 mt-1 break-all select-all">${channel.key_hex.toLowerCase()}</div>`
: nothing;
const qrPlaceholder = channel.key_hex
? html`<div id="${qrId}" class="qr-container"></div>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl cursor-pointer" @click=${() => onNavigate(channelIdx)}>
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
${iconChannel('h-5 w-5')}
${channel.name}
${visibilityBadge}
${enabledBadge}
</h2>
${keyDisplay}
<div id="${qrId}" class="qr-container mt-2"></div>
${adminButtons}
<div class="card-body flex-row gap-4">
<div class="flex-1 min-w-0">
<h2 class="card-title flex items-center gap-2">
${channel.name}
${visibilityBadge}
${enabledBadge}
</h2>
${keyDisplay}
${adminButtons}
</div>
<div class="flex-shrink-0 self-center">
${qrPlaceholder}
</div>
</div>
</div>`;
}
@@ -145,6 +146,36 @@ export async function render(container, params, router) {
</div>`
: 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`
<h2 class="text-lg font-semibold mt-6 mb-3 opacity-70">${vis.charAt(0).toUpperCase() + vis.slice(1)}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${group.map(ch => renderChannelCard(ch, cardOpts))}
</div>
`);
}
let modalHtml = nothing;
if (modalState?.type === 'add' || modalState?.type === 'edit') {
modalHtml = renderChannelModal({
@@ -170,15 +201,7 @@ export async function render(container, params, router) {
</div>
${adminHeader}
${emptyMessage}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${channelsList.map(ch => renderChannelCard(ch, {
oidcEnabled,
isAdmin,
onDelete: handleDeleteClick,
onEdit: handleEditClick,
onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`),
}))}
</div>
${groupedSections}
${modalHtml}
`, container);
@@ -35,14 +35,14 @@ function renderRadioTiles(rc) {
function renderNavCard({ href, icon, label, colorVar }) {
return html`
<a href="${href}" class="w-28 h-28 sm:w-32 sm:h-32
<a href="${href}" class="w-24 h-24 sm:w-28 sm:h-28
border border-base-content/20 rounded-box
hover:scale-105 hover:border-base-content/40
transition-all duration-200 ease-out
flex flex-col items-center justify-center gap-2
bg-base-200/50 hover:bg-base-200
group">
<span class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center"
<span class="w-7 h-7 sm:w-9 sm:h-9 flex items-center justify-center"
style="${colorVar ? `color: var(${colorVar})` : ''}">
${icon}
</span>
@@ -74,7 +74,7 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity,
</div>
${welcomeText}
<div class="flex-1"></div>
<div class="flex flex-wrap justify-center gap-3 sm:gap-4 mt-auto">
<div class="flex flex-wrap justify-center gap-2 sm:gap-3 mt-auto">
${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'),
@@ -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);
</label>
<select name="channel_idx" class="select select-bordered select-sm" @change=${autoSubmit}>
<option value="">${t('common.all_channels')}</option>
${[...channelLabels.entries()].map(([idx, label]) =>
${builtinLabels.size > 0 ? html`<optgroup label=${t('channels.optgroup_standard')}>${[...builtinLabels.entries()].map(([idx, label]) =>
html`<option value=${idx} ?selected=${channel_idx === String(idx)}>${label}</option>`
)}
)}</optgroup>` : nothing}
${customLabels.size > 0 ? html`<optgroup label=${t('channels.optgroup_custom')}>${[...customLabels.entries()].map(([idx, label]) =>
html`<option value=${idx} ?selected=${channel_idx === String(idx)}>${label}</option>`
)}</optgroup>` : nothing}
</select>
</div>`,
];
+5 -3
View File
@@ -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."
+3 -3
View File
@@ -67,12 +67,12 @@
{% if features.advertisements %}
<li><a href="/advertisements" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-adverts" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg> {{ t('entities.advertisements') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.channels %}
<li><a href="/channels" data-nav-link><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="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
{% endif %}
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
{% endif %}
@@ -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"
+6 -1
View File
@@ -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"
+1 -1
View File
@@ -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"