Adds a web manifest, hand-drawn cup+frame icons, and a presence-only service worker (no offline caching) so mobile browsers offer "Add to Home Screen" for the server UI.
120 lines
5.0 KiB
JavaScript
120 lines
5.0 KiB
JavaScript
// 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'); });
|
|
}
|
|
})();
|
|
|
|
// Registering this is what makes Chrome/Android offer the "Add to Home
|
|
// screen" install prompt -- a manifest link alone isn't enough. Served
|
|
// from /sw.js (not /static/sw.js) so its scope is the whole app.
|
|
if ("serviceWorker" in navigator) {
|
|
window.addEventListener("load", function () {
|
|
navigator.serviceWorker.register("/sw.js");
|
|
});
|
|
}
|
|
|
|
// Shared display names for widget_type, everywhere one shows up in the
|
|
// UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns).
|
|
const WIDGET_LABELS = {
|
|
photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks',
|
|
static: 'Static image', text: 'Text', weather: 'Weather', battery: 'Battery',
|
|
};
|
|
|
|
function showStatus(ok, message) {
|
|
// While a <dialog> is open, its own .dialog-result container gets the
|
|
// message instead of the page-level #result -- otherwise it lands
|
|
// behind the dialog's backdrop, invisible until the dialog closes
|
|
// (e.g. the widget config dialogs on the Layout tab, see
|
|
// frame_layout.js). Falls back to #result for everything else.
|
|
var openDialog = document.querySelector('dialog[open]');
|
|
var el = (openDialog && openDialog.querySelector('.dialog-result')) || 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();
|
|
}
|