Add admin theme management: rename, upload, and create themes

Adds a THEMES tab to the admin panel with per-theme cards showing
4 directional previews, editable display names, per-direction PNG
upload, and new theme creation. Display names are stored in
config.json theme_names map rather than renaming filesystem folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 10:50:40 -07:00
parent 933fa8f257
commit 248c33ed80
5 changed files with 402 additions and 8 deletions
+2
View File
@@ -46,6 +46,8 @@ Access at http://localhost:{web_port} (configured in config.json)
- **index.html** - Main HTML interface with embedded styles - **index.html** - Main HTML interface with embedded styles
- **admin/** - Admin panel (login + tabbed settings UI) - **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) - **setup/** - First-run setup wizard (multi-step configuration)
+121
View File
@@ -443,6 +443,119 @@ textarea {
display: none; 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 */ /* Mobile */
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-header { .admin-header {
@@ -488,4 +601,12 @@ textarea {
.sprite-grid { .sprite-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.theme-directions {
grid-template-columns: 1fr;
}
.theme-create-row {
flex-direction: column;
}
} }
+17
View File
@@ -39,6 +39,7 @@
<button class="tab" data-tab="receivers">RECEIVERS</button> <button class="tab" data-tab="receivers">RECEIVERS</button>
<button class="tab" data-tab="location">LOCATION</button> <button class="tab" data-tab="location">LOCATION</button>
<button class="tab" data-tab="display">DISPLAY</button> <button class="tab" data-tab="display">DISPLAY</button>
<button class="tab" data-tab="themes">THEMES</button>
<button class="tab" data-tab="tuning">TUNING</button> <button class="tab" data-tab="tuning">TUNING</button>
<button class="tab" data-tab="sprites">SPRITES</button> <button class="tab" data-tab="sprites">SPRITES</button>
<button class="tab" data-tab="security">SECURITY</button> <button class="tab" data-tab="security">SECURITY</button>
@@ -174,6 +175,22 @@
</div> </div>
</section> </section>
<!-- Themes Tab -->
<section id="tab-themes" class="tab-pane hidden">
<h2>Theme Management</h2>
<div class="theme-info">
<h3>Background Specifications</h3>
<p><strong>Size:</strong> 1536 x 1024 px &nbsp; <strong>Format:</strong> PNG</p>
<p><strong>Directions:</strong> North, East, South, West - each showing the horizon view from your location</p>
<p><strong>Tip:</strong> Place the horizon roughly at the vertical center of each image</p>
</div>
<div class="theme-create-row">
<input type="text" id="new-theme-name" placeholder="New theme name...">
<button id="create-theme-btn" class="btn btn-primary">CREATE THEME</button>
</div>
<div id="theme-grid"></div>
</section>
<!-- Tuning Tab --> <!-- Tuning Tab -->
<section id="tab-tuning" class="tab-pane hidden"> <section id="tab-tuning" class="tab-pane hidden">
<h2>Performance Tuning</h2> <h2>Performance Tuning</h2>
+143 -3
View File
@@ -72,6 +72,11 @@
spritesLoaded = true; spritesLoaded = true;
loadSprites(); 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() { async function loadThemes() {
try { try {
const res = await fetch('/api/admin/themes'); const res = await fetch('/api/admin/themes');
@@ -153,8 +158,8 @@
sel.innerHTML = ''; sel.innerHTML = '';
(data.themes || []).forEach(t => { (data.themes || []).forEach(t => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = t; opt.value = t.id;
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); opt.textContent = t.name;
sel.appendChild(opt); sel.appendChild(opt);
}); });
// Apply pending value if config loaded first // 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 ? '<span class="theme-active-badge">ACTIVE</span>' : '';
const cacheBust = Date.now();
let directionsHtml = '';
['north', 'east', 'south', 'west'].forEach(dir => {
const hasImage = theme.directions[dir];
const imgHtml = hasImage
? `<img src="/backgrounds/${theme.id}/${dir}.png?v=${cacheBust}" alt="${dir}">`
: `<span class="no-sprite">No image</span>`;
directionsHtml += `
<div class="theme-dir-cell">
<div class="theme-dir-label">${dir.toUpperCase()}</div>
<div class="theme-dir-preview">${imgHtml}</div>
<label class="sprite-upload-label">
UPLOAD
<input type="file" class="theme-upload-input" accept=".png,image/png"
data-theme="${theme.id}" data-direction="${dir}">
</label>
</div>
`;
});
card.innerHTML = `
<div class="theme-card-header">
<div class="theme-card-title">
<input type="text" class="theme-name-input" value="${theme.name}" data-theme="${theme.id}">
<button class="btn btn-small theme-rename-btn" data-theme="${theme.id}">RENAME</button>
${badgeHtml}
</div>
</div>
<div class="theme-directions">${directionsHtml}</div>
`;
// 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 ------- // ------- Sprites -------
let spritesLoaded = false; let spritesLoaded = false;
+119 -5
View File
@@ -6,6 +6,7 @@ import json
import time import time
import os import os
import copy import copy
import re
import secrets import secrets
import ipaddress import ipaddress
from pathlib import Path from pathlib import Path
@@ -53,7 +54,8 @@ DEFAULT_CONFIG = {
"broadcast_interval_seconds": 1, "broadcast_interval_seconds": 1,
"cleanup_interval_seconds": 10, "cleanup_interval_seconds": 10,
"receiver_reconnect_seconds": 5 "receiver_reconnect_seconds": 5
} },
"theme_names": {}
} }
@@ -705,16 +707,125 @@ async def handle_admin_status(request):
}) })
VALID_DIRECTIONS = ("north", "east", "south", "west")
@require_auth @require_auth
async def handle_admin_themes(request): 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" bg_dir = WEB_DIR / "backgrounds"
themes = [] themes = []
theme_names = config.get("theme_names", {})
if bg_dir.exists(): if bg_dir.exists():
for d in sorted(bg_dir.iterdir()): for d in sorted(bg_dir.iterdir()):
if d.is_dir() and (d / "north.png").exists(): if d.is_dir():
themes.append(d.name) directions = {}
return web.json_response({"themes": themes}) 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 @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_put('/api/admin/config', handle_admin_config_update)
app.router.add_get('/api/admin/status', handle_admin_status) app.router.add_get('/api/admin/status', handle_admin_status)
app.router.add_get('/api/admin/themes', handle_admin_themes) 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/password', handle_admin_password)
app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers) app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers)
app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers) app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers)