feat(web): observer filter as toggle badges on adverts/messages

Replace the multi-select Observer dropdown buried in the filter panel with
a row of clickable observer badges rendered between the filter panel and the
data list (and below the Sorting dropdown on mobile).

- Selection is stored in localStorage (shared across Adverts and Messages)
  as the disabled set, so new observers default to enabled.
- Badge style reflects enabled (filled) vs disabled (muted) state; the last
  enabled observer cannot be toggled off.
- Observer filter is sourced from localStorage instead of the URL query;
  a two-phase fetch resolves the enabled include-list (the API filters by
  inclusion only) before fetching data, with no flash of unfiltered results.
- Toggling re-scopes data and resets to page 1.
- Add enable/disable tooltip strings (en/nl) and the previously-missing
  Dutch observer label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-06-14 12:47:57 +01:00
parent 6417ed2ae2
commit 56696bdcd6
6 changed files with 334 additions and 79 deletions
@@ -0,0 +1,146 @@
# Plan: Observer filter as toggle badges (Adverts & Messages)
## Goal
Replace the multi-select Observer dropdown (currently buried in the Filter panel) with a
row of clickable observer **badges** rendered between the filter panel and the data list.
Selection persists in `localStorage` (shared across both pages), defaults to all-enabled,
and is applied to the first API call on load.
## Motivation
The Observer filter on the Advert and Message pages is hard to reach (inside the collapsed
filter panel, as a multi-select `<select>`). Many users only care about a single observer or
a specific set, and re-opening the filter panel each visit is a chore. Badges give one-click
toggling, visible state, and remembered preferences across sessions.
## Confirmed decisions
- **Shared selection** across Adverts + Messages (single localStorage key).
- **Block the last toggle-off** — always keep >= 1 observer enabled.
- **Reset to page 1 on toggle** — toggling re-scopes the data, so navigate to the base path
(dropping `page`) and re-fetch.
## Core model
Persist the **disabled** set, not the enabled set. Storing deselected pubkeys means any
newly-discovered observer node defaults to enabled automatically (matches "by default all
observers enabled").
- localStorage key: `meshcore-observers-disabled` -> JSON array of pubkeys.
- Effective filter = all observer nodes minus the disabled set.
- If disabled set is empty -> send **no** `observed_by` param (show all).
- If some disabled -> send `observed_by` = enabled pubkeys.
- **Implementation note (deviation):** the adverts/messages API filters observers by
*inclusion only* (`observed_by` is an include-list; there is no exclude param). So the data
fetch genuinely depends on the full observer node list to translate the stored disabled set
into an include-list. Implemented as a **two-phase fetch**: phase 1 fetches the observer
nodes (plus channels/profiles), phase 2 fetches the data with the resolved `observed_by`.
This is fully correct (always uses the fresh node list) and produces no flash of unfiltered
data, at the cost of the main data call waiting on the (small) nodes call. The original
"derive synchronously from localStorage, fetch in parallel" idea is not achievable without a
client-side cache of observer pubkeys; two-phase was chosen as the simpler, always-correct
option.
- `observerFilterActive` is gated on `enabledKeys.length < sortedNodes.length`, so a stale
disabled key that no longer matches any current node does not accidentally filter everything.
## Source-of-truth change
Today `observed_by` lives in the **URL query string** and is threaded through pagination/sort
links and the filter form. Moving to localStorage means:
- Remove `observed_by` from the filter panel, from `headerParams`, and from `pagination(...)`
params on both pages.
- Toggling a badge updates localStorage and re-scopes the data (reset to page 1). Page/sort/
search stay in the URL; observer selection does not.
### Why pagination is not broken
- Pagination is driven by the `page` param, which stays in the URL. `observed_by` was only
carried along so the filter survived a page click.
- Every navigation re-invokes the page's `render()`, which re-reads `getDisabledObservers()`
at the top, so the same filter is applied on every page. localStorage is stable across
navigations, so total count / page count stay consistent while paging.
- Toggling a badge can make the current page number out of range, so the toggle handler resets
to page 1 (navigates to the base path without `page`, then re-fetches).
## Files to change
### 1. `src/meshcore_hub/web/static/js/spa/components.js` — add helpers + component
- localStorage helpers (mirroring the theme pattern in `spa.html`):
- `getDisabledObservers()` -> `Set<string>` (safe JSON parse, returns empty set on error).
- `setDisabledObservers(set)` -> persists JSON array.
- `toggleObserver(pubkey, totalObserverCount)` -> updates the set, enforcing the
"keep >= 1 enabled" guard (refuse to disable the last enabled observer); returns the new set.
- `observerFilterBadges({ nodes, disabled, onToggle, extraClass })` component:
- Returns `nothing` if `nodes.length === 0`.
- Small label (`common.filter_observer_label`) + one badge per observer (using `n._displayName`).
- **Enabled** badge: `badge badge-primary` (filled). **Disabled** badge: `badge badge-ghost`
+ `opacity-50` (muted/outlined). Each `cursor-pointer`, `@click=${() => onToggle(n.public_key)}`,
with a `title` tooltip (enable/disable).
- Optional "All" / "None" quick-toggle chips at the start of the row (nice-to-have; "None"
still respects the keep->=1 guard).
- `extraClass` lets the caller apply responsive visibility (`hidden lg:flex` vs `lg:hidden`).
### 2. `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
- Remove `observed_by` read from `query`; instead keep a closure variable
`disabledObservers = getDisabledObservers()`.
- In `fetchAndRenderData`: compute `enabled = sortedNodes.filter(n => !disabledObservers.has(n.public_key))`;
set `apiParams.observed_by = enabled.map(n => n.public_key)` only when `disabledObservers.size > 0`.
- Remove the `nodesFilter` `<select>` from `filterFields`; drop `observed_by` from
`headerParams`, `pagination`, and `hasActiveFilters`.
- Add `onToggle(pubkey)` handler:
1. Apply `toggleObserver` guard + persist.
2. Update closure `disabledObservers`.
3. Reset to page 1: `navigate('/advertisements?...')` rebuilt from current search/sort/order/limit
**without** `page` (or navigate to base path when no other params). This re-runs `render()`,
which re-reads localStorage and re-fetches.
- Render two badge blocks:
- **Desktop**: `observerFilterBadges({ ..., extraClass: 'hidden lg:flex mb-4' })` immediately
after `filterCard`.
- **Mobile**: `observerFilterBadges({ ..., extraClass: 'lg:hidden mb-4' })` between
`mobileSortSelect(...)` and the mobile cards `<div>`.
### 3. `src/meshcore_hub/web/static/js/spa/pages/messages.js`
- Identical changes: remove the `observerFilter` `<select>` + URL threading, add closure
`disabledObservers`, add `onToggle` (reset to page 1 via `/messages?...`), add the two badge
blocks (mobile block after `mobileSortSelect`, before mobile cards).
### 4. Locales `locales/en.json` + `locales/nl.json`
- Add under `common`: badge tooltip key (e.g. `filter_observer_toggle`) and, if quick-toggles
are added, `filter_observer_all` / `filter_observer_none`. Reuse existing
`filter_observer_label` for the row label.
### 5. Build
- Run `npm run build` (esbuild bundles `dist/`; `spa.html` loads the hashed bundle). Required
for the change to appear.
## Cross-link audit (confirmed safe to remove from URL)
- `?observer_id` is **not** a frontend navigation param at all — it only exists in the Python
API as a SQL column label / internal variable (`raw_packets.py`, `messages.py`,
`advertisements.py`, `packet_groups.py`).
- `observed_by` in the frontend is only ever: (a) a **data field** on records used to build
`/nodes/<pubkey>` links (`packet-detail.js`, `packet-group-detail.js`, `node-detail.js`) —
not a query string; or (b) **internal to the Adverts/Messages pages** (the filter `<select>`
+ their own pagination/sort link threading).
- **No other page** links to `/advertisements?observed_by=` or `/messages?observed_by=`. The
only cross-link into these routes carrying a query is `channels.js -> /messages?channel_idx=`,
which uses `channel_idx` (untouched; the messages page keeps reading it from the URL).
- Therefore removing `observed_by` from URL threading breaks nothing in site navigation. Only a
hand-crafted/bookmarked external link would be affected -> covered by optional add-on (b).
## Notes / trade-offs
- **No URL backward-compat**: existing `?observed_by=` links stop filtering. Acceptable given
the redesign and the cross-link audit above. Optional add-on: a one-time URL->localStorage
migration on load so old links keep working.
- **Empty-selection guard**: keep-at-least-one-enabled avoids a confusing empty list and an
ambiguous "all disabled == all enabled" API call.
- Styling uses existing DaisyUI badge classes — no `app.css` changes expected.
- Auto-refresh keeps working unchanged (it calls `fetchAndRenderData`, which reads current
localStorage state).
## Optional add-ons (opt-in)
- (a) "All" / "None" quick-toggle chips on the badge row.
- (b) URL->localStorage migration for old `?observed_by=` links.
## Verification
- Toggle an observer off on Adverts -> list re-scopes, page resets to 1, badge greys out.
- Reload page -> selection restored from localStorage before first API call (filtered results
appear immediately, no flash of unfiltered data).
- Switch to Messages -> same selection applies (shared key).
- Page through results -> filter persists, total/page count consistent.
- Disable all but one, attempt to disable the last -> blocked, stays enabled.
- Mobile viewport -> badges appear below the Sorting dropdown, above the cards.
@@ -545,6 +545,88 @@ export function observerIcons(observers) {
return html`<span class="badge badge-sm badge-primary cursor-help observer-badge" title=${tooltip}>${observers.length}</span>`;
}
// --- Observer filter (localStorage-backed toggle badges) ---
// Shared across the Adverts and Messages pages. We persist the *disabled* set
// so any newly-discovered observer node defaults to enabled automatically.
const OBSERVER_FILTER_KEY = 'meshcore-observers-disabled';
/**
* Read the set of disabled (deselected) observer public keys from localStorage.
* @returns {Set<string>}
*/
export function getDisabledObservers() {
try {
const raw = localStorage.getItem(OBSERVER_FILTER_KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return Array.isArray(arr) ? new Set(arr) : new Set();
} catch {
return new Set();
}
}
/**
* Persist the set of disabled observer public keys to localStorage.
* @param {Set<string>} disabled
*/
export function setDisabledObservers(disabled) {
try {
localStorage.setItem(OBSERVER_FILTER_KEY, JSON.stringify([...disabled]));
} catch {
// Ignore quota/availability errors — filtering still works in-memory.
}
}
/**
* Toggle an observer's enabled state, enforcing that at least one observer
* stays enabled. Returns the updated disabled set (persisted).
* @param {string} pubkey - Observer public key to toggle
* @param {number} totalObserverCount - Total number of observer nodes
* @returns {Set<string>}
*/
export function toggleObserver(pubkey, totalObserverCount) {
const disabled = getDisabledObservers();
if (disabled.has(pubkey)) {
disabled.delete(pubkey);
} else {
// Block disabling the last enabled observer.
if (totalObserverCount - disabled.size <= 1) {
return disabled;
}
disabled.add(pubkey);
}
setDisabledObservers(disabled);
return disabled;
}
/**
* Render a row of clickable observer filter badges.
* @param {Array<Object>} options.nodes - Observer nodes (with public_key and _displayName)
* @param {Set<string>} options.disabled - Currently disabled observer public keys
* @param {Function} options.onToggle - Called with a public_key when a badge is clicked
* @param {string} [options.extraClass] - Wrapper classes; must set the display
* (e.g. 'hidden lg:flex' or 'flex lg:hidden') since the base omits it to avoid conflicts
* @returns {TemplateResult|nothing}
*/
export function observerFilterBadges({ nodes, disabled, onToggle, extraClass = 'flex' }) {
if (!nodes || nodes.length === 0) return nothing;
return html`<div class="flex-wrap items-center gap-2 ${extraClass}">
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}:</span>
${nodes.map(n => {
const enabled = !disabled.has(n.public_key);
const cls = enabled ? 'badge badge-primary' : 'badge badge-ghost opacity-50';
const title = enabled
? t('common.filter_observer_disable')
: t('common.filter_observer_enable');
return html`<button type="button"
class="${cls} cursor-pointer"
title=${title}
@click=${() => onToggle(n.public_key)}>${n._displayName}</button>`;
})}
</div>`;
}
// --- Form Helpers ---
/**
@@ -5,7 +5,7 @@ import {
warningBadge,
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
observerIcons
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -26,9 +26,6 @@ export async function render(container, params, router) {
const { signal } = params || {};
const query = params.query || {};
const search = query.search || '';
const observed_by = query.observed_by
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
: [];
const adopted_by = query.adopted_by || '';
const route_type = query.route_type || 'flood,transport_flood';
const page = parseInt(query.page, 10) || 1;
@@ -37,6 +34,9 @@ export async function render(container, params, router) {
const sort = query.sort || 'time';
const order = query.order || 'desc';
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
let disabledObservers = getDisabledObservers();
const config = getConfig();
const features = config.features || {};
const packetsEnabled = features.packets === true;
@@ -84,27 +84,22 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const apiParams = { limit, offset, search, sort, order, route_type };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
if (adopted_by) apiParams.adopted_by = adopted_by;
const fetches = [
apiGet('/api/v1/advertisements', apiParams, { signal }),
// Phase 1: fetch the observer node list (and operator profiles) first.
// The advertisements API filters observers by inclusion only, so we need
// the full observer list to translate the stored "disabled" set into an
// explicit include-list before fetching the data.
const metaFetches = [
apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
];
if (config.oidc_enabled) {
fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
metaFetches.push(apiGet('/api/v1/user/profiles', { limit: 500 }, { signal }));
}
const results = await Promise.all(fetches);
const data = results[0];
const nodesData = results[1];
const metaResults = await Promise.all(metaFetches);
const nodesData = metaResults[0];
const operatorRole = config.role_names?.operator || 'operator';
const profiles = config.oidc_enabled
? (results[2]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
? (metaResults[1]?.items || []).filter(p => p.roles && p.roles.includes(operatorRole))
: [];
const advertisements = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const allNodes = nodesData.items || [];
const sortedNodes = allNodes.map(n => {
@@ -112,23 +107,39 @@ ${displayContent}`, container);
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
const nodesFilter = sortedNodes.length > 0
? html`
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
</label>
<select name="observed_by" multiple size="2"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>`
: nothing;
const enabledObserverKeys = sortedNodes
.filter(n => !disabledObservers.has(n.public_key))
.map(n => n.public_key);
// Only constrain when some current observer is actually hidden (a stale
// disabled key that no longer matches a node should not filter anything).
const observerFilterActive = enabledObserverKeys.length < sortedNodes.length;
const onObserverToggle = (pubkey) => {
disabledObservers = toggleObserver(pubkey, sortedNodes.length);
if (page > 1) {
// Re-scoping the data invalidates the current page; reset to page 1.
const sp = new URLSearchParams(window.location.search);
sp.delete('page');
const qs = sp.toString();
navigate(qs ? `/advertisements?${qs}` : '/advertisements');
} else {
fetchAndRenderData();
}
};
// Phase 2: fetch the advertisements with the resolved observer filter.
const apiParams = { limit, offset, search, sort, order, route_type };
if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
if (adopted_by) apiParams.adopted_by = adopted_by;
const data = await apiGet('/api/v1/advertisements', apiParams, { signal });
const advertisements = data.items || [];
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const observerBadges = (extraClass) => observerFilterBadges({
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
});
const mobileCards = advertisements.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.advertisements').toLowerCase() })}</div>`
@@ -206,7 +217,7 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/advertisements', {
search, observed_by, adopted_by, route_type, limit, sort, order,
search, adopted_by, route_type, limit, sort, order,
});
const filterFields = [
@@ -248,11 +259,7 @@ ${displayContent}`, container);
</select>
</div>`);
}
if (sortedNodes.length > 0) {
filterFields.push(() => nodesFilter);
}
const hasActiveFilters = search !== '' || observed_by.length > 0 || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
const hasActiveFilters = search !== '' || (config.oidc_enabled && adopted_by !== '') || route_type !== 'flood,transport_flood';
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
@@ -264,7 +271,7 @@ ${displayContent}`, container);
defaultOpen: isFilterOpen,
});
const headerParams = { search, observed_by, adopted_by, route_type, limit };
const headerParams = { search, adopted_by, route_type, limit };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/advertisements', params: headerParams,
@@ -272,6 +279,8 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${observerBadges('hidden lg:flex mb-4')}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/advertisements',
@@ -286,6 +295,8 @@ ${mobileSortSelect({
],
})}
${observerBadges('flex lg:hidden mb-4')}
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
@@ -4,9 +4,9 @@ import {
getConfig, formatDateTime, formatDateTimeShort,
getChannelLabelsMap, resolveChannelLabel,
warningBadge,
pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator,
renderFilterCard, autoSubmit, submitOnEnter,
observerIcons
pagination, sortableTableHeader, mobileSortSelect,
renderFilterCard, autoSubmit,
observerIcons, getDisabledObservers, toggleObserver, observerFilterBadges
} from '../components.js';
import { createAutoRefresh } from '../auto-refresh.js';
@@ -15,15 +15,15 @@ export async function render(container, params, router) {
const query = params.query || {};
const message_type = query.message_type || '';
const channel_idx = query.channel_idx || '';
const observed_by = query.observed_by
? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by])
: [];
const page = parseInt(query.page, 10) || 1;
const limit = parseInt(query.limit, 10) || 50;
const offset = (page - 1) * limit;
const sort = query.sort || 'time';
const order = query.order || 'desc';
// Observer filter is sourced from localStorage (shared toggle badges), not the URL.
let disabledObservers = getDisabledObservers();
const config = getConfig();
const features = config.features || {};
const packetsEnabled = features.packets === true;
@@ -210,10 +210,10 @@ ${displayContent}`, container);
async function fetchAndRenderData() {
try {
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
const [data, nodesData, channelsData] = await Promise.all([
apiGet('/api/v1/messages', apiParams, { signal }),
// Phase 1: fetch the observer node list (and channels) first. The messages
// API filters observers by inclusion only, so we need the full observer list
// to translate the stored "disabled" set into an explicit include-list.
const [nodesData, channelsData] = await Promise.all([
apiGet('/api/v1/nodes', { limit: 500, observer: true }, { signal }),
apiGet('/api/v1/channels', {}, { signal }),
]);
@@ -224,16 +224,45 @@ ${displayContent}`, container);
.filter(([idx]) => Number.isInteger(idx)),
);
channelLabels = new Map([...builtinLabels, ...customLabels]);
const messages = dedupeBySignature(data.items || []);
const allNodes = nodesData.items || [];
const sortedNodes = allNodes.map(n => {
const tagName = n.tags?.find(t => t.key === 'name')?.value;
return { ...n, _sortName: (tagName || n.name || '').toLowerCase(), _displayName: tagName || n.name || n.public_key.slice(0, 12) + '...' };
}).sort((a, b) => a._sortName.localeCompare(b._sortName));
const enabledObserverKeys = sortedNodes
.filter(n => !disabledObservers.has(n.public_key))
.map(n => n.public_key);
// Only constrain when some current observer is actually hidden (a stale
// disabled key that no longer matches a node should not filter anything).
const observerFilterActive = enabledObserverKeys.length < sortedNodes.length;
const onObserverToggle = (pubkey) => {
disabledObservers = toggleObserver(pubkey, sortedNodes.length);
if (page > 1) {
// Re-scoping the data invalidates the current page; reset to page 1.
const sp = new URLSearchParams(window.location.search);
sp.delete('page');
const qs = sp.toString();
navigate(qs ? `/messages?${qs}` : '/messages');
} else {
fetchAndRenderData();
}
};
// Phase 2: fetch the messages with the resolved observer filter.
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
if (observerFilterActive) apiParams.observed_by = enabledObserverKeys;
const data = await apiGet('/api/v1/messages', apiParams, { signal });
const messages = dedupeBySignature(data.items || []);
const total = data.total || 0;
const totalPages = Math.ceil(total / limit);
const observerBadges = (extraClass) => observerFilterBadges({
nodes: sortedNodes, disabled: disabledObservers, onToggle: onObserverToggle, extraClass,
});
const mobileCards = messages.length === 0
? html`<div class="text-center py-8 opacity-70">${t('common.no_entity_found', { entity: t('entities.messages').toLowerCase() })}</div>`
: messages.map(msg => {
@@ -313,27 +342,9 @@ ${displayContent}`, container);
});
const paginationBlock = pagination(page, totalPages, '/messages', {
message_type, channel_idx, observed_by, limit, sort, order,
message_type, channel_idx, limit, sort, order,
});
const observerFilter = sortedNodes.length > 0
? html`
<div class="flex flex-col gap-1">
<label class="flex items-center py-1">
<span class="opacity-80 text-sm">${t('common.filter_observer_label')}</span>
</label>
<select name="observed_by" multiple size="3"
class="select select-bordered select-sm w-full max-w-xs">
${sortedNodes.map(n => html`
<option value=${n.public_key}
?selected=${observed_by.includes(n.public_key)}>
${n._displayName}
</option>
`)}
</select>
</div>`
: nothing;
const filterFields = [
() => html`
<div class="flex flex-col gap-1">
@@ -362,11 +373,7 @@ ${displayContent}`, container);
</select>
</div>`,
];
if (sortedNodes.length > 0) {
filterFields.push(() => observerFilter);
}
const hasActiveFilters = message_type !== '' || channel_idx !== '' || observed_by.length > 0;
const hasActiveFilters = message_type !== '' || channel_idx !== '';
const existingDetails = container.querySelector('details.collapse');
const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters;
@@ -378,7 +385,7 @@ ${displayContent}`, container);
defaultOpen: isFilterOpen,
});
const headerParams = { message_type, channel_idx, observed_by, limit };
const headerParams = { message_type, channel_idx, limit };
const sortable = (label, sortKey) => sortableTableHeader(label, {
sortKey, currentSort: sort, currentOrder: order,
navigate, basePath: '/messages', params: headerParams,
@@ -386,6 +393,8 @@ ${displayContent}`, container);
renderPage(html`${filterCard}
${observerBadges('hidden lg:flex mb-4')}
${mobileSortSelect({
currentSort: sort, currentOrder: order,
navigate, basePath: '/messages',
@@ -402,6 +411,8 @@ ${mobileSortSelect({
],
})}
${observerBadges('flex lg:hidden mb-4')}
<div class="lg:hidden space-y-3">
${mobileCards}
</div>
@@ -88,6 +88,8 @@
"filter_member_label": "Member",
"filter_operator_label": "Operator",
"filter_observer_label": "Observer",
"filter_observer_enable": "Click to show this observer",
"filter_observer_disable": "Click to hide this observer",
"node_type": "Node Type",
"show": "Show",
"search_placeholder": "Search by name, ID, or public key...",
+4 -1
View File
@@ -100,7 +100,10 @@
"unnamed": "Naamloos",
"unnamed_node": "Naamloos knooppunt",
"all_operators": "Alle Operators",
"filter_operator_label": "Operator"
"filter_operator_label": "Operator",
"filter_observer_label": "Waarnemer",
"filter_observer_enable": "Klik om deze waarnemer te tonen",
"filter_observer_disable": "Klik om deze waarnemer te verbergen"
},
"links": {
"website": "Website",