feat: web server with REST API, captive portal, and management UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 10:59:26 -04:00
parent 8c5c39dbe3
commit 8efd1917f7
6 changed files with 670 additions and 0 deletions

204
data/app.js Normal file
View File

@@ -0,0 +1,204 @@
const API = '';
let currentPage = 'dashboard';
async function api(path, opts = {}) {
const res = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...opts
});
return res.json();
}
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
function navigate(page) {
currentPage = page;
document.querySelectorAll('#nav-links a').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
renderPage(page);
}
async function renderPage(page) {
const el = document.getElementById('content');
switch (page) {
case 'dashboard': return renderDashboard(el);
case 'albums': return renderAlbums(el);
case 'slideshow': return renderSlideshow(el);
case 'display': return renderDisplay(el);
case 'device': return renderDevice(el);
case 'firmware': return renderFirmware(el);
}
}
async function renderDashboard(el) {
const status = await api('/api/status');
el.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="stat"><span>Battery</span><span>${status.battery}%${status.charging ? ' ⚡' : ''}</span></div>
<div class="stat"><span>WiFi Signal</span><span>${status.wifi_rssi} dBm</span></div>
<div class="stat"><span>IP Address</span><span>${status.ip}</span></div>
<div class="stat"><span>Uptime</span><span>${Math.floor(status.uptime/60)}m</span></div>
<div class="stat"><span>Free RAM</span><span>${Math.floor(status.free_heap/1024)}KB</span></div>
<div class="stat"><span>Free PSRAM</span><span>${Math.floor(status.free_psram/1024)}KB</span></div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="btn-group">
<button class="btn btn-primary" onclick="api('/api/action/next',{method:'POST'}).then(()=>toast('Next photo'))">Next</button>
<button class="btn btn-primary" onclick="api('/api/action/random',{method:'POST'}).then(()=>toast('Random photo'))">Random</button>
<button class="btn btn-primary" onclick="api('/api/action/pause',{method:'POST'}).then(()=>toast('Paused'))">Pause</button>
<button class="btn btn-primary" onclick="api('/api/action/play',{method:'POST'}).then(()=>toast('Playing'))">Play</button>
</div>
</div>`;
}
async function renderAlbums(el) {
const [albums, settings] = await Promise.all([api('/api/albums'), api('/api/settings')]);
const selected = JSON.parse(settings.albums_json || '[]');
el.innerHTML = `
<div class="card">
<h2>Albums</h2>
<ul class="checkbox-list">
${albums.map(a => `
<li>
<input type="checkbox" value="${a.id}" ${selected.includes(a.id) ? 'checked' : ''}>
<span>${a.title} (${a.assetCount})</span>
</li>`).join('')}
</ul>
<div class="btn-group">
<button class="btn btn-primary" id="saveAlbums">Save Selection</button>
</div>
</div>`;
document.getElementById('saveAlbums').onclick = async () => {
const ids = [...el.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
await api('/api/albums/select', { method: 'POST', body: JSON.stringify({ album_ids: ids }) });
toast('Albums saved');
};
}
async function renderSlideshow(el) {
const settings = await api('/api/settings');
const intervals = [1, 5, 15, 30, 60];
const modes = ['Random', 'Chronological', 'Reverse Chrono', 'Favorites Weighted'];
el.innerHTML = `
<div class="card">
<h2>Interval</h2>
<div class="preset-btns">
${intervals.map(i => `<button class="${settings.interval_min===i?'active':''}" data-interval="${i}">${i}m</button>`).join('')}
</div>
</div>
<div class="card">
<h2>Cycling Mode</h2>
<div class="field">
<select id="cycleMode">
${modes.map((m, i) => `<option value="${i}" ${settings.cycle_mode===i?'selected':''}>${m}</option>`).join('')}
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveSlideshow">Save</button></div>`;
el.querySelectorAll('[data-interval]').forEach(btn => {
btn.onclick = () => {
el.querySelectorAll('[data-interval]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
};
});
document.getElementById('saveSlideshow').onclick = async () => {
const interval = parseInt(el.querySelector('[data-interval].active')?.dataset.interval || '5');
const mode = parseInt(document.getElementById('cycleMode').value);
await api('/api/settings', { method: 'POST', body: JSON.stringify({ interval_min: interval, cycle_mode: mode }) });
toast('Slideshow settings saved');
};
}
async function renderDisplay(el) {
const settings = await api('/api/settings');
const flags = settings.meta_flags;
el.innerHTML = `
<div class="card">
<h2>Image Quality</h2>
<div class="field">
<select id="imgQuality">
<option value="0" ${settings.img_quality===0?'selected':''}>Preview (faster)</option>
<option value="1" ${settings.img_quality===1?'selected':''}>Original (higher quality)</option>
</select>
</div>
</div>
<div class="card">
<h2>Metadata Overlay</h2>
<ul class="checkbox-list">
<li><input type="checkbox" data-flag="1" ${flags&1?'checked':''}><span>Date</span></li>
<li><input type="checkbox" data-flag="2" ${flags&2?'checked':''}><span>Location</span></li>
<li><input type="checkbox" data-flag="4" ${flags&4?'checked':''}><span>People</span></li>
<li><input type="checkbox" data-flag="8" ${flags&8?'checked':''}><span>Album</span></li>
<li><input type="checkbox" data-flag="16" ${flags&16?'checked':''}><span>Camera</span></li>
</ul>
<div class="field">
<label>Position</label>
<select id="metaPos">
<option value="0" ${settings.meta_pos===0?'selected':''}>Bottom</option>
<option value="1" ${settings.meta_pos===1?'selected':''}>Top</option>
</select>
</div>
</div>
<div class="btn-group"><button class="btn btn-primary" id="saveDisplay">Save</button></div>`;
document.getElementById('saveDisplay').onclick = async () => {
let flags = 0;
el.querySelectorAll('[data-flag]').forEach(cb => { if (cb.checked) flags |= parseInt(cb.dataset.flag); });
await api('/api/settings', { method: 'POST', body: JSON.stringify({
img_quality: parseInt(document.getElementById('imgQuality').value),
meta_flags: flags,
meta_pos: parseInt(document.getElementById('metaPos').value)
})});
toast('Display settings saved');
};
}
async function renderDevice(el) {
el.innerHTML = `
<div class="card">
<h2>Device</h2>
<div class="btn-group">
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
<button class="btn btn-danger" onclick="if(confirm('Reboot?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
</div>
</div>`;
}
async function renderFirmware(el) {
el.innerHTML = `
<div class="card">
<h2>Firmware Update</h2>
<div class="field">
<label>Upload .bin file</label>
<input type="file" id="fwFile" accept=".bin">
</div>
<button class="btn btn-primary" id="uploadFw">Upload & Flash</button>
<div id="fwStatus" style="margin-top:12px"></div>
</div>`;
document.getElementById('uploadFw').onclick = async () => {
const file = document.getElementById('fwFile').files[0];
if (!file) return toast('Select a file first');
const formData = new FormData();
formData.append('firmware', file);
document.getElementById('fwStatus').textContent = 'Uploading...';
const res = await fetch('/api/firmware', { method: 'POST', body: formData });
const data = await res.json();
document.getElementById('fwStatus').textContent = data.msg || (data.ok ? 'Success!' : 'Failed');
};
}
// Navigation
document.querySelectorAll('#nav-links a').forEach(a => {
a.addEventListener('click', (e) => { e.preventDefault(); navigate(a.dataset.page); });
});
// Initial render
navigate('dashboard');

24
data/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<nav>
<h1>PaperColor</h1>
<div id="nav-links">
<a href="#" data-page="dashboard" class="active">Dashboard</a>
<a href="#" data-page="albums">Albums</a>
<a href="#" data-page="slideshow">Slideshow</a>
<a href="#" data-page="display">Display</a>
<a href="#" data-page="device">Device</a>
<a href="#" data-page="firmware">Firmware</a>
</div>
</nav>
<main id="content"></main>
<script src="/app.js"></script>
</body>
</html>

89
data/setup.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PaperColor Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; max-width: 400px; margin: 40px auto; padding: 20px; background: #f5f5f5; }
h1 { margin-bottom: 20px; color: #333; }
.field { margin-bottom: 16px; }
label { display: block; font-weight: 600; margin-bottom: 4px; color: #555; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
button { width: 100%; padding: 12px; background: #2563eb; color: white; border: none; border-radius: 6px; font-size: 16px; cursor: pointer; margin-top: 10px; }
button:hover { background: #1d4ed8; }
.status { margin-top: 16px; padding: 10px; border-radius: 6px; display: none; }
.status.error { display: block; background: #fee2e2; color: #991b1b; }
.status.success { display: block; background: #d1fae5; color: #065f46; }
#networks { margin-bottom: 16px; }
</style>
</head>
<body>
<h1>PaperColor Setup</h1>
<form id="setupForm">
<div class="field">
<label>WiFi Network</label>
<select id="wifiSsid"><option value="">Scanning...</option></select>
</div>
<div class="field">
<label>WiFi Password</label>
<input type="password" id="wifiPass" required>
</div>
<div class="field">
<label>Immich URL</label>
<input type="url" id="immichUrl" value="https://photos.example.com">
</div>
<div class="field">
<label>Immich API Key</label>
<input type="text" id="immichKey" required placeholder="Your API key">
</div>
<button type="submit">Save & Connect</button>
</form>
<div id="status" class="status"></div>
<script>
async function scanNetworks() {
try {
const res = await fetch('/api/wifi/scan');
const networks = await res.json();
const select = document.getElementById('wifiSsid');
select.innerHTML = networks.map(n =>
`<option value="${n.ssid}">${n.ssid} (${n.rssi}dBm)</option>`
).join('');
} catch (e) {
console.error('Scan failed:', e);
}
}
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
try {
const res = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wifi_ssid: document.getElementById('wifiSsid').value,
wifi_pass: document.getElementById('wifiPass').value,
immich_url: document.getElementById('immichUrl').value,
immich_key: document.getElementById('immichKey').value
})
});
const data = await res.json();
if (data.ok) {
status.className = 'status success';
status.textContent = 'Saved! Device is rebooting...';
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
status.className = 'status error';
status.textContent = 'Error: ' + e.message;
}
});
scanNetworks();
</script>
</body>
</html>

29
data/style.css Normal file
View File

@@ -0,0 +1,29 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #1e293b; }
nav { background: #1e293b; color: white; padding: 16px 24px; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
nav h1 { font-size: 18px; white-space: nowrap; }
#nav-links { display: flex; gap: 12px; flex-wrap: wrap; }
#nav-links a { color: #94a3b8; text-decoration: none; padding: 4px 8px; border-radius: 4px; font-size: 14px; }
#nav-links a.active { color: white; background: #334155; }
main { max-width: 800px; margin: 24px auto; padding: 0 16px; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 16px; margin-bottom: 12px; color: #475569; }
.stat { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.stat:last-child { border-bottom: none; }
.field { margin-bottom: 16px; }
.field label { display: block; font-weight: 600; margin-bottom: 4px; font-size: 14px; color: #64748b; }
.field input, .field select { width: 100%; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; }
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.btn-group { display: flex; gap: 8px; margin-top: 12px; }
.checkbox-list { list-style: none; }
.checkbox-list li { padding: 8px 0; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 8px; }
.checkbox-list input[type="checkbox"] { width: 18px; height: 18px; }
.preset-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.preset-btns button { padding: 6px 12px; border: 1px solid #e2e8f0; border-radius: 4px; background: white; cursor: pointer; }
.preset-btns button.active { background: #2563eb; color: white; border-color: #2563eb; }
.toast { position: fixed; bottom: 20px; right: 20px; background: #065f46; color: white; padding: 12px 20px; border-radius: 6px; display: none; }
.toast.show { display: block; }