Refactor PAGES_HOME to CONTENT_HOME and add custom logo support

- Replace PAGES_HOME with CONTENT_HOME configuration (default: ./content)
- Content directory now contains pages/ and media/ subdirectories
- Add support for custom logo at $CONTENT_HOME/media/images/logo.svg
- Custom logo replaces favicon and navbar/home logos when present
- Mount media directory as /media for serving custom assets
- Simplify default logo to generic WiFi-style radiating arcs
- Update documentation and example directory structure
- Update tests for new CONTENT_HOME structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-07 13:45:42 +00:00
parent 9d99262401
commit b18b3c9aa4
11 changed files with 166 additions and 92 deletions

View File

@@ -455,7 +455,7 @@ See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list.
Key variables:
- `DATA_HOME` - Base directory for runtime data (default: `./data`)
- `SEED_HOME` - Directory containing seed data files (default: `./seed`)
- `PAGES_HOME` - Directory containing custom markdown pages (default: `./pages`)
- `CONTENT_HOME` - Directory containing custom content (pages, media) (default: `./content`)
- `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
@@ -473,12 +473,16 @@ ${SEED_HOME}/
└── members.yaml # Network members list
```
**Custom Pages (`PAGES_HOME`)** - Contains custom markdown pages for the web dashboard:
**Custom Content (`CONTENT_HOME`)** - Contains custom pages and media for the web dashboard:
```
${PAGES_HOME}/
├── about.md # Example: About page (/pages/about)
├── faq.md # Example: FAQ page (/pages/faq)
└── getting-started.md # Example: Getting Started (/pages/getting-started)
${CONTENT_HOME}/
├── pages/ # Custom markdown pages
│ ├── about.md # Example: About page (/pages/about)
│ ├── faq.md # Example: FAQ page (/pages/faq)
│ └── getting-started.md # Example: Getting Started (/pages/getting-started)
└── media/ # Custom media files
└── images/
└── logo.svg # Custom logo (replaces default favicon and navbar/home logo)
```
Pages use YAML frontmatter for metadata:

View File

@@ -338,19 +338,28 @@ The collector automatically cleans up old event data and inactive nodes:
| `NETWORK_CONTACT_EMAIL` | *(none)* | Contact email address |
| `NETWORK_CONTACT_DISCORD` | *(none)* | Discord server link |
| `NETWORK_CONTACT_GITHUB` | *(none)* | GitHub repository URL |
| `PAGES_HOME` | `./pages` | Directory containing custom markdown pages |
| `CONTENT_HOME` | `./content` | Directory containing custom content (pages/, media/) |
### Custom Pages
### Custom Content
The web dashboard supports custom markdown pages for adding static content like "About Us", "Getting Started", or "FAQ" pages. Pages are stored as markdown files with YAML frontmatter.
The web dashboard supports custom content including markdown pages and media files. Content is organized in subdirectories:
```
content/
├── pages/ # Custom markdown pages
│ └── about.md
└── media/ # Custom media files
└── images/
└── logo.svg # Custom logo (replaces favicon and navbar/home logo)
```
**Setup:**
```bash
# Create pages directory
mkdir -p pages
# Create content directory structure
mkdir -p content/pages content/media
# Create a custom page
cat > pages/about.md << 'EOF'
cat > content/pages/about.md << 'EOF'
---
title: About Us
slug: about
@@ -378,14 +387,14 @@ EOF
The markdown content is rendered as-is, so include your own `# Heading` if desired.
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the pages directory:
Pages automatically appear in the navigation menu and sitemap. With Docker, mount the content directory:
```yaml
# docker-compose.yml (already configured)
volumes:
- ${PAGES_HOME:-./pages}:/pages:ro
- ${CONTENT_HOME:-./content}:/content:ro
environment:
- PAGES_HOME=/pages
- CONTENT_HOME=/content
```
## Seed Data
@@ -594,10 +603,16 @@ meshcore-hub/
│ ├── seed/ # Example seed data files
│ │ ├── node_tags.yaml # Example node tags
│ │ └── members.yaml # Example network members
│ └── pages/ # Example custom pages
── about.md # Example about page
│ └── content/ # Example custom content
── pages/ # Example custom pages
│ │ └── about.md # Example about page
│ └── media/ # Example media files
│ └── images/ # Custom images
├── seed/ # Seed data directory (SEED_HOME, copy from example/seed/)
├── pages/ # Custom pages directory (PAGES_HOME, optional)
├── content/ # Custom content directory (CONTENT_HOME, optional)
│ ├── pages/ # Custom markdown pages
│ └── media/ # Custom media files
│ └── images/ # Custom images (logo.svg replaces default logo)
├── data/ # Runtime data directory (DATA_HOME, created at runtime)
├── Dockerfile # Docker build configuration
├── docker-compose.yml # Docker Compose services

