Compare commits

3 Commits

Author SHA1 Message Date
Louis King
b18b3c9aa4 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>
2026-02-07 13:45:42 +00:00
Louis King
9d99262401 Updates 2026-02-06 23:48:43 +00:00
Louis King
adfe5bc503 Updates 2026-02-06 23:38:08 +00:00
11 changed files with 199 additions and 64 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,14 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145 120" width="145" height="120">
<!-- I letter - muted -->
<rect x="30" y="10" width="25" height="100" rx="2" fill="#ffffff" opacity="0.5"/>
<!-- P vertical stem -->
<rect x="65" y="10" width="25" height="100" rx="2" fill="#ffffff"/>
<!-- 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">
<path d="M 110,65 A 20,20 0 0,0 90,45"/>
<path d="M 125,65 A 35,35 0 0,0 90,30"/>
<path d="M 140,65 A 50,50 0 0,0 90,15"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 70 70"
width="70"
height="70"
version="1.1"
xmlns="http://www.w3.org/2000/svg">
<!-- WiFi arcs radiating from bottom-left corner -->
<g
fill="none"
stroke="#ffffff"
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: 605 B

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

@@ -8,11 +8,16 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 bg-base-100 rounded-box p-6">
<!-- Hero Content (2 columns) -->
<div class="lg:col-span-2 flex flex-col items-center text-center">
<img src="/static/img/logo.svg" alt="{{ network_name }}" class="h-32 w-32 mb-4" />
<h1 class="text-4xl font-bold">{{ network_name }}</h1>
{% if network_city and network_country %}
<p class="py-1 text-lg opacity-70">{{ network_city }}, {{ network_country }}</p>
{% endif %}
<!-- Header: Logo and Title side by side -->
<div class="flex items-center gap-8 mb-4">
<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 %}
<p class="text-2xl opacity-70 mt-2">{{ network_city }}, {{ network_country }}</p>
{% endif %}
</div>
</div>
{% if network_welcome_text %}
<p class="py-4 max-w-[70%]">{{ network_welcome_text }}</p>
{% else %}
@@ -22,12 +27,24 @@
</p>
{% endif %}
<div class="flex-1"></div>
<div class="flex gap-3 mt-auto">
<a href="/dashboard" class="btn btn-neutral">
<div class="flex flex-wrap justify-center gap-3 mt-auto">
<a href="/dashboard" class="btn btn-outline btn-info">
{{ icon_dashboard("h-5 w-5 mr-2") }}
Dashboard
</a>
<a href="/map" class="btn btn-neutral">
<a href="/nodes" class="btn btn-outline btn-primary">
{{ icon_nodes("h-5 w-5 mr-2") }}
Nodes
</a>
<a href="/advertisements" class="btn btn-outline btn-secondary">
{{ icon_advertisements("h-5 w-5 mr-2") }}
Adverts
</a>
<a href="/messages" class="btn btn-outline btn-accent">
{{ icon_messages("h-5 w-5 mr-2") }}
Messages
</a>
<a href="/map" class="btn btn-outline btn-warning">
{{ icon_map("h-5 w-5 mr-2") }}
Map
</a>
@@ -37,36 +54,33 @@
<!-- Stats Column (stacked vertically) -->
<div class="flex flex-col gap-4">
<!-- Total Nodes -->
<div class="stat bg-base-200 rounded-box relative">
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-primary">
{{ icon_nodes("h-8 w-8") }}
</div>
<div class="stat-title">Total Nodes</div>
<div class="stat-value text-primary">{{ stats.total_nodes }}</div>
<div class="stat-desc">All discovered nodes</div>
<a href="/nodes" class="link link-primary text-sm absolute bottom-2 right-4">View Nodes</a>
</div>
<!-- Advertisements (7 days) -->
<div class="stat bg-base-200 rounded-box relative">
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-secondary">
{{ icon_advertisements("h-8 w-8") }}
</div>
<div class="stat-title">Advertisements</div>
<div class="stat-value text-secondary">{{ stats.advertisements_7d }}</div>
<div class="stat-desc">Last 7 days</div>
<a href="/advertisements" class="link link-secondary text-sm absolute bottom-2 right-4">View Adverts</a>
</div>
<!-- Messages (7 days) -->
<div class="stat bg-base-200 rounded-box relative">
<div class="stat bg-base-200 rounded-box">
<div class="stat-figure text-accent">
{{ icon_messages("h-8 w-8") }}
</div>
<div class="stat-title">Messages</div>
<div class="stat-value text-accent">{{ stats.messages_7d }}</div>
<div class="stat-desc">Last 7 days</div>
<a href="/messages" class="link link-accent text-sm absolute bottom-2 right-4">View Messages</a>
</div>
</div>
</div>

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