2026-08-03 10:59:26 -04:00
|
|
|
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>
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
<li><input type="checkbox" data-flag="32" ${flags&32?'checked':''}><span>Time</span></li>
|
2026-08-03 10:59:26 -04:00
|
|
|
<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">
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
<label>Date Format</label>
|
|
|
|
|
<input type="text" id="dateFmt" value="${settings.date_fmt || '%Y-%m-%d'}" placeholder="%Y-%m-%d">
|
|
|
|
|
<small>strftime: %Y=year %m=month %d=day %b=Mon %B=Month</small>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Time Format</label>
|
|
|
|
|
<input type="text" id="timeFmt" value="${settings.time_fmt || '%H:%M:%S'}" placeholder="%H:%M:%S">
|
|
|
|
|
<small>strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM</small>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Style & Position</label>
|
2026-08-03 10:59:26 -04:00
|
|
|
<select id="metaPos">
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
<option value="0" ${settings.meta_pos===0?'selected':''}>Caption - Bottom</option>
|
|
|
|
|
<option value="1" ${settings.meta_pos===1?'selected':''}>Caption - Top</option>
|
|
|
|
|
<option value="2" ${settings.meta_pos===2?'selected':''}>Overlay - Top Left</option>
|
|
|
|
|
<option value="3" ${settings.meta_pos===3?'selected':''}>Overlay - Top Right</option>
|
|
|
|
|
<option value="4" ${settings.meta_pos===4?'selected':''}>Overlay - Bottom Left</option>
|
|
|
|
|
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
|
2026-08-03 10:59:26 -04:00
|
|
|
</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,
|
feat: blurred letterbox, portrait pairs, overlay metadata, button fixes
- Letterbox: extend edge pixels outward with blur + fade to solid color
- Portrait: detect and pair portraits side-by-side, fallback to solo
- Metadata: add overlay mode (corner badge), time field, custom date/time
format strings (strftime), font size 1.5x, region-aware for pairs
- Buttons: swap G9/G10 to match physical layout, double-press random
button for deep sleep, SLP indicator drawn before sleep
- Boot: remove redundant 'Loading photos' screen (saves 17s refresh)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 15:53:06 -04:00
|
|
|
meta_pos: parseInt(document.getElementById('metaPos').value),
|
|
|
|
|
date_fmt: document.getElementById('dateFmt').value,
|
|
|
|
|
time_fmt: document.getElementById('timeFmt').value
|
2026-08-03 10:59:26 -04:00
|
|
|
})});
|
|
|
|
|
toast('Display settings saved');
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renderDevice(el) {
|
2026-08-03 14:39:55 -04:00
|
|
|
const settings = await api('/api/settings');
|
2026-08-03 10:59:26 -04:00
|
|
|
el.innerHTML = `
|
|
|
|
|
<div class="card">
|
2026-08-03 14:39:55 -04:00
|
|
|
<h2>Immich Connection</h2>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>Immich URL</label>
|
|
|
|
|
<input type="url" id="immichUrl" value="${settings.immich_url || ''}" placeholder="https://photos.example.com">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label>API Key</label>
|
|
|
|
|
<input type="password" id="immichKey" value="${settings.immich_key || ''}" placeholder="Your Immich API key">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="btn-group">
|
|
|
|
|
<button class="btn btn-primary" id="saveImmich">Save</button>
|
|
|
|
|
<button class="btn" id="testConn">Test Connection</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="connStatus" style="margin-top:12px"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card">
|
|
|
|
|
<h2>Device Actions</h2>
|
2026-08-03 10:59:26 -04:00
|
|
|
<div class="btn-group">
|
|
|
|
|
<button class="btn btn-danger" onclick="if(confirm('Enter deep sleep?'))api('/api/action/sleep',{method:'POST'})">Deep Sleep</button>
|
2026-08-03 14:39:55 -04:00
|
|
|
<button class="btn btn-danger" onclick="if(confirm('Reboot device?'))fetch('/api/action/reboot',{method:'POST'})">Reboot</button>
|
2026-08-03 10:59:26 -04:00
|
|
|
</div>
|
|
|
|
|
</div>`;
|
2026-08-03 14:39:55 -04:00
|
|
|
|
|
|
|
|
document.getElementById('saveImmich').onclick = async () => {
|
|
|
|
|
const url = document.getElementById('immichUrl').value.trim();
|
|
|
|
|
const key = document.getElementById('immichKey').value.trim();
|
|
|
|
|
if (!url || !key) return toast('URL and API key are required');
|
|
|
|
|
await api('/api/settings', { method: 'POST', body: JSON.stringify({ immich_url: url, immich_key: key }) });
|
|
|
|
|
toast('Immich settings saved. Reboot to apply.');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.getElementById('testConn').onclick = async () => {
|
|
|
|
|
const status = document.getElementById('connStatus');
|
|
|
|
|
status.textContent = 'Testing...';
|
|
|
|
|
status.style.color = '';
|
|
|
|
|
try {
|
|
|
|
|
const result = await api('/api/test-connection', { method: 'POST' });
|
|
|
|
|
status.textContent = result.msg;
|
|
|
|
|
status.style.color = result.ok ? '#2e7d32' : '#c62828';
|
|
|
|
|
} catch (e) {
|
|
|
|
|
status.textContent = 'Request failed: ' + e.message;
|
|
|
|
|
status.style.color = '#c62828';
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-08-03 10:59:26 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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');
|