View File

@@ -242,7 +242,7 @@ services:
ports:
- "${WEB_PORT:-8080}:8080"
volumes:
- ${PAGES_HOME:-./pages}:/pages:ro
- ${CONTENT_HOME:-./content}:/content:ro
environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- API_BASE_URL=http://api:8000
@@ -260,7 +260,7 @@ services:
- NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-}
- NETWORK_CONTACT_GITHUB=${NETWORK_CONTACT_GITHUB:-}
- NETWORK_WELCOME_TEXT=${NETWORK_WELCOME_TEXT:-}
- PAGES_HOME=/pages
- CONTENT_HOME=/content
command: ["web"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 115 100"
width="115"
height="100"
version="1.1"
id="svg4"
sodipodi:docname="logo-dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<!-- I letter - muted -->
<rect
x="0"
y="0"
width="25"
height="100"
rx="2"
fill="#ffffff"
opacity="0.5"
id="rect1" />
<!-- P vertical stem -->
<rect
x="35"
y="0"
width="25"
height="100"
rx="2"
fill="#ffffff"
id="rect2" />
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
<g
fill="none"
stroke="#ffffff"
stroke-width="10"
stroke-linecap="round"
id="g4"
transform="translate(-30,-10)">
<path
d="M 110,65 A 20,20 0 0 0 90,45"
id="path2" />
<path
d="M 125,65 A 35,35 0 0 0 90,30"
id="path3" />
<path
d="M 140,65 A 50,50 0 0 0 90,15"
id="path4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -295,18 +295,32 @@ class WebSettings(CommonSettings):
default=None, description="Welcome text for homepage"
)
# Custom pages directory
pages_home: Optional[str] = Field(
# Content directory (contains pages/ and media/ subdirectories)
content_home: Optional[str] = Field(
default=None,
description="Directory containing custom markdown pages (default: ./pages)",
description="Directory containing custom content (pages/, media/) (default: ./content)",
)
@property
def effective_pages_home(self) -> str:
"""Get the effective pages home directory."""
def effective_content_home(self) -> str:
"""Get the effective content home directory."""
from pathlib import Path
return str(Path(self.pages_home or "./pages"))
return str(Path(self.content_home or "./content"))
@property
def effective_pages_home(self) -> str:
"""Get the effective pages directory (content_home/pages)."""
from pathlib import Path
return str(Path(self.effective_content_home) / "pages")
@property
def effective_media_home(self) -> str:
"""Get the effective media directory (content_home/media)."""
from pathlib import Path
return str(Path(self.effective_content_home) / "media")
@property
def web_data_dir(self) -> str:

View File

@@ -132,10 +132,23 @@ def create_app(
page_loader.load_pages()
app.state.page_loader = page_loader
# Check for custom logo and store media path
media_home = Path(settings.effective_media_home)
custom_logo_path = media_home / "images" / "logo.svg"
if custom_logo_path.exists():
app.state.logo_url = "/media/images/logo.svg"
logger.info(f"Using custom logo from {custom_logo_path}")
else:
app.state.logo_url = "/static/img/logo.svg"
# Mount static files
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Mount custom media files if directory exists
if media_home.exists() and media_home.is_dir():
app.mount("/media", StaticFiles(directory=str(media_home)), name="media")
# Include routers
from meshcore_hub.web.routes import web_router
@@ -292,5 +305,6 @@ def get_network_context(request: Request) -> dict:
"network_welcome_text": request.app.state.network_welcome_text,
"admin_enabled": request.app.state.admin_enabled,
"custom_pages": custom_pages,
"logo_url": request.app.state.logo_url,
"version": __version__,
}

View File

@@ -1,61 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 115 100"
width="115"
height="100"
viewBox="0 0 70 70"
width="70"
height="70"
version="1.1"
id="svg4"
sodipodi:docname="logo-dark.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4" />
<sodipodi:namedview
id="namedview4"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<!-- I letter - muted -->
<rect
x="0"
y="0"
width="25"
height="100"
rx="2"
fill="#ffffff"
opacity="0.5"
id="rect1" />
<!-- P vertical stem -->
<rect
x="35"
y="0"
width="25"
height="100"
rx="2"
fill="#ffffff"
id="rect2" />
<!-- WiFi arcs: center at mid-stem (90, 60), sweeping from right up to top -->
xmlns="http://www.w3.org/2000/svg">
<!-- WiFi arcs radiating from bottom-left corner -->
<g
fill="none"
stroke="#ffffff"
stroke-width="10"
stroke-linecap="round"
id="g4"
transform="translate(-30,-10)">
<path
d="M 110,65 A 20,20 0 0 0 90,45"
id="path2" />
<path
d="M 125,65 A 35,35 0 0 0 90,30"
id="path3" />
<path
d="M 140,65 A 50,50 0 0 0 90,15"
id="path4" />
stroke-width="8"
stroke-linecap="round">
<!-- Inner arc: from right to top -->
<path d="M 20,65 A 15,15 0 0 0 5,50" />
<!-- Middle arc -->
<path d="M 35,65 A 30,30 0 0 0 5,35" />
<!-- Outer arc -->
<path d="M 50,65 A 45,45 0 0 0 5,20" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -25,7 +25,7 @@
<meta name="twitter:description" content="{% block twitter_description %}{{ self.meta_description() }}{% endblock %}">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/img/logo.svg">
<link rel="icon" type="image/svg+xml" href="{{ logo_url }}">
<!-- Tailwind CSS with DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.4.19/dist/full.min.css" rel="stylesheet" type="text/css" />
@@ -112,7 +112,7 @@
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-6 w-6 mr-2" />
{{ network_name }}
</a>
</div>

View File

@@ -10,7 +10,7 @@
<div class="lg:col-span-2 flex flex-col items-center text-center">
<!-- Header: Logo and Title side by side -->
<div class="flex items-center gap-8 mb-4">
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-36 w-36" />
<img src="{{ logo_url }}" alt="{{ network_name }}" class="h-36 w-36" />
<div class="flex flex-col justify-center">
<h1 class="text-6xl font-black tracking-tight">{{ network_name }}</h1>
{% if network_city and network_country %}

View File

@@ -346,9 +346,12 @@ class TestPagesRoute:
@pytest.fixture
def pages_dir(self) -> Generator[str, None, None]:
"""Create a temporary directory with test pages."""
"""Create a temporary content directory with test pages."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "about.md").write_text(
# Create pages subdirectory (CONTENT_HOME/pages)
pages_subdir = Path(tmpdir) / "pages"
pages_subdir.mkdir()
(pages_subdir / "about.md").write_text(
"""---
title: About Us
slug: about
@@ -360,7 +363,7 @@ menu_order: 10
Welcome to the network.
"""
)
(Path(tmpdir) / "faq.md").write_text(
(pages_subdir / "faq.md").write_text(
"""---
title: FAQ
slug: faq
@@ -381,8 +384,8 @@ Here are some answers.
"""Create a web app with custom pages configured."""
import os
# Temporarily set PAGES_HOME environment variable
os.environ["PAGES_HOME"] = pages_dir
# Temporarily set CONTENT_HOME environment variable
os.environ["CONTENT_HOME"] = pages_dir
from meshcore_hub.web.app import create_app
@@ -396,7 +399,7 @@ Here are some answers.
yield app
# Cleanup
del os.environ["PAGES_HOME"]
del os.environ["CONTENT_HOME"]
@pytest.fixture
def client_with_pages(
@@ -442,9 +445,12 @@ class TestPagesInSitemap:
@pytest.fixture
def pages_dir(self) -> Generator[str, None, None]:
"""Create a temporary directory with test pages."""
"""Create a temporary content directory with test pages."""
with tempfile.TemporaryDirectory() as tmpdir:
(Path(tmpdir) / "about.md").write_text(
# Create pages subdirectory (CONTENT_HOME/pages)
pages_subdir = Path(tmpdir) / "pages"
pages_subdir.mkdir()
(pages_subdir / "about.md").write_text(
"""---
title: About
slug: about
@@ -462,7 +468,7 @@ About page.
"""Create a test client with custom pages for sitemap testing."""
import os
os.environ["PAGES_HOME"] = pages_dir
os.environ["CONTENT_HOME"] = pages_dir
from meshcore_hub.web.app import create_app
@@ -476,7 +482,7 @@ About page.
client = TestClient(app, raise_server_exceptions=True)
yield client
del os.environ["PAGES_HOME"]
del os.environ["CONTENT_HOME"]
def test_pages_included_in_sitemap(
self, client_with_pages_for_sitemap: TestClient