mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-03 15:33:20 +02:00
feat: improve markdown prose styling, map popup overlay, and collapsible filters
- Add .prose > :first-child margin-top:0 to eliminate double-spacing before headings - Add nested list CSS (circle/square bullets, lower-alpha/roman numbering) - Replace map popup <p>/space-y-1 layout with CSS grid for aligned label-value pairs - Convert map filter card to collapsible <details> with state persistence - Add nested list HTML output tests and markdown features docs
This commit is contained in:
@@ -77,6 +77,24 @@ Markdown content here (include your own heading)...
|
||||
|
||||
The markdown content is rendered as-is, so include your own `# Heading` if desired.
|
||||
|
||||
### Supported Markdown Features
|
||||
|
||||
Pages are rendered with [Python-Markdown](https://python-markdown.github.io/) with the following extensions enabled:
|
||||
|
||||
| Feature | Syntax | Notes |
|
||||
|---------|--------|-------|
|
||||
| Headings | `# H1` through `### H3` | Rendered with `.prose` styling |
|
||||
| Bold / Italic | `**bold**`, `*italic*` | Standard Markdown |
|
||||
| Links | `[text](url)` | Relative paths supported |
|
||||
| Unordered lists | `- item` or `* item` | Nested lists supported (3 levels) |
|
||||
| Ordered lists | `1. item` | Nested lists supported (3 levels) |
|
||||
| Tables | Pipe-delimited (`\| Header \|`) | Auto-generated `<thead>`/`<tbody>` |
|
||||
| Fenced code blocks | ` ``` ` with optional language | Syntax highlighting via `codehilite` extension |
|
||||
| Inline code | `` `code` `` | Styled with monospace font |
|
||||
| Blockquotes | `> quote` | Left border styling |
|
||||
| Images | `` | Use absolute paths to `/media/` |
|
||||
| Table of contents | `[TOC]` marker | Auto-generated from headings |
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
With Docker, mount the content directory as a read-only volume. This is already configured in `docker-compose.yml`:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Plan: Markdown Nested Lists & Map Collapsible Filters
|
||||
|
||||
Date: 2026-05-06
|
||||
|
||||
## Part A: Fix Markdown Prose Styling
|
||||
|
||||
### Problem
|
||||
|
||||
Two CSS issues in custom markdown pages:
|
||||
|
||||
1. **Excessive top spacing** — The first element inside `.prose` (typically an `<h1>`) gets both the DaisyUI `card-body` padding AND its own `margin-top: 1.5rem`, creating a large gap before content starts. The `.prose h1` rule at `app.css:166` applies `margin-top: 1.5rem` unconditionally.
|
||||
|
||||
2. **No nested list differentiation** — All list levels use the same bullet style (`disc`) because the `.prose` CSS in `app.css:199-215` only styles `ul`, `ol`, and `li` at a single level. Python-Markdown correctly generates nested `<ul>`/`<ol>` HTML — the issue is CSS-only.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. Remove top margin on first prose child (`app.css`)
|
||||
|
||||
Add after the `.prose` block (after line 256, the closing brace):
|
||||
|
||||
```css
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
```
|
||||
|
||||
This eliminates the double-spacing (card-body padding + h1 margin-top) for the first element. Subsequent headings still get their normal `margin-top` for visual separation between sections.
|
||||
|
||||
**Specificity note:** `.prose > :first-child` and `.prose h1` have identical specificity (0-1-1). This rule works only because it appears later in the source. Avoid adding new heading styles after this rule — the top-margin override would silently break.
|
||||
|
||||
#### 2. Add nested list CSS to `app.css`
|
||||
|
||||
Add after the `.prose li` block (line 215):
|
||||
|
||||
```css
|
||||
.prose ul ul { list-style-type: circle; }
|
||||
.prose ul ul ul { list-style-type: square; }
|
||||
.prose ol ol { list-style-type: lower-alpha; }
|
||||
.prose ol ol ol { list-style-type: lower-roman; }
|
||||
.prose ul ul, .prose ul ol,
|
||||
.prose ol ul, .prose ol ol { margin-top: 0.25rem; margin-bottom: 0; }
|
||||
```
|
||||
|
||||
This provides:
|
||||
- Level 1: disc / decimal
|
||||
- Level 2: circle / lower-alpha
|
||||
- Level 3+: square / lower-roman
|
||||
|
||||
#### 3. Update `docs/content.md`
|
||||
|
||||
Add a "Supported Markdown Features" section documenting:
|
||||
- Basic formatting (headings, bold, italic, links)
|
||||
- Lists (ordered, unordered, nested)
|
||||
- Tables (pipe-delimited)
|
||||
- Code blocks (fenced with triple backticks, optional language)
|
||||
- Table of contents (`[TOC]` marker)
|
||||
- Images (require absolute paths to `/media/`)
|
||||
|
||||
### Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/meshcore_hub/web/static/css/app.css` | Add `:first-child` rule + nested list CSS rules |
|
||||
| `docs/content.md` | Add markdown features section |
|
||||
| `tests/test_web/test_pages.py` | Add test for nested list HTML output |
|
||||
|
||||
---
|
||||
|
||||
## Part B: Map Page Collapsible Filters
|
||||
|
||||
### Problem
|
||||
|
||||
The map page (`src/meshcore_hub/web/static/js/spa/pages/map.js:195-237`) uses a hardcoded non-collapsible filter card:
|
||||
|
||||
```html
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
```
|
||||
|
||||
Other list pages (nodes, messages, advertisements) use `renderFilterCard()` from `components.js:724-766` with `collapsible: true`, which renders as:
|
||||
|
||||
```html
|
||||
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6">
|
||||
```
|
||||
|
||||
The map page is inconsistent with the rest of the SPA.
|
||||
|
||||
### Approach
|
||||
|
||||
Wrap the map's existing filter controls in the same collapsible `<details>` DaisyUI pattern, but keep the map's custom client-side filter logic. Do **not** refactor to use `renderFilterCard()` — the map has unique behaviors (client-side filtering, show-labels toggle, member filter triggers API re-fetch) that don't fit the shared component's server-side form submission model.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. Replace filter card HTML in `map.js`
|
||||
|
||||
Replace lines 195-237 (the hardcoded `<div class="card">`) with:
|
||||
|
||||
```html
|
||||
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6"
|
||||
?open=${isFilterOpen}>
|
||||
<summary class="collapse-title text-sm font-medium cursor-pointer">
|
||||
${t('common.filters')}
|
||||
</summary>
|
||||
<div class="collapse-content pt-4">
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<!-- existing filter controls unchanged -->
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
```
|
||||
|
||||
#### 2. Add collapsible state persistence
|
||||
|
||||
Before the `litRender()` call, add the same pattern used by other pages:
|
||||
|
||||
```javascript
|
||||
const existingDetails = container.querySelector('details.collapse');
|
||||
const isFilterOpen = existingDetails ? existingDetails.open : false;
|
||||
```
|
||||
|
||||
**Behavior note:** On first visit (no prior DOM state), the filter starts **closed**. This is consistent with all other list pages (nodes, messages, advertisements), but is a change from the current map behavior where the filter card is always visible. Users who want to see filters will need to click to expand — same as every other page in the SPA.
|
||||
|
||||
**Re-render note:** State persistence works because `container.querySelector` inspects the pre-existing DOM before `litRender` replaces content. If the map ever re-renders on every pan/zoom (not just filter changes), the filter would collapse on each re-render. The current map only re-renders on filter changes, so this is not an issue.
|
||||
|
||||
#### 3. Preserve all existing filter logic
|
||||
|
||||
- `applyFilters()` / `applyFiltersCore()` — unchanged
|
||||
- `@change=${applyFilters}` event handlers — unchanged
|
||||
- `clearFiltersHandler()` — unchanged
|
||||
- Show-labels checkbox — unchanged
|
||||
- Member filter re-fetch logic — unchanged
|
||||
|
||||
### Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/map.js` | Replace filter card HTML with collapsible `<details>`, add state persistence |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- `pytest tests/test_web/test_pages.py` — verify Python-Markdown generates nested `<ul>`/`<ol>` HTML for indented markdown (NOT CSS rendering)
|
||||
- `pre-commit run --all-files` — quality checks
|
||||
- Manual: create a test `.md` page with nested lists, verify visual rendering with different bullet/indentation levels
|
||||
- Manual: load map page, verify filter section collapses/expands correctly, survives re-render after filter changes
|
||||
@@ -0,0 +1,17 @@
|
||||
# Tasks: Markdown Nested Lists & Map Collapsible Filters
|
||||
|
||||
## Part A: Fix Markdown Prose Styling
|
||||
|
||||
- [ ] Add `.prose > :first-child { margin-top: 0; }` to `app.css` after the `.prose` block (after line 256)
|
||||
- [ ] Add nested list CSS rules to `app.css` after the `.prose li` block (line 215)
|
||||
- [ ] Update `docs/content.md` with "Supported Markdown Features" section
|
||||
- [ ] Add nested list HTML output test to `tests/test_web/test_pages.py`
|
||||
- [ ] Run `pytest tests/test_web/test_pages.py`
|
||||
- [ ] Run `pre-commit run --all-files`
|
||||
|
||||
## Part B: Map Page Collapsible Filters
|
||||
|
||||
- [ ] Add `isFilterOpen` state persistence logic before `litRender()` in `map.js`
|
||||
- [ ] Replace hardcoded `<div class="card">` filter card (lines 195-237) with collapsible `<details>` in `map.js`
|
||||
- [ ] Verify all existing filter logic preserved (`applyFilters`, `clearFiltersHandler`, show-labels, member filter)
|
||||
- [ ] Manual: load map page, verify filter collapses/expands, survives re-render after filter changes
|
||||
@@ -214,6 +214,13 @@
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.prose ul ul { list-style-type: circle; }
|
||||
.prose ul ul ul { list-style-type: square; }
|
||||
.prose ol ol { list-style-type: lower-alpha; }
|
||||
.prose ol ol ol { list-style-type: lower-roman; }
|
||||
.prose ul ul, .prose ul ol,
|
||||
.prose ol ul, .prose ol ol { margin-top: 0.25rem; margin-bottom: 0; }
|
||||
|
||||
.prose a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
@@ -255,6 +262,10 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@@ -67,19 +67,6 @@ function createNodeIcon(node, oidcEnabled) {
|
||||
|
||||
// Leaflet popup requires plain HTML strings, so keep escapeHtml here
|
||||
function createPopupContent(node, oidcEnabled) {
|
||||
let ownerHtml = '';
|
||||
if (node.owner) {
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
|
||||
: escapeHtml(node.owner.name);
|
||||
ownerHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.owner')) || 'Owner:') + '</span> ' + ownerDisplay + '</p>';
|
||||
}
|
||||
|
||||
let roleHtml = '';
|
||||
if (node.role) {
|
||||
roleHtml = '<p><span class="opacity-70">' + ((window.t && window.t('map.role')) || 'Role:') + '</span> <span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></p>';
|
||||
}
|
||||
|
||||
const typeDisplay = getTypeDisplay(node);
|
||||
const nodeTypeEmoji = typeEmoji(node.adv_type);
|
||||
|
||||
@@ -91,27 +78,39 @@ function createPopupContent(node, oidcEnabled) {
|
||||
infraIndicatorHtml = ' <span style="display: inline-block; width: 10px; height: 10px; background: ' + dotColor + '; border: 2px solid ' + borderColor + '; border-radius: 50%; vertical-align: middle;" title="' + title + '"></span>';
|
||||
}
|
||||
|
||||
const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
|
||||
const lastSeenHtml = node.last_seen
|
||||
? '<p><span class="opacity-70">' + lastSeenLabel + '</span> ' + node.last_seen.substring(0, 19).replace('T', ' ') + '</p>'
|
||||
: '';
|
||||
|
||||
const typeLabel = (window.t && window.t('common.type')) || 'Type:';
|
||||
const keyLabel = (window.t && window.t('common.key')) || 'Key:';
|
||||
const locationLabel = (window.t && window.t('common.location')) || 'Location:';
|
||||
const lastSeenLabel = (window.t && window.t('common.last_seen_label')) || 'Last seen:';
|
||||
const unknownLabel = (window.t && window.t('node_types.unknown')) || 'Unknown';
|
||||
const viewDetailsLabel = (window.t && window.t('common.view_details')) || 'View Details';
|
||||
|
||||
let rows = '';
|
||||
rows += '<div class="opacity-70">' + typeLabel + '</div><div>' + escapeHtml(typeDisplay) + '</div>';
|
||||
|
||||
if (node.role) {
|
||||
const roleLabel = (window.t && window.t('map.role')) || 'Role:';
|
||||
rows += '<div class="opacity-70">' + roleLabel + '</div><div><span class="badge badge-xs badge-ghost">' + escapeHtml(node.role) + '</span></div>';
|
||||
}
|
||||
|
||||
if (node.owner) {
|
||||
const ownerLabel = (window.t && window.t('map.owner')) || 'Owner:';
|
||||
const ownerDisplay = node.owner.callsign
|
||||
? escapeHtml(node.owner.name) + ' (' + escapeHtml(node.owner.callsign) + ')'
|
||||
: escapeHtml(node.owner.name);
|
||||
rows += '<div class="opacity-70">' + ownerLabel + '</div><div>' + ownerDisplay + '</div>';
|
||||
}
|
||||
|
||||
rows += '<div class="opacity-70">' + keyLabel + '</div><div><code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></div>';
|
||||
rows += '<div class="opacity-70">' + locationLabel + '</div><div>' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</div>';
|
||||
|
||||
if (node.last_seen) {
|
||||
rows += '<div class="opacity-70">' + lastSeenLabel + '</div><div>' + node.last_seen.substring(0, 19).replace('T', ' ') + '</div>';
|
||||
}
|
||||
|
||||
return '<div class="p-2">' +
|
||||
'<h3 class="font-bold text-lg mb-2">' + nodeTypeEmoji + ' ' + escapeHtml(node.name || unknownLabel) + infraIndicatorHtml + '</h3>' +
|
||||
'<div class="space-y-1 text-sm">' +
|
||||
'<p><span class="opacity-70">' + typeLabel + '</span> ' + escapeHtml(typeDisplay) + '</p>' +
|
||||
roleHtml +
|
||||
ownerHtml +
|
||||
'<p><span class="opacity-70">' + keyLabel + '</span> <code class="text-xs">' + escapeHtml(node.public_key.substring(0, 16)) + '...</code></p>' +
|
||||
'<p><span class="opacity-70">' + locationLabel + '</span> ' + node.lat.toFixed(4) + ', ' + node.lon.toFixed(4) + '</p>' +
|
||||
lastSeenHtml +
|
||||
'</div>' +
|
||||
'<div class="text-sm grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">' + rows + '</div>' +
|
||||
'<a href="/nodes/' + encodeURIComponent(node.public_key) + '" class="btn btn-outline btn-xs mt-3">' + viewDetailsLabel + '</a>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -182,6 +181,9 @@ export async function render(container, params, router) {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
const existingDetails = container.querySelector('details.collapse');
|
||||
const isFilterOpen = existingDetails ? existingDetails.open : false;
|
||||
|
||||
litRender(html`
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">${t('entities.map')}</h1>
|
||||
@@ -192,8 +194,12 @@ export async function render(container, params, router) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-6 panel-solid" style="--panel-color: var(--color-neutral)">
|
||||
<div class="card-body py-4">
|
||||
<details class="collapse collapse-arrow bg-base-200 border-2 border-base-content/25 rounded-box mb-6"
|
||||
?open=${isFilterOpen}>
|
||||
<summary class="collapse-title text-sm font-medium cursor-pointer">
|
||||
${t('common.filters')}
|
||||
</summary>
|
||||
<div class="collapse-content pt-4">
|
||||
<div class="flex gap-4 flex-wrap items-end">
|
||||
<div class="fieldset">
|
||||
<label class="fieldset-label">${t('common.show')}</label>
|
||||
@@ -234,7 +240,7 @@ export async function render(container, params, router) {
|
||||
<button id="clear-filters" class="btn btn-ghost btn-sm" @click=${clearFiltersHandler}>${t('common.clear_filters')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body p-2">
|
||||
|
||||
@@ -313,6 +313,60 @@ def hello():
|
||||
assert "<pre>" in pages[0].content_html
|
||||
assert "def hello():" in pages[0].content_html
|
||||
|
||||
def test_markdown_nested_unordered_list(self) -> None:
|
||||
"""Test that nested unordered lists produce nested <ul> elements."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "nested.md").write_text("""---
|
||||
title: Nested
|
||||
---
|
||||
|
||||
- Item 1
|
||||
- Sub item A
|
||||
- Sub item B
|
||||
- Deep item
|
||||
- Item 2
|
||||
""")
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
html = pages[0].content_html
|
||||
assert "<ul>" in html
|
||||
assert "<li>Item 1" in html
|
||||
assert "<li>Sub item A" in html
|
||||
assert "<li>Deep item" in html
|
||||
outer_ul = html.index("<ul>")
|
||||
inner_ul = html.index("<ul>", outer_ul + 1)
|
||||
assert inner_ul > outer_ul
|
||||
|
||||
def test_markdown_nested_ordered_list(self) -> None:
|
||||
"""Test that nested ordered lists produce nested <ol> elements."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
(Path(tmpdir) / "nested-ol.md").write_text("""---
|
||||
title: Nested OL
|
||||
---
|
||||
|
||||
1. First
|
||||
1. Sub first
|
||||
2. Sub second
|
||||
2. Second
|
||||
""")
|
||||
|
||||
loader = PageLoader(tmpdir)
|
||||
loader.load_pages()
|
||||
|
||||
pages = loader.get_menu_pages()
|
||||
assert len(pages) == 1
|
||||
html = pages[0].content_html
|
||||
assert "<ol>" in html
|
||||
assert "<li>First" in html
|
||||
assert "<li>Sub first" in html
|
||||
outer_ol = html.index("<ol>")
|
||||
inner_ol = html.index("<ol>", outer_ol + 1)
|
||||
assert inner_ol > outer_ol
|
||||
|
||||
|
||||
class TestPagesRoute:
|
||||
"""Tests for the custom pages routes (SPA).
|
||||
|
||||
Reference in New Issue
Block a user