Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Hand-drawn canvas battery-history chart. Ported intact from the
|
||||
// original single-page UI. Reads theme colors live so it redraws
|
||||
// correctly on theme changes (see the themechange listener in
|
||||
// frame_stats.js).
|
||||
|
||||
let lastBatteryLog = null;
|
||||
|
||||
function drawBatteryChart(log) {
|
||||
lastBatteryLog = log;
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
if (!log || log.length < 2) {
|
||||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = '';
|
||||
const width = wrap.clientWidth || 440;
|
||||
const height = 180;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||||
canvas.style.borderRadius = '8px';
|
||||
wrap.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const gridColor = themeColor('--border');
|
||||
const mutedColor = themeColor('--text-muted');
|
||||
const accentColor = themeColor('--accent');
|
||||
|
||||
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
|
||||
const times = log.map((p) => p[0]);
|
||||
const minT = Math.min(...times);
|
||||
const maxT = Math.max(...times);
|
||||
const spanT = Math.max(1, maxT - minT);
|
||||
|
||||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||||
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'left';
|
||||
[0, 25, 50, 75, 100].forEach((pct) => {
|
||||
const yy = y(pct);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, yy);
|
||||
ctx.lineTo(width - pad.right, yy);
|
||||
ctx.stroke();
|
||||
ctx.fillText(String(pct), 2, yy + 3);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
log.forEach((p, i) => {
|
||||
const px = x(p[0]);
|
||||
const py = y(p[1]);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
|
||||
}
|
||||
|
||||
async function loadBatteryLog() {
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/battery-log`);
|
||||
if (!resp.ok) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drawBatteryChart(data.log);
|
||||
} catch (e) {
|
||||
wrap.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Shared plumbing for every page: CSRF-injecting fetch, theme toggle,
|
||||
// sidebar toggle (mobile), and small formatting helpers. No framework,
|
||||
// no build step -- plain scripts, load order handled by <script> tags.
|
||||
|
||||
// Session-cookie auth needs CSRF proof on mutating requests. Wrapping
|
||||
// fetch once means no call site has to remember the header. The token
|
||||
// rides a <meta> tag emitted only for session-authed pages.
|
||||
(function () {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!meta || !meta.content) return;
|
||||
var CSRF = meta.content;
|
||||
var origFetch = window.fetch;
|
||||
window.fetch = function (input, init) {
|
||||
init = init || {};
|
||||
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
|
||||
var url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
|
||||
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
|
||||
init.headers = new Headers(init.headers || (input && input.headers) || {});
|
||||
init.headers.set('X-CSRF-Token', CSRF);
|
||||
}
|
||||
return origFetch.call(this, input, init);
|
||||
};
|
||||
})();
|
||||
|
||||
// Theme toggle: explicit choice wins over the OS preference and is
|
||||
// remembered; with no explicit choice, CSS falls back to
|
||||
// prefers-color-scheme on its own. (The pre-paint snippet in the page
|
||||
// <head> applies the stored theme before first render.)
|
||||
(function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function currentTheme() {
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var theme = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile sidebar: hamburger opens, backdrop or navigation closes.
|
||||
(function () {
|
||||
var shell = document.querySelector('.shell');
|
||||
var toggle = document.getElementById('sidebar-toggle');
|
||||
var backdrop = document.querySelector('.sidebar-backdrop');
|
||||
if (!shell || !toggle) return;
|
||||
toggle.addEventListener('click', function () { shell.classList.toggle('sidebar-open'); });
|
||||
if (backdrop) {
|
||||
backdrop.addEventListener('click', function () { shell.classList.remove('sidebar-open'); });
|
||||
}
|
||||
})();
|
||||
|
||||
function showStatus(ok, message) {
|
||||
var el = document.getElementById('result');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
|
||||
el.firstChild.textContent = message;
|
||||
}
|
||||
|
||||
// A 409 from a control-gated endpoint means someone else holds the
|
||||
// frame's control lock -- surface who, plus how to take over.
|
||||
async function apiError(resp) {
|
||||
var text = await resp.text();
|
||||
try {
|
||||
var body = JSON.parse(text);
|
||||
var detail = body.detail !== undefined ? body.detail : body;
|
||||
if (detail && detail.error === 'not_controller') {
|
||||
var holder = detail.holder || 'Someone else';
|
||||
return holder + ' has control of this frame — use "Take control" to make changes.';
|
||||
}
|
||||
if (typeof detail === 'string') return detail;
|
||||
} catch (e) { /* not JSON */ }
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
var d = Math.floor(seconds / 86400);
|
||||
var h = Math.floor((seconds % 86400) / 3600);
|
||||
var m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
return m + 'm';
|
||||
}
|
||||
|
||||
// Reads resolved colors from CSS custom properties rather than
|
||||
// hardcoding hex values, so canvas drawing matches the current theme.
|
||||
function themeColor(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Configuration tab: frame settings + firmware card + take control.
|
||||
// window.FRAME_API is set by the template. Checkboxes are always sent
|
||||
// explicitly as "true"/"false" -- the server treats absent fields as
|
||||
// "leave unchanged", so a checkbox must never be simply omitted.
|
||||
|
||||
async function saveConfig() {
|
||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||
const body = new URLSearchParams({
|
||||
name: document.getElementById('frame_name').value || '',
|
||||
order: document.getElementById('order').value,
|
||||
orientation: document.getElementById('orientation').value,
|
||||
refresh_interval_s: String(minutes * 60),
|
||||
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
timezone: document.getElementById('timezone').value || 'UTC',
|
||||
});
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await saveConfig();
|
||||
showStatus(true, 'Saved.');
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'You have control now.');
|
||||
loadControl();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControl() {
|
||||
const banner = document.getElementById('control-banner');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||||
const data = await resp.json();
|
||||
if (data.control && !data.control.you) {
|
||||
banner.style.display = 'flex';
|
||||
document.getElementById('control-holder').textContent = data.control.controller
|
||||
? `${data.control.controller} currently has control of this frame.`
|
||||
: 'Nobody has control of this frame yet.';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
} catch (e) { /* banner is best-effort */ }
|
||||
}
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
// ---- Firmware card ----
|
||||
|
||||
document.getElementById('firmware-upload').addEventListener('click', async () => {
|
||||
const input = document.getElementById('firmware-file');
|
||||
if (!input.files.length) {
|
||||
showStatus(false, 'Pick a firmware .bin first.');
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
function showRepoDisplayMode(url) {
|
||||
document.getElementById('firmware-repo-text').textContent = url;
|
||||
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
|
||||
document.getElementById('firmware-repo-display').style.display = 'none';
|
||||
document.getElementById('firmware-repo-edit').style.display = 'block';
|
||||
document.getElementById('firmware_update_repo_url').focus();
|
||||
});
|
||||
|
||||
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
|
||||
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
|
||||
});
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved.');
|
||||
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFirmwareCheck(force) {
|
||||
const statusEl = document.getElementById('firmware-gitea-status');
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
const boardEl = document.getElementById('firmware-board');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
|
||||
if (!resp.ok) {
|
||||
if (force) {
|
||||
showStatus(false, await apiError(resp));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.board) {
|
||||
boardEl.textContent = `Detected board: ${data.board}`;
|
||||
}
|
||||
if (!data.enabled) {
|
||||
statusEl.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
if (force) {
|
||||
showStatus(false, 'No Gitea repo URL configured.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
statusEl.style.display = 'block';
|
||||
if (!data.board) {
|
||||
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
|
||||
btn.style.display = 'none';
|
||||
} else if (data.update_available) {
|
||||
statusEl.textContent = `Update available: v${data.latest_version}.`;
|
||||
btn.style.display = 'inline-block';
|
||||
} else if (data.latest_version) {
|
||||
statusEl.textContent = `Up to date (v${data.latest_version}).`;
|
||||
btn.style.display = 'none';
|
||||
} else {
|
||||
statusEl.textContent = 'No releases found yet.';
|
||||
btn.style.display = 'none';
|
||||
}
|
||||
if (force) {
|
||||
showStatus(true, 'Checked.');
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed passive poll is silent; an explicit "Check now" click
|
||||
// still surfaces the error.
|
||||
if (force) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
|
||||
|
||||
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('firmware-update-btn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const result = await resp.json();
|
||||
document.getElementById('firmware-available').textContent =
|
||||
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
|
||||
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
|
||||
loadFirmwareCheck();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadControl();
|
||||
loadFirmwareCheck();
|
||||
// The server throttles actual Gitea API calls itself, so this poll is
|
||||
// cheap either way.
|
||||
setInterval(loadFirmwareCheck, 60000);
|
||||
@@ -0,0 +1,126 @@
|
||||
// Photos tab: now-displaying, album picker, and the upcoming grid
|
||||
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
|
||||
// template.
|
||||
|
||||
function renderControlBanner(control) {
|
||||
const banner = document.getElementById('control-banner');
|
||||
if (!banner) return;
|
||||
if (!control || control.you) {
|
||||
banner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
banner.style.display = 'flex';
|
||||
document.getElementById('control-holder').textContent = control.controller
|
||||
? `${control.controller} currently has control of this frame.`
|
||||
: 'Nobody has control of this frame yet.';
|
||||
}
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'You have control now.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
if (dragState) {
|
||||
return; // don't yank the grid out from under an in-progress drag
|
||||
}
|
||||
const currentEl = document.getElementById('current-thumb');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) {
|
||||
currentEl.innerHTML =
|
||||
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
|
||||
renderUpcoming([]);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
currentEl.innerHTML = '';
|
||||
if (data.current) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'thumb-wrap';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb';
|
||||
img.src = data.current.thumbnail_url;
|
||||
img.alt = '';
|
||||
wrap.appendChild(img);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
|
||||
wrap.appendChild(removeBtn);
|
||||
|
||||
currentEl.appendChild(wrap);
|
||||
} else {
|
||||
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
|
||||
}
|
||||
renderControlBanner(data.control);
|
||||
renderUpcoming(data.upcoming);
|
||||
} catch (e) {
|
||||
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function savePhotoSettings() {
|
||||
const body = new URLSearchParams({
|
||||
album_id: document.getElementById('album_id').value || '',
|
||||
queue_target_len: document.getElementById('queue_target_len').value,
|
||||
});
|
||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('load-albums').addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/albums`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
const albums = await resp.json();
|
||||
|
||||
const select = document.getElementById('album_id');
|
||||
select.innerHTML = '';
|
||||
for (const a of albums) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.name} (${a.count})`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('photos-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await savePhotoSettings();
|
||||
showStatus(true, 'Saved.');
|
||||
loadQueue();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
|
||||
loadQueue();
|
||||
// Slow poll: picks up real changes (new photo displayed, queue edited
|
||||
// from elsewhere) without a manual refresh. Skipped mid-drag.
|
||||
setInterval(loadQueue, 10000);
|
||||
@@ -0,0 +1,118 @@
|
||||
// Stats tab: device status, lifetime counters, battery history chart
|
||||
// (chart logic in battery_chart.js). window.FRAME_API set by template.
|
||||
|
||||
let lastDevice = null;
|
||||
|
||||
function renderDeviceStatus(device) {
|
||||
const el = document.getElementById('device-status');
|
||||
el.innerHTML = '';
|
||||
if (!device || !device.last_seen) {
|
||||
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
|
||||
return;
|
||||
}
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [];
|
||||
const ago = formatDuration(Math.max(0, now - device.last_seen));
|
||||
rows.push(['Last seen', `${ago} ago`, device.overdue]);
|
||||
if (device.firmware_version) {
|
||||
let fw = `v${device.firmware_version}`;
|
||||
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
|
||||
fw += ` (v${device.firmware_available} waiting)`;
|
||||
}
|
||||
rows.push(['Firmware', fw, false]);
|
||||
}
|
||||
if (device.battery) {
|
||||
rows.push(['Battery', `${device.battery.percent}%`, false]);
|
||||
}
|
||||
if (device.on_battery_since) {
|
||||
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
|
||||
}
|
||||
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
|
||||
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
|
||||
}
|
||||
for (const [label, value, alert] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
if (alert) {
|
||||
p.style.color = 'var(--danger-text)';
|
||||
p.style.fontWeight = '600';
|
||||
}
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevice() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
lastDevice = data.device;
|
||||
renderDeviceStatus(data.device);
|
||||
} catch (e) { /* retried on the next poll */ }
|
||||
}
|
||||
|
||||
function renderStats(stats) {
|
||||
const el = document.getElementById('stats-box');
|
||||
el.innerHTML = '';
|
||||
const now = Date.now() / 1000;
|
||||
const rows = [
|
||||
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
|
||||
['Wake cycles', stats.device_wakes],
|
||||
['Photos displayed', stats.photos_displayed],
|
||||
['Photos removed from rotation', stats.photos_removed],
|
||||
['Battery reports received', stats.battery_reports],
|
||||
['Battery recharge cycles', stats.recharge_cycles],
|
||||
['OTA updates applied', stats.ota_updates_applied],
|
||||
['Settings saved', stats.config_saves],
|
||||
];
|
||||
for (const [label, value] of rows) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
p.textContent = `${label}: ${value}`;
|
||||
el.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const el = document.getElementById('stats-box');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/stats`);
|
||||
if (!resp.ok) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
return;
|
||||
}
|
||||
renderStats(await resp.json());
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="sub">Could not load.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
loadDevice();
|
||||
loadStats();
|
||||
loadBatteryLog();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle is used -- canvas pixels
|
||||
// don't repaint themselves the way CSS does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (lastBatteryLog) {
|
||||
drawBatteryChart(lastBatteryLog);
|
||||
}
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast tick: re-renders "Last seen"/"On battery for" from already-
|
||||
// fetched data every second so they count up smoothly without hitting
|
||||
// the server that often.
|
||||
setInterval(() => {
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
setInterval(loadDevice, 10000);
|
||||
@@ -0,0 +1,240 @@
|
||||
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
|
||||
// intact from the original single-page UI -- the Pointer Events state
|
||||
// machine below (hold-to-arm on touch so page scrolling still works) is
|
||||
// battle-tested; treat changes with suspicion.
|
||||
//
|
||||
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
|
||||
// loadQueue() global (frame_photos.js) to refetch authoritative state.
|
||||
|
||||
let upcomingItems = [];
|
||||
|
||||
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
|
||||
// code drives mouse, touch, and pen -- native drag-and-drop is
|
||||
// mouse-only by spec and never fires at all on phones/tablets.
|
||||
//
|
||||
// On touch specifically, a card only "arms" for dragging after a
|
||||
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
|
||||
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
|
||||
// the whole time up to that point, so a normal touch-and-swipe to
|
||||
// scroll the page still works even though it starts on a card. Once
|
||||
// armed, touch-action switches to "none" for the rest of that touch
|
||||
// so drag tracking gets every pointermove reliably. Mouse skips the
|
||||
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
|
||||
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
|
||||
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
|
||||
const DRAG_HOLD_MS = 250;
|
||||
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
|
||||
|
||||
function vibrate(ms) {
|
||||
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
|
||||
// it's a harmless no-op everywhere else rather than needing a
|
||||
// feature check at every call site.
|
||||
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function clearDragOverStyling() {
|
||||
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function endDrag(card) {
|
||||
if (dragState && dragState.holdTimer) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
}
|
||||
card.style.touchAction = '';
|
||||
card.style.transform = '';
|
||||
card.classList.remove('dragging', 'drag-armed');
|
||||
clearDragOverStyling();
|
||||
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
|
||||
vibrate(15);
|
||||
moveItem(dragState.fromIndex, dragState.toIndex);
|
||||
}
|
||||
dragState = null;
|
||||
}
|
||||
|
||||
function renderUpcoming(items) {
|
||||
upcomingItems = items;
|
||||
const grid = document.getElementById('upcoming-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card';
|
||||
card.dataset.index = String(i);
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = item.thumbnail_url;
|
||||
img.alt = '';
|
||||
card.appendChild(img);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = String(i + 1);
|
||||
card.appendChild(badge);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.title = 'Remove from rotation';
|
||||
removeBtn.textContent = '×';
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
removeAsset(item.id);
|
||||
});
|
||||
card.appendChild(removeBtn);
|
||||
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.type = 'button';
|
||||
nextBtn.className = 'show-next';
|
||||
nextBtn.textContent = 'Show next';
|
||||
nextBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
showNext(i);
|
||||
});
|
||||
card.appendChild(nextBtn);
|
||||
|
||||
card.addEventListener('pointerdown', (e) => {
|
||||
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
|
||||
return; // let the button's own click handler run, don't start a drag
|
||||
}
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) {
|
||||
return; // left button only
|
||||
}
|
||||
|
||||
dragState = {
|
||||
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
|
||||
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
|
||||
};
|
||||
|
||||
if (e.pointerType === 'touch') {
|
||||
dragState.holdTimer = setTimeout(() => {
|
||||
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
|
||||
dragState.armed = true;
|
||||
card.classList.add('drag-armed');
|
||||
card.style.touchAction = 'none';
|
||||
vibrate(10);
|
||||
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
|
||||
}
|
||||
}, DRAG_HOLD_MS);
|
||||
} else {
|
||||
card.setPointerCapture(e.pointerId);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', (e) => {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.armed) {
|
||||
// Still deciding whether this is a hold-to-drag or a scroll --
|
||||
// moving this much before the hold timer fires means scroll;
|
||||
// bail out and let the browser's native pan-y handle it.
|
||||
const dx0 = e.clientX - dragState.startX;
|
||||
const dy0 = e.clientY - dragState.startY;
|
||||
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
|
||||
clearTimeout(dragState.holdTimer);
|
||||
dragState = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dy = e.clientY - dragState.startY;
|
||||
|
||||
if (!dragState.moved) {
|
||||
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
dragState.moved = true;
|
||||
card.classList.remove('drag-armed');
|
||||
card.classList.add('dragging');
|
||||
}
|
||||
|
||||
// Follows the finger 1:1 -- the actual "pick it up and carry it"
|
||||
// feedback.
|
||||
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
|
||||
|
||||
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
|
||||
clearDragOverStyling();
|
||||
if (overCard && overCard !== card) {
|
||||
overCard.classList.add('drag-over');
|
||||
dragState.toIndex = Number(overCard.dataset.index);
|
||||
} else {
|
||||
dragState.toIndex = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener('pointerup', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
card.addEventListener('pointercancel', (e) => {
|
||||
if (dragState && dragState.pointerId === e.pointerId) {
|
||||
endDrag(card);
|
||||
}
|
||||
});
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function moveItem(fromIndex, toIndex) {
|
||||
const items = upcomingItems.slice();
|
||||
const [moved] = items.splice(fromIndex, 1);
|
||||
items.splice(toIndex, 0, moved);
|
||||
renderUpcoming(items);
|
||||
persistOrder(items);
|
||||
}
|
||||
|
||||
async function showNext(index) {
|
||||
const assetId = upcomingItems[index].id;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue(); // always refetch the authoritative order rather than guessing locally
|
||||
}
|
||||
|
||||
async function removeAsset(assetId) {
|
||||
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
async function persistOrder(items) {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ queue: items.map((item) => item.id) }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(await apiError(resp));
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
loadQueue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f1f4;
|
||||
--border: #e3e5e9;
|
||||
--text: #16181d;
|
||||
--text-muted: #666d7a;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--focus-ring: rgba(37, 99, 235, 0.35);
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--danger-bg: #fee2e2;
|
||||
--danger-text: #991b1b;
|
||||
--warn-bg: #fef9c3;
|
||||
--warn-text: #854d0e;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(153, 27, 27, 0.85);
|
||||
--color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: var(--color-scheme);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background-color .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 72px;
|
||||
}
|
||||
.page.page-narrow {
|
||||
max-width: 420px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.brand-mark {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
|
||||
|
||||
.icon-btn {
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-alt); }
|
||||
.icon-btn:active { transform: scale(0.94); }
|
||||
|
||||
.topbar-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
|
||||
.topnav a { color: var(--text-muted); text-decoration: none; }
|
||||
.topnav a:hover { color: var(--text); }
|
||||
.inline-form { display: inline; margin: 0; }
|
||||
button.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.linklike:hover { color: var(--text); background: none; }
|
||||
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
.admin-actions form { margin: 4px 0 0; }
|
||||
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
|
||||
.admin-actions input[type="password"] { margin-top: 6px; }
|
||||
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
|
||||
.admin-frame:last-child { border-bottom: none; }
|
||||
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||||
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
|
||||
|
||||
h2.card-title, summary.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
summary.card-title { cursor: pointer; margin-bottom: 0; }
|
||||
details.card[open] summary.card-title { margin-bottom: 14px; }
|
||||
details.card .sub { margin-top: 8px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background-color .15s ease, transform .1s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.btn-inline {
|
||||
margin-top: 0;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-right: 8px;
|
||||
}
|
||||
button.secondary:hover { background: var(--surface-alt); }
|
||||
|
||||
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
|
||||
.status.ok { background: var(--success-bg); color: var(--success-text); }
|
||||
.status.err { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.info-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
||||
|
||||
code {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
|
||||
|
||||
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
|
||||
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.photo-card {
|
||||
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
|
||||
background: var(--surface-alt); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||
when you touch a card without meaning to drag it. Dragging on
|
||||
touch instead requires a brief hold first (see the JS below),
|
||||
which switches this to "none" for the rest of that touch --
|
||||
only once we're sure it's a deliberate drag, not a scroll. */
|
||||
touch-action: pan-y;
|
||||
/* Without this, a press-and-drag gesture also triggers the
|
||||
browser's native text/content selection (the blue highlight) --
|
||||
distracting, and on some browsers it fights the pointer-based
|
||||
drag tracking below closely enough to break it outright. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.drag-armed {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring) inset;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
/* No transform transition here -- the JS drives transform on every
|
||||
pointermove to track the finger 1:1, and the .15s base transition
|
||||
would otherwise make it visibly lag behind a fast swipe. */
|
||||
.photo-card.dragging {
|
||||
z-index: 20;
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
|
||||
transition: box-shadow .15s ease;
|
||||
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
|
||||
cursor: grabbing;
|
||||
}
|
||||
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
|
||||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
|
||||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||||
background: var(--overlay); color: white;
|
||||
}
|
||||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
|
||||
.photo-card .show-next {
|
||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||
}
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.main-col, .side-col { display: flex; flex-direction: column; }
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* App shell: left sidebar (frame list + account nav) + main content. */
|
||||
/* Used by app_base.html for all logged-in pages; the narrow auth/ */
|
||||
/* manage pages keep the simple centered .page layout above. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 20px 14px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 18px; }
|
||||
.sidebar .brand h1 { font-size: 17px; }
|
||||
.sidebar-section {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
padding: 14px 8px 6px;
|
||||
}
|
||||
.sidebar a.nav-item, .sidebar .nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: background-color .12s ease;
|
||||
}
|
||||
.sidebar a.nav-item:hover { background: var(--surface-alt); }
|
||||
.sidebar a.nav-item.active { background: var(--surface-alt); font-weight: 600; }
|
||||
.nav-item .frame-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex: none;
|
||||
background: var(--text-muted); opacity: 0.5;
|
||||
}
|
||||
.nav-item.online .frame-dot { background: #22c55e; opacity: 1; }
|
||||
.nav-item .nav-sub { margin-left: auto; font-size: 11.5px; color: var(--text-muted); }
|
||||
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.sidebar-footer .nav-item { color: var(--text-muted); font-size: 13.5px; }
|
||||
.sidebar-footer form { margin: 0; }
|
||||
.sidebar-footer button.linklike {
|
||||
display: block; width: 100%; text-align: left; padding: 9px 10px; border-radius: 8px;
|
||||
}
|
||||
.sidebar-footer button.linklike:hover { background: var(--surface-alt); }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px 72px;
|
||||
max-width: 1160px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
.page-head .head-actions { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tabs a {
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color .12s ease;
|
||||
}
|
||||
.tabs a:hover { color: var(--text); }
|
||||
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
|
||||
.control-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.control-banner button { margin: 0; }
|
||||
|
||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||
.mobile-bar { display: none; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
@media (max-width: 860px) {
|
||||
.mobile-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.mobile-bar .brand { display: flex; align-items: center; gap: 8px; }
|
||||
.mobile-bar h1 { font-size: 16px; margin: 0; }
|
||||
.mobile-bar .icon-btn { width: 34px; height: 34px; box-shadow: none; }
|
||||
.mobile-bar .spacer { flex: 1; }
|
||||
|
||||
.shell { display: block; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
z-index: 50;
|
||||
height: 100vh;
|
||||
transform: translateX(-105%);
|
||||
transition: transform .2s ease;
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
.shell.sidebar-open .sidebar { transform: translateX(0); }
|
||||
.sidebar-backdrop {
|
||||
position: fixed; inset: 0; z-index: 40;
|
||||
background: var(--overlay);
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
.shell.sidebar-open .sidebar-backdrop { display: block; opacity: 1; pointer-events: auto; }
|
||||
.sidebar-backdrop { display: block; }
|
||||
|
||||
.main { padding: 18px 14px 64px; }
|
||||
}
|
||||
Reference in New Issue
Block a user