diff --git a/CLAUDE.md b/CLAUDE.md index 6905aa7..bec1f8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,8 @@ Access at http://localhost:{web_port} (configured in config.json) - **index.html** - Main HTML interface with embedded styles - **admin/** - Admin panel (login + tabbed settings UI) + - Theme management: rename display names, upload per-direction background PNGs, create new themes + - Display names stored in `config.json` `theme_names` map (folder name -> display name) - **setup/** - First-run setup wizard (multi-step configuration) diff --git a/admin/admin.css b/admin/admin.css index 868b5ca..07817e7 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -443,6 +443,119 @@ textarea { display: none; } +/* Theme Management */ +.theme-info { + background: rgba(0, 0, 0, 0.3); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 20px; +} + +.theme-info h3 { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: #fcd444; + margin-bottom: 10px; +} + +.theme-info p { + font-size: 12px; + color: #b4d4ec; + margin-bottom: 4px; + line-height: 1.6; +} + +.theme-create-row { + display: flex; + gap: 10px; + margin-bottom: 20px; + align-items: center; +} + +.theme-create-row input { + flex: 1; +} + +.theme-card { + background: rgba(0, 0, 0, 0.4); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.theme-card-header { + margin-bottom: 14px; +} + +.theme-card-title { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.theme-name-input { + flex: 1; + min-width: 150px; + font-size: 14px; + font-weight: bold; +} + +.theme-active-badge { + font-family: 'Press Start 2P', monospace; + font-size: 8px; + background: rgba(84, 252, 84, 0.25); + color: #54fc54; + border: 2px solid #54fc54; + border-radius: 4px; + padding: 4px 8px; + white-space: nowrap; +} + +.theme-directions { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.theme-dir-cell { + text-align: center; +} + +.theme-dir-label { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + color: #b4d4ec; + margin-bottom: 6px; +} + +.theme-dir-preview { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #444; + border-radius: 4px; + padding: 4px; + margin-bottom: 8px; + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + aspect-ratio: 3/2; +} + +.theme-dir-preview img { + max-width: 100%; + height: auto; + max-height: 100%; + border-radius: 2px; +} + +.theme-dir-preview .no-sprite { + color: #666; + font-size: 11px; +} + /* Mobile */ @media (max-width: 768px) { .admin-header { @@ -488,4 +601,12 @@ textarea { .sprite-grid { grid-template-columns: repeat(2, 1fr); } + + .theme-directions { + grid-template-columns: 1fr; + } + + .theme-create-row { + flex-direction: column; + } } diff --git a/admin/admin.html b/admin/admin.html index 0dcdb9d..7114f3b 100644 --- a/admin/admin.html +++ b/admin/admin.html @@ -39,6 +39,7 @@ RECEIVERS LOCATION DISPLAY + THEMES TUNING SPRITES SECURITY @@ -174,6 +175,22 @@ + + + Theme Management + + Background Specifications + Size: 1536 x 1024 px Format: PNG + Directions: North, East, South, West - each showing the horizon view from your location + Tip: Place the horizon roughly at the vertical center of each image + + + + CREATE THEME + + + + Performance Tuning diff --git a/admin/admin.js b/admin/admin.js index 4e7bd3a..5e5a6c9 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -72,6 +72,11 @@ spritesLoaded = true; loadSprites(); } + // Lazy-load theme manager on first visit + if (tab.dataset.tab === 'themes' && !themesManagerLoaded) { + themesManagerLoaded = true; + loadThemeManager(); + } }); }); @@ -143,7 +148,7 @@ } } - // ------- Load Themes ------- + // ------- Load Themes (Display tab dropdown) ------- async function loadThemes() { try { const res = await fetch('/api/admin/themes'); @@ -153,8 +158,8 @@ sel.innerHTML = ''; (data.themes || []).forEach(t => { const opt = document.createElement('option'); - opt.value = t; - opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + opt.value = t.id; + opt.textContent = t.name; sel.appendChild(opt); }); // Apply pending value if config loaded first @@ -312,6 +317,141 @@ } }); + // ------- Theme Manager ------- + let themesManagerLoaded = false; + + async function loadThemeManager() { + try { + const res = await fetch('/api/admin/themes'); + if (!res.ok) return; + const data = await res.json(); + const grid = document.getElementById('theme-grid'); + if (!grid) return; + + const activeTheme = data.active || ''; + grid.innerHTML = ''; + + (data.themes || []).forEach(theme => { + const card = document.createElement('div'); + card.className = 'theme-card'; + + const isActive = theme.id === activeTheme; + const badgeHtml = isActive ? 'ACTIVE' : ''; + const cacheBust = Date.now(); + + let directionsHtml = ''; + ['north', 'east', 'south', 'west'].forEach(dir => { + const hasImage = theme.directions[dir]; + const imgHtml = hasImage + ? `` + : `No image`; + directionsHtml += ` + + ${dir.toUpperCase()} + ${imgHtml} + + UPLOAD + + + + `; + }); + + card.innerHTML = ` + + + + RENAME + ${badgeHtml} + + + ${directionsHtml} + `; + + // Rename handler + const renameBtn = card.querySelector('.theme-rename-btn'); + const nameInput = card.querySelector('.theme-name-input'); + renameBtn.addEventListener('click', async () => { + const newName = nameInput.value.trim(); + if (!newName) { toast('Name cannot be empty', true); return; } + try { + const r = await fetch(`/api/admin/themes/${theme.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName }) + }); + if (r.ok) { + toast('Theme renamed'); + loadThemes(); // refresh Display tab dropdown + } else { + const err = await r.json(); + toast(err.error || 'Rename failed', true); + } + } catch (e) { toast('Connection error', true); } + }); + + // Upload handlers + card.querySelectorAll('.theme-upload-input').forEach(input => { + input.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + if (!file.type.includes('png')) { + toast('Only PNG files are allowed', true); + e.target.value = ''; + return; + } + const formData = new FormData(); + formData.append('file', file); + const tid = input.dataset.theme; + const dir = input.dataset.direction; + try { + const r = await fetch(`/api/admin/themes/${tid}/${dir}`, { + method: 'POST', + body: formData + }); + if (r.ok) { + toast(`${dir} background updated`); + loadThemeManager(); // refresh previews + } else { + const err = await r.json(); + toast(err.error || 'Upload failed', true); + } + } catch (err) { toast('Upload error', true); } + e.target.value = ''; + }); + }); + + grid.appendChild(card); + }); + } catch (e) { + console.error('Failed to load theme manager', e); + } + } + + // Create theme handler + document.getElementById('create-theme-btn').addEventListener('click', async () => { + const nameInput = document.getElementById('new-theme-name'); + const name = nameInput.value.trim(); + if (!name) { toast('Enter a theme name', true); return; } + try { + const res = await fetch('/api/admin/themes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (res.ok) { + nameInput.value = ''; + toast('Theme created'); + loadThemeManager(); + loadThemes(); // refresh Display tab dropdown + } else { + const err = await res.json(); + toast(err.error || 'Create failed', true); + } + } catch (e) { toast('Connection error', true); } + }); + // ------- Sprites ------- let spritesLoaded = false; diff --git a/server.py b/server.py index 1659dd1..bd8a80c 100755 --- a/server.py +++ b/server.py @@ -6,6 +6,7 @@ import json import time import os import copy +import re import secrets import ipaddress from pathlib import Path @@ -53,7 +54,8 @@ DEFAULT_CONFIG = { "broadcast_interval_seconds": 1, "cleanup_interval_seconds": 10, "receiver_reconnect_seconds": 5 - } + }, + "theme_names": {} } @@ -705,16 +707,125 @@ async def handle_admin_status(request): }) +VALID_DIRECTIONS = ("north", "east", "south", "west") + + @require_auth async def handle_admin_themes(request): - """GET /api/admin/themes - List available themes.""" + """GET /api/admin/themes - List available themes with display names and directions.""" bg_dir = WEB_DIR / "backgrounds" themes = [] + theme_names = config.get("theme_names", {}) if bg_dir.exists(): for d in sorted(bg_dir.iterdir()): - if d.is_dir() and (d / "north.png").exists(): - themes.append(d.name) - return web.json_response({"themes": themes}) + if d.is_dir(): + directions = {} + for direction in VALID_DIRECTIONS: + directions[direction] = (d / f"{direction}.png").exists() + display_name = theme_names.get(d.name, d.name.replace("_", " ").replace("-", " ").title()) + themes.append({ + "id": d.name, + "name": display_name, + "directions": directions, + }) + return web.json_response({"themes": themes, "active": config.get("theme", "desert")}) + + +def _valid_theme_id(theme_id: str) -> bool: + """Reject path-traversal attempts in theme IDs.""" + return bool(theme_id) and '/' not in theme_id and '\\' not in theme_id and theme_id not in ('.', '..') + + +@require_auth +async def handle_admin_theme_create(request): + """POST /api/admin/themes - Create a new theme.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + name = body.get("name", "").strip() + if not name: + return web.json_response({"error": "Theme name is required"}, status=400) + + # Generate slug from name + slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') + if not slug or not _valid_theme_id(slug): + return web.json_response({"error": "Invalid theme name"}, status=400) + + bg_dir = WEB_DIR / "backgrounds" / slug + if bg_dir.exists(): + return web.json_response({"error": "Theme already exists"}, status=409) + + bg_dir.mkdir(parents=True, exist_ok=True) + config.setdefault("theme_names", {})[slug] = name + save_config() + return web.json_response({"ok": True, "id": slug, "name": name}) + + +@require_auth +async def handle_admin_theme_rename(request): + """PUT /api/admin/themes/{id} - Rename a theme's display name.""" + theme_id = request.match_info.get("id", "") + if not _valid_theme_id(theme_id): + return web.json_response({"error": "Invalid theme ID"}, status=400) + bg_dir = WEB_DIR / "backgrounds" / theme_id + if not bg_dir.exists() or not bg_dir.is_dir(): + return web.json_response({"error": "Theme not found"}, status=404) + + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + name = body.get("name", "").strip() + if not name: + return web.json_response({"error": "Display name is required"}, status=400) + + config.setdefault("theme_names", {})[theme_id] = name + save_config() + return web.json_response({"ok": True, "id": theme_id, "name": name}) + + +@require_auth +async def handle_admin_theme_upload(request): + """POST /api/admin/themes/{id}/{direction} - Upload a background PNG.""" + theme_id = request.match_info.get("id", "") + direction = request.match_info.get("direction", "") + + if not _valid_theme_id(theme_id): + return web.json_response({"error": "Invalid theme ID"}, status=400) + if direction not in VALID_DIRECTIONS: + return web.json_response({"error": f"Invalid direction: {direction}"}, status=400) + + bg_dir = WEB_DIR / "backgrounds" / theme_id + if not bg_dir.exists() or not bg_dir.is_dir(): + return web.json_response({"error": "Theme not found"}, status=404) + + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "file": + return web.json_response({"error": "No file field in upload"}, status=400) + + # Read the uploaded file (limit to 10 MB for background images) + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > 10 * 1024 * 1024: + return web.json_response({"error": "File too large (max 10 MB)"}, status=400) + + # Validate PNG header + if data[:8] != b'\x89PNG\r\n\x1a\n': + return web.json_response({"error": "File is not a valid PNG"}, status=400) + + filepath = bg_dir / f"{direction}.png" + with open(filepath, "wb") as f: + f.write(data) + + return web.json_response({"ok": True, "id": theme_id, "direction": direction}) @require_auth @@ -931,6 +1042,9 @@ async def start_http_server(): app.router.add_put('/api/admin/config', handle_admin_config_update) app.router.add_get('/api/admin/status', handle_admin_status) app.router.add_get('/api/admin/themes', handle_admin_themes) + app.router.add_post('/api/admin/themes', handle_admin_theme_create) + app.router.add_put('/api/admin/themes/{id}', handle_admin_theme_rename) + app.router.add_post('/api/admin/themes/{id}/{direction}', handle_admin_theme_upload) app.router.add_post('/api/admin/password', handle_admin_password) app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers) app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers)
Size: 1536 x 1024 px Format: PNG
Directions: North, East, South, West - each showing the horizon view from your location
Tip: Place the horizon roughly at the vertical center of each image