mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
feat(web): Schedule and General become ES-module pages (stage 3) (#767)
* feat(web): Schedule and General become ES-module pages (stage 3) Schedule and General follow stage 2 (#727): no inline scripts or inline handlers in either partial. Their code moves to static/v3/js/pages/schedule.js and pages/general.js, started per swap-in by the page registry. - Schedule: both pickers are drawn from the saved config carried as JSON in data-* attributes. The forms' hx-on save handlers become one htmx:afterRequest listener on the page; the forms are marked data-reports-result, which app.js now treats like an hx-on after-request handler, so a save still shows one notification. - General: the timezone picker reads data-timezone. The Security section's forms and buttons are delegated data-actions; requests go through core/api.js, so the login redirect is quiet, and a change made just before a swap is still reported. - handleScheduleResponse, handleDimScheduleResponse and webLogin stay as deprecated aliases through window.LEDMatrix. - New DOM suites test_schedule_page.js and test_general_page.js; the web login unit suite imports the module; test_es_modules.py pins the pages, the aliases, and the schedule config's round trip through its attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): no unused catch bindings or computed writes in the stage 3 modules Codacy flagged two unused catch variables and dynamic-key writes in pages/schedule.js and boot.js. The schedule config is read with getAttribute, and the default days and the webLogin alias object are built with Object.fromEntries. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,11 +57,13 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
// Show the server's message, unless the element that made the request
|
||||
// (or its form) has its own after-request handler: every such handler in
|
||||
// the templates reports the result itself, and this used to repeat it,
|
||||
// so each save showed two toasts.
|
||||
// so each save showed two toasts. A form whose page module reports the
|
||||
// result instead (a js/pages/*.js listener for htmx:afterRequest) is
|
||||
// marked data-reports-result.
|
||||
const response = event.detail.xhr;
|
||||
const elt = event.detail.elt;
|
||||
const reportsItself = elt && elt.closest &&
|
||||
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request]');
|
||||
elt.closest('[hx-on\\:\\:after-request], [hx-on\\:htmx\\:after-request], [data-reports-result]');
|
||||
if (!reportsItself && response && response.responseText) {
|
||||
try {
|
||||
const data = JSON.parse(response.responseText);
|
||||
|
||||
@@ -43,18 +43,33 @@ const pages = {
|
||||
'operation-history': page(function() { return import('../pages/operation-history.js'); }),
|
||||
'raw-json': page(function() { return import('../pages/raw-json.js'); }),
|
||||
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
|
||||
'schedule': page(function() { return import('../pages/schedule.js'); }),
|
||||
'general': page(function() { return import('../pages/general.js'); }),
|
||||
};
|
||||
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
|
||||
|
||||
/** Keep window[name] working: forward to the page module's export of the same name. */
|
||||
function alias(pageName, name, replacement) {
|
||||
/** A function that calls `pick(module)` with its arguments, loading the page's module if needed. */
|
||||
function forward(pageName, pick) {
|
||||
const loader = pages[pageName];
|
||||
facade.deprecate(name, function() {
|
||||
return function() {
|
||||
const args = arguments;
|
||||
const module = loader.loaded();
|
||||
if (module) return module[name].apply(null, args);
|
||||
return loader().then(function(loaded) { return loaded[name].apply(null, args); });
|
||||
}, replacement);
|
||||
if (module) return pick(module).apply(null, args);
|
||||
return loader().then(function(loaded) { return pick(loaded).apply(null, args); });
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep window[name] working: forward to the page module's export of the same name. */
|
||||
function alias(pageName, name, replacement) {
|
||||
facade.deprecate(name, forward(pageName, function(module) { return module[name]; }), replacement);
|
||||
}
|
||||
|
||||
/** The same for an old global object of functions: each method forwards to the export's method. */
|
||||
function aliasObject(pageName, name, methods, replacement) {
|
||||
const target = Object.fromEntries(methods.map(function(method) {
|
||||
return [method, forward(pageName, function(module) { return module[name][method]; })];
|
||||
}));
|
||||
facade.deprecate(name, Object.freeze(target), replacement);
|
||||
}
|
||||
|
||||
// Old globals the converted pages used to define.
|
||||
@@ -65,5 +80,10 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
|
||||
['exportBackup', 'loadBackupList', 'validateRestoreFile', 'clearRestore', 'runRestore'].forEach(function(name) {
|
||||
alias('backup-restore', name, "the Backup & Restore tab's buttons");
|
||||
});
|
||||
['handleScheduleResponse', 'handleDimScheduleResponse'].forEach(function(name) {
|
||||
alias('schedule', name, "the Schedule tab's own save handling");
|
||||
});
|
||||
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
|
||||
"the General tab's Security buttons");
|
||||
|
||||
registry.start();
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* pages/general.js -- the General tab (templates/v3/partials/general.html).
|
||||
*
|
||||
* The settings form stays plain htmx (hx-post to /api/v3/config/main; its
|
||||
* hx-on calls the shared showSaveResult). This module:
|
||||
* - draws the timezone-selector widget into #timezone_container, with the
|
||||
* saved zone from its data-timezone attribute
|
||||
* - runs the optional Security section (web login, #683): three forms and
|
||||
* two kinds of button, all marked data-action and handled by one
|
||||
* delegated submit and one delegated click listener on the page root, so
|
||||
* a token row added later needs no listener of its own
|
||||
*
|
||||
* Login changes are writes: they are not cancelled when the page is swapped
|
||||
* away, so the server always finishes and the result is still reported, but
|
||||
* nothing is drawn into a page that has gone. Token names reach the page as
|
||||
* text.
|
||||
*
|
||||
* window.webLogin (setPassword, disable, createToken, copyToken, revoke) is a
|
||||
* deprecated alias made in core/boot.js; it forwards to the webLogin export
|
||||
* at the bottom of this file.
|
||||
*/
|
||||
|
||||
const API = '/api/v3/auth';
|
||||
const RETRY_MS = 50;
|
||||
const MAX_TRIES = 200;
|
||||
|
||||
// The mounted page, for the deprecated alias (boot.js).
|
||||
let active = null;
|
||||
|
||||
function windowOf(ctx) {
|
||||
return ctx.root.ownerDocument.defaultView;
|
||||
}
|
||||
|
||||
function find(ctx, id) {
|
||||
return ctx.root.querySelector('#' + id);
|
||||
}
|
||||
|
||||
function field(form, name) {
|
||||
const el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A login request. Resolves to {ok, d}: d is the JSON body, also for a
|
||||
* refused request (its message is shown). A network failure rejects; the
|
||||
* optional login's own "sign in again" answer resolves to null, since the
|
||||
* page is already on its way to the login form.
|
||||
*/
|
||||
function send(ctx, method, url, body) {
|
||||
return ctx.api.request(method, url, body === undefined ? {} : { json: body }).then(function(d) {
|
||||
return { ok: true, d: d };
|
||||
}, function(err) {
|
||||
if (err && err.loginRequired) return null;
|
||||
// Only an HTTP answer is a refusal; anything else is a failure.
|
||||
if (!err || err.name !== 'ApiError' || err.network || !err.status) throw err;
|
||||
return { ok: false, d: err.body && typeof err.body === 'object' ? err.body : {} };
|
||||
});
|
||||
}
|
||||
|
||||
function failed(ctx) {
|
||||
return function(err) { ctx.notify('Request failed: ' + ((err && err.message) || err), 'error'); };
|
||||
}
|
||||
|
||||
function reloadSection(ctx) {
|
||||
if (ctx.signal.aborted) return; // already swapped for a fresh copy
|
||||
const win = windowOf(ctx);
|
||||
if (win.htmx) {
|
||||
win.htmx.ajax('GET', '/v3/partials/general', { target: '#general-content', swap: 'innerHTML' });
|
||||
} else {
|
||||
win.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function tokenRow(doc, record) {
|
||||
const row = doc.createElement('div');
|
||||
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
|
||||
row.dataset.tokenId = record.id;
|
||||
const text = doc.createElement('div');
|
||||
text.className = 'text-sm';
|
||||
[['font-semibold text-gray-900', record.name],
|
||||
['font-mono text-gray-600 ml-2', record.prefix + '…'],
|
||||
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
|
||||
const span = doc.createElement('span');
|
||||
span.className = part[0];
|
||||
span.textContent = part[1];
|
||||
text.appendChild(span);
|
||||
});
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'text-sm text-red-600 hover:underline';
|
||||
button.dataset.action = 'revoke-token';
|
||||
button.dataset.tokenName = record.name;
|
||||
button.textContent = 'Revoke';
|
||||
row.appendChild(text);
|
||||
row.appendChild(button);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The password form: set (or change) the password, then redraw the section. */
|
||||
export function setPassword(ctx, form) {
|
||||
const next = field(form, 'new_password');
|
||||
if (next !== field(form, 'confirm_password')) {
|
||||
ctx.notify('The two new passwords do not match.', 'error');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const body = { new_password: next };
|
||||
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
|
||||
return send(ctx, 'POST', API + '/password', body).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection(ctx);
|
||||
return res.ok;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The "Turn login off" form. */
|
||||
export function disableLogin(ctx, form) {
|
||||
if (!windowOf(ctx).confirm('Turn login off? Anyone on your network will be able to open this page.')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return send(ctx, 'POST', API + '/disable', { current_password: field(form, 'current_password') }).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection(ctx);
|
||||
return res.ok;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The "Create token" form: add the row and show the token once. */
|
||||
export function createToken(ctx, form) {
|
||||
return send(ctx, 'POST', API + '/tokens', { name: field(form, 'name') }).then(function(res) {
|
||||
if (!res) return false;
|
||||
if (!res.ok) {
|
||||
ctx.notify(res.d.message || 'Could not create the token', 'error');
|
||||
return false;
|
||||
}
|
||||
const data = res.d.data || {};
|
||||
// app.js marks a form dirty on input and clears the mark only after
|
||||
// an htmx save; this one posts with fetch, so clear it here or a
|
||||
// reload asks "Leave site?" about a saved token.
|
||||
form.reset();
|
||||
form.removeAttribute('data-dirty');
|
||||
if (!ctx.signal.aborted) {
|
||||
const list = find(ctx, 'web-login-tokens');
|
||||
if (list && data.record) {
|
||||
const empty = list.querySelector('[data-empty]');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(tokenRow(ctx.root.ownerDocument, data.record));
|
||||
}
|
||||
const value = find(ctx, 'web-login-new-token-value');
|
||||
const box = find(ctx, 'web-login-new-token');
|
||||
if (value) value.textContent = data.token;
|
||||
if (box) box.classList.remove('hidden');
|
||||
}
|
||||
ctx.notify(res.d.message || 'Token created', 'success');
|
||||
return true;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
/** The Copy button next to a new token. */
|
||||
export function copyToken(ctx) {
|
||||
const win = windowOf(ctx);
|
||||
const box = find(ctx, 'web-login-new-token-value');
|
||||
if (!box) return;
|
||||
if (win.navigator.clipboard && win.isSecureContext) {
|
||||
win.navigator.clipboard.writeText(box.textContent).then(function() { ctx.notify('Token copied', 'success'); });
|
||||
return;
|
||||
}
|
||||
// Plain http on a LAN is not a secure context: select it instead.
|
||||
const range = ctx.root.ownerDocument.createRange();
|
||||
range.selectNodeContents(box);
|
||||
const selection = win.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
ctx.notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
|
||||
}
|
||||
|
||||
/** A token row's Revoke button. */
|
||||
export function revokeToken(ctx, id, name) {
|
||||
if (!windowOf(ctx).confirm('Revoke the token "' + name + '"? Anything using it stops working.')) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return send(ctx, 'DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
|
||||
if (!res) return false;
|
||||
ctx.notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
|
||||
if (!res.ok) return false;
|
||||
ctx.root.querySelectorAll('#web-login-tokens [data-token-id]').forEach(function(row) {
|
||||
if (row.dataset.tokenId === id) row.remove();
|
||||
});
|
||||
return true;
|
||||
}).catch(failed(ctx));
|
||||
}
|
||||
|
||||
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
||||
|
||||
function drawTimezone(root, ctx) {
|
||||
const win = root.ownerDocument.defaultView;
|
||||
let tries = 0;
|
||||
function attempt() {
|
||||
ctx.state.timer = null;
|
||||
if (ctx.signal.aborted) return;
|
||||
const container = root.querySelector('#timezone_container');
|
||||
if (!container) return;
|
||||
const widget = win.LEDMatrixWidgets && win.LEDMatrixWidgets.get('timezone-selector');
|
||||
if (!widget) {
|
||||
if (++tries < MAX_TRIES) ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
||||
else win.console.error('[General] timezone-selector widget not available');
|
||||
return;
|
||||
}
|
||||
// Only render if container is empty (not already rendered)
|
||||
if (container.children.length > 0) return;
|
||||
widget.render(container, {
|
||||
'x-options': { showOffset: true, placeholder: 'Select your timezone...' },
|
||||
}, container.dataset.timezone || 'America/Chicago', {
|
||||
fieldId: 'timezone',
|
||||
name: 'timezone',
|
||||
});
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
|
||||
export function init(root, ctx) {
|
||||
const on = { signal: ctx.signal };
|
||||
root.addEventListener('submit', function(event) {
|
||||
const form = event.target;
|
||||
const action = form && form.dataset ? FORM_ACTIONS[form.dataset.action] : null;
|
||||
if (!action) return; // the settings form: htmx posts it
|
||||
event.preventDefault();
|
||||
action(ctx, form);
|
||||
}, on);
|
||||
root.addEventListener('click', function(event) {
|
||||
const button = event.target.closest('button[data-action]');
|
||||
if (!button || !root.contains(button)) return;
|
||||
if (button.dataset.action === 'copy-token') {
|
||||
copyToken(ctx);
|
||||
} else if (button.dataset.action === 'revoke-token') {
|
||||
const row = button.closest('[data-token-id]');
|
||||
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
||||
}
|
||||
}, on);
|
||||
drawTimezone(root, ctx);
|
||||
active = ctx;
|
||||
}
|
||||
|
||||
export function destroy(root, ctx) {
|
||||
if (ctx.state.timer) {
|
||||
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
||||
ctx.state.timer = null;
|
||||
}
|
||||
if (active === ctx) active = null;
|
||||
}
|
||||
|
||||
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
|
||||
// window.webLogin's methods took the form (or the token's id and name) and
|
||||
// acted on the General tab on screen.
|
||||
export const webLogin = Object.freeze({
|
||||
setPassword: function(form) { return active ? setPassword(active, form) : undefined; },
|
||||
disable: function(form) { return active ? disableLogin(active, form) : undefined; },
|
||||
createToken: function(form) { return active ? createToken(active, form) : undefined; },
|
||||
copyToken: function() { return active ? copyToken(active) : undefined; },
|
||||
revoke: function(id, name) { return active ? revokeToken(active, id, name) : undefined; },
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* pages/schedule.js -- the Schedule tab (templates/v3/partials/schedule.html):
|
||||
* when the display is on, and when it dims.
|
||||
*
|
||||
* Both forms stay plain htmx (hx-post with json-enc). This module:
|
||||
* - draws the two schedule-picker widgets from the saved config the
|
||||
* partial carries in data-schedule-config / data-dim-schedule-config
|
||||
* - reports each form's save in one notification. The forms used to call
|
||||
* window.handleScheduleResponse / handleDimScheduleResponse from an
|
||||
* hx-on attribute; now one htmx:afterRequest listener on the page root
|
||||
* does it, and the forms carry data-reports-result so app.js does not
|
||||
* show the server's message a second time
|
||||
* - keeps the "30%" label next to the dim brightness slider current
|
||||
*
|
||||
* The widget registry (LEDMatrixWidgets) and the schedule-picker widget are
|
||||
* classic deferred scripts and normally load before this module runs; the
|
||||
* short retry covers a page that arrives first anyway.
|
||||
*
|
||||
* The old globals are deprecated aliases made in core/boot.js; they forward
|
||||
* to the exports of the same name at the bottom of this file.
|
||||
*/
|
||||
|
||||
const DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
const RETRY_MS = 100;
|
||||
const MAX_TRIES = 50;
|
||||
|
||||
const PICKERS = [
|
||||
{
|
||||
label: 'Schedule', container: 'schedule_picker_container', data: 'data-schedule-config',
|
||||
fieldId: 'schedule', start: '07:00', end: '23:00',
|
||||
// The display schedule fills in days only when it has no "days" at
|
||||
// all; the dim schedule also when "days" is empty.
|
||||
fillEmptyDays: false,
|
||||
},
|
||||
{
|
||||
label: 'DimSchedule', container: 'dim_schedule_picker_container', data: 'data-dim-schedule-config',
|
||||
fieldId: 'dim_schedule', start: '20:00', end: '07:00',
|
||||
fillEmptyDays: true,
|
||||
},
|
||||
];
|
||||
|
||||
const FORMS = {
|
||||
schedule_form: { saved: 'Schedule settings saved', failed: 'Error saving schedule' },
|
||||
dim_schedule_form: { saved: 'Dim schedule settings saved', failed: 'Error saving dim schedule' },
|
||||
};
|
||||
|
||||
const WIDGET_OPTIONS = { showModeToggle: true, showEnableToggle: true, compactMode: false };
|
||||
|
||||
// The mounted page, for the deprecated aliases (boot.js).
|
||||
let active = null;
|
||||
|
||||
function readConfig(root, attribute) {
|
||||
try {
|
||||
const parsed = JSON.parse(root.getAttribute(attribute) || 'null');
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The saved config, in the shape the schedule-picker widget takes. */
|
||||
export function widgetValue(config, spec) {
|
||||
config = config || {};
|
||||
let mode = 'global';
|
||||
if (config.mode) {
|
||||
// Normalize mode value (handle both 'per_day' and 'per-day')
|
||||
mode = config.mode.replace('-', '_');
|
||||
} else if (config.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
const value = {
|
||||
enabled: config.enabled || false,
|
||||
mode: mode,
|
||||
start_time: config.start_time || spec.start,
|
||||
end_time: config.end_time || spec.end,
|
||||
days: config.days || {},
|
||||
};
|
||||
const noDays = !config.days || (spec.fillEmptyDays && Object.keys(config.days).length === 0);
|
||||
if (noDays) {
|
||||
value.days = Object.fromEntries(DAYS.map(function(day) {
|
||||
return [day, { enabled: true, start_time: spec.start, end_time: spec.end }];
|
||||
}));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** One notification for a schedule form's answer. */
|
||||
function report(notify, xhr, labels) {
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
if (!response || typeof response !== 'object') {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
const message = response.message || (response.status === 'success' ? labels.saved : labels.failed);
|
||||
notify(message, response.status || 'info');
|
||||
}
|
||||
|
||||
function drawPickers(root, ctx) {
|
||||
const win = root.ownerDocument.defaultView;
|
||||
let tries = 0;
|
||||
function attempt() {
|
||||
ctx.state.timer = null;
|
||||
if (ctx.signal.aborted) return;
|
||||
const registry = win.LEDMatrixWidgets;
|
||||
const widget = registry && registry.get('schedule-picker');
|
||||
if (!widget) {
|
||||
if (++tries < MAX_TRIES) {
|
||||
ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
||||
} else {
|
||||
win.console.error(registry
|
||||
? '[Schedule] schedule-picker widget not registered'
|
||||
: '[Schedule] LEDMatrixWidgets registry not available');
|
||||
}
|
||||
return;
|
||||
}
|
||||
PICKERS.forEach(function(spec) {
|
||||
const container = root.querySelector('#' + spec.container);
|
||||
if (!container) {
|
||||
win.console.error('[' + spec.label + '] Container not found');
|
||||
return;
|
||||
}
|
||||
widget.render(container, { 'x-options': WIDGET_OPTIONS },
|
||||
widgetValue(readConfig(root, spec.data), spec), { fieldId: spec.fieldId });
|
||||
});
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
|
||||
export function init(root, ctx) {
|
||||
const on = { signal: ctx.signal };
|
||||
|
||||
root.addEventListener('htmx:afterRequest', function(event) {
|
||||
const form = event.target && event.target.closest ? event.target.closest('form') : null;
|
||||
const labels = form && root.contains(form) ? FORMS[form.id] : null;
|
||||
if (labels && event.detail && event.detail.xhr) report(ctx.notify, event.detail.xhr, labels);
|
||||
}, on);
|
||||
|
||||
const slider = root.querySelector('#dim_brightness');
|
||||
const label = root.querySelector('#dim_brightness_display');
|
||||
if (slider && label) {
|
||||
slider.addEventListener('input', function() { label.textContent = slider.value + '%'; }, on);
|
||||
}
|
||||
|
||||
drawPickers(root, ctx);
|
||||
active = ctx;
|
||||
}
|
||||
|
||||
export function destroy(root, ctx) {
|
||||
if (ctx.state.timer) {
|
||||
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
||||
ctx.state.timer = null;
|
||||
}
|
||||
if (active === ctx) active = null;
|
||||
}
|
||||
|
||||
// ── the old globals, kept as deprecated aliases (boot.js) ────────────────────
|
||||
function notifier(event) {
|
||||
if (active) return active.notify;
|
||||
const win = event && event.target && event.target.ownerDocument
|
||||
? event.target.ownerDocument.defaultView : globalThis;
|
||||
return function(message, type) { return win.showNotification(message, type); };
|
||||
}
|
||||
|
||||
/** window.handleScheduleResponse(event): an htmx:afterRequest event. */
|
||||
export function handleScheduleResponse(event) {
|
||||
report(notifier(event), event.detail.xhr, FORMS.schedule_form);
|
||||
}
|
||||
|
||||
/** window.handleDimScheduleResponse(event): an htmx:afterRequest event. */
|
||||
export function handleDimScheduleResponse(event) {
|
||||
report(notifier(event), event.detail.xhr, FORMS.dim_schedule_form);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
{# No inline script: static/v3/js/pages/general.js draws the timezone picker
|
||||
and runs the Security section's forms and buttons (data-action). The page
|
||||
registry (static/v3/js/core/registry.js) starts it when this root appears
|
||||
and stops it when the partial is swapped away. #}
|
||||
<div data-page="general">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">General Settings</h2>
|
||||
@@ -88,7 +93,7 @@
|
||||
<!-- Timezone -->
|
||||
<div class="form-group" id="setting-general-timezone" data-setting-key="timezone">
|
||||
<label for="timezone" class="block text-sm font-medium text-gray-700">Timezone{{ ui.help_tip('Time zone used for clocks, schedules, and time-based content.\nChoose the zone where the display physically lives so on/off schedules fire at the correct local time.', 'Timezone') }}</label>
|
||||
<div id="timezone_container" class="mt-1"></div>
|
||||
<div id="timezone_container" class="mt-1" data-timezone="{{ main_config.timezone or 'America/Chicago' }}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Legacy scroll frame rate (device-wide). Core scrolling no longer reads it. -->
|
||||
@@ -102,32 +107,6 @@
|
||||
max="200"
|
||||
class="form-control">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function initTimezoneWidget() {
|
||||
if (!window.LEDMatrixWidgets) { setTimeout(initTimezoneWidget, 50); return; }
|
||||
var widget = window.LEDMatrixWidgets.get('timezone-selector');
|
||||
if (!widget) { setTimeout(initTimezoneWidget, 50); return; }
|
||||
var container = document.getElementById('timezone_container');
|
||||
if (!container) return;
|
||||
|
||||
// Only render if container is empty (not already rendered)
|
||||
if (container.children.length > 0) return;
|
||||
|
||||
widget.render(container, {
|
||||
'x-options': { showOffset: true, placeholder: 'Select your timezone...' }
|
||||
}, {{ (main_config.timezone or "America/Chicago")|tojson }}, {
|
||||
fieldId: 'timezone',
|
||||
name: 'timezone'
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTimezoneWidget);
|
||||
} else {
|
||||
setTimeout(initTimezoneWidget, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Location Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-4">
|
||||
@@ -216,7 +195,7 @@
|
||||
the password inputs keep their own labels. #}
|
||||
<label>{% if web_login.enabled %}Change the web interface password{% else %}Web interface password{% endif %}{{ ui.help_tip('Optional. With a password set, every page and API call needs a login or an API token.\nAlways allowed without one: this Pi itself (localhost), and the Wi-Fi setup page while the Pi is in access-point mode.\nForgot it? On the Pi run: sudo python3 ~/LEDMatrix/scripts/reset_web_password.py', 'Web interface password') }}</label>
|
||||
</h3>
|
||||
<form class="space-y-4" onsubmit="window.webLogin.setPassword(this); return false;" autocomplete="off">
|
||||
<form class="space-y-4" data-action="set-password" autocomplete="off">
|
||||
{% if web_login.enabled %}
|
||||
<div>
|
||||
<label for="web-login-current" class="block text-sm font-medium text-gray-700">Current password</label>
|
||||
@@ -251,7 +230,7 @@
|
||||
{% if web_login.enabled %}
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Turn login off</h3>
|
||||
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.disable(this); return false;" autocomplete="off">
|
||||
<form class="flex flex-wrap items-end gap-2" data-action="disable-login" autocomplete="off">
|
||||
<div class="flex-1">
|
||||
<label for="web-login-disable-current" class="block text-sm font-medium text-gray-700">Current password</label>
|
||||
<input type="password" id="web-login-disable-current" name="current_password" required
|
||||
@@ -280,8 +259,7 @@
|
||||
<span class="text-gray-600 ml-2">created {{ (token.created_at or '')[:10] }}</span>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-red-600 hover:underline"
|
||||
data-token-name="{{ token.name }}"
|
||||
onclick="window.webLogin.revoke(this.closest('[data-token-id]').dataset.tokenId, this.dataset.tokenName)">
|
||||
data-action="revoke-token" data-token-name="{{ token.name }}">
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
@@ -289,7 +267,7 @@
|
||||
<p class="text-sm text-gray-600" data-empty>No tokens yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<form class="flex flex-wrap items-end gap-2" onsubmit="window.webLogin.createToken(this); return false;" autocomplete="off">
|
||||
<form class="flex flex-wrap items-end gap-2" data-action="create-token" autocomplete="off">
|
||||
<div class="flex-1">
|
||||
<label for="web-login-token-name" class="block text-sm font-medium text-gray-700">New token name</label>
|
||||
<input type="text" id="web-login-token-name" name="name" required maxlength="60"
|
||||
@@ -304,7 +282,7 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<code id="web-login-new-token-value" class="font-mono break-all bg-white border border-gray-200 rounded-md px-4 py-2 flex-1"></code>
|
||||
<button type="button" class="btn bg-white border border-gray-300 text-gray-700 px-4 py-2 rounded-md"
|
||||
onclick="window.webLogin.copyToken()">
|
||||
data-action="copy-token">
|
||||
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -312,131 +290,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var API = '/api/v3/auth';
|
||||
|
||||
function notify(message, type) {
|
||||
if (typeof window.showNotification === 'function') window.showNotification(message, type);
|
||||
else window.alert(message);
|
||||
}
|
||||
|
||||
function send(method, url, body) {
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
}).then(function(r) {
|
||||
return r.json().catch(function() { return {}; }).then(function(d) { return {ok: r.ok, d: d}; });
|
||||
});
|
||||
}
|
||||
|
||||
function reloadSection() {
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax('GET', '/v3/partials/general', {target: '#general-content', swap: 'innerHTML'});
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function field(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function tokenRow(record) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
|
||||
row.dataset.tokenId = record.id;
|
||||
var text = document.createElement('div');
|
||||
text.className = 'text-sm';
|
||||
[['font-semibold text-gray-900', record.name],
|
||||
['font-mono text-gray-600 ml-2', record.prefix + '…'],
|
||||
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
|
||||
var span = document.createElement('span');
|
||||
span.className = part[0];
|
||||
span.textContent = part[1];
|
||||
text.appendChild(span);
|
||||
});
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'text-sm text-red-600 hover:underline';
|
||||
btn.textContent = 'Revoke';
|
||||
btn.addEventListener('click', function() { window.webLogin.revoke(record.id, record.name); });
|
||||
row.appendChild(text);
|
||||
row.appendChild(btn);
|
||||
return row;
|
||||
}
|
||||
|
||||
window.webLogin = {
|
||||
setPassword: function(form) {
|
||||
var next = field(form, 'new_password');
|
||||
if (next !== field(form, 'confirm_password')) {
|
||||
notify('The two new passwords do not match.', 'error');
|
||||
return;
|
||||
}
|
||||
var body = {new_password: next};
|
||||
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
|
||||
send('POST', API + '/password', body).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection();
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
disable: function(form) {
|
||||
if (!window.confirm('Turn login off? Anyone on your network will be able to open this page.')) return;
|
||||
send('POST', API + '/disable', {current_password: field(form, 'current_password')}).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
|
||||
if (res.ok) reloadSection();
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
createToken: function(form) {
|
||||
send('POST', API + '/tokens', {name: field(form, 'name')}).then(function(res) {
|
||||
if (!res.ok) {
|
||||
notify(res.d.message || 'Could not create the token', 'error');
|
||||
return;
|
||||
}
|
||||
var list = document.getElementById('web-login-tokens');
|
||||
if (list) {
|
||||
var empty = list.querySelector('[data-empty]');
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(tokenRow(res.d.data.record));
|
||||
}
|
||||
document.getElementById('web-login-new-token-value').textContent = res.d.data.token;
|
||||
document.getElementById('web-login-new-token').classList.remove('hidden');
|
||||
form.reset();
|
||||
// app.js marks a form dirty on input and clears the mark only
|
||||
// after an htmx save; this one posts with fetch, so clear it
|
||||
// here or a reload asks "Leave site?" about a saved token.
|
||||
form.removeAttribute('data-dirty');
|
||||
notify(res.d.message || 'Token created', 'success');
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
},
|
||||
copyToken: function() {
|
||||
var box = document.getElementById('web-login-new-token-value');
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(box.textContent).then(function() { notify('Token copied', 'success'); });
|
||||
return;
|
||||
}
|
||||
// Plain http on a LAN is not a secure context: select it instead.
|
||||
var range = document.createRange();
|
||||
range.selectNodeContents(box);
|
||||
var sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
|
||||
},
|
||||
revoke: function(id, name) {
|
||||
if (!window.confirm('Revoke the token "' + name + '"? Anything using it stops working.')) return;
|
||||
send('DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
|
||||
notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
|
||||
if (!res.ok) return;
|
||||
var rows = document.querySelectorAll('#web-login-tokens [data-token-id]');
|
||||
Array.prototype.forEach.call(rows, function(row) {
|
||||
if (row.dataset.tokenId === id) row.remove();
|
||||
});
|
||||
}).catch(function(err) { notify('Request failed: ' + err.message, 'error'); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
{% import 'v3/partials/_macros.html' as ui %}
|
||||
{# No inline script: static/v3/js/pages/schedule.js draws both schedule
|
||||
pickers from the data-*-config attributes, reports each form's save and
|
||||
keeps the dim brightness label current. The page registry
|
||||
(static/v3/js/core/registry.js) starts it when this root appears and stops
|
||||
it when the partial is swapped away. The forms carry data-reports-result so
|
||||
app.js leaves the save notification to the page. #}
|
||||
<div data-page="schedule"
|
||||
data-schedule-config='{{ schedule_config | tojson }}'
|
||||
data-dim-schedule-config='{{ dim_schedule_config | tojson }}'>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Schedule Settings</h2>
|
||||
@@ -12,7 +21,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="handleScheduleResponse(event)"
|
||||
data-reports-result
|
||||
class="space-y-6">
|
||||
|
||||
<!-- Schedule Picker Widget Container -->
|
||||
@@ -41,7 +50,7 @@
|
||||
hx-ext="json-enc"
|
||||
hx-headers='{"Content-Type": "application/json"}'
|
||||
hx-swap="none"
|
||||
hx-on:htmx:after-request="handleDimScheduleResponse(event)"
|
||||
data-reports-result
|
||||
class="space-y-6">
|
||||
|
||||
<!-- Dim Brightness Level -->
|
||||
@@ -56,8 +65,7 @@
|
||||
min="0"
|
||||
max="100"
|
||||
value="{{ dim_schedule_config.dim_brightness | default(30) }}"
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||
oninput="document.getElementById('dim_brightness_display').textContent = this.value + '%'">
|
||||
class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<span id="dim_brightness_display" class="text-sm font-medium text-gray-700 w-12 text-right">
|
||||
{{ dim_schedule_config.dim_brightness | default(30) }}%
|
||||
</span>
|
||||
@@ -77,198 +85,4 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Initialize schedule picker widget when DOM is ready
|
||||
function initSchedulePicker() {
|
||||
const container = document.getElementById('schedule_picker_container');
|
||||
if (!container) {
|
||||
console.error('[Schedule] Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if widget registry is available
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[Schedule] LEDMatrixWidgets registry not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const widget = window.LEDMatrixWidgets.get('schedule-picker');
|
||||
if (!widget) {
|
||||
console.error('[Schedule] schedule-picker widget not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get schedule config from template data (injected by Jinja2)
|
||||
// Default to empty object if null/undefined
|
||||
const scheduleConfig = {{ schedule_config | tojson | safe }} || {};
|
||||
|
||||
// Determine mode: prefer explicit mode, then infer from days, then default to global
|
||||
let mode = 'global';
|
||||
if (scheduleConfig.mode) {
|
||||
// Normalize mode value (handle both 'per_day' and 'per-day')
|
||||
mode = scheduleConfig.mode.replace('-', '_');
|
||||
} else if (scheduleConfig.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
|
||||
// Convert flat config to nested format expected by widget
|
||||
const widgetValue = {
|
||||
enabled: scheduleConfig.enabled || false,
|
||||
mode: mode,
|
||||
start_time: scheduleConfig.start_time || '07:00',
|
||||
end_time: scheduleConfig.end_time || '23:00',
|
||||
days: scheduleConfig.days || {}
|
||||
};
|
||||
|
||||
// If we have per-day data in the old flat format, convert it
|
||||
if (!scheduleConfig.days) {
|
||||
widgetValue.days = {};
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
days.forEach(function(day) {
|
||||
widgetValue.days[day] = {
|
||||
enabled: true,
|
||||
start_time: '07:00',
|
||||
end_time: '23:00'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Render the widget
|
||||
widget.render(container, {
|
||||
'x-options': {
|
||||
showModeToggle: true,
|
||||
showEnableToggle: true,
|
||||
compactMode: false
|
||||
}
|
||||
}, widgetValue, {
|
||||
fieldId: 'schedule'
|
||||
});
|
||||
|
||||
debugLog('[Schedule] Schedule picker widget initialized');
|
||||
}
|
||||
|
||||
// Handle form submission response
|
||||
window.handleScheduleResponse = function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
|
||||
const message = response.message || (response.status === 'success' ? 'Schedule settings saved' : 'Error saving schedule');
|
||||
const type = response.status || 'info';
|
||||
|
||||
// Use global notification function if available
|
||||
window.showNotification(message, type);
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready or if already loaded
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSchedulePicker);
|
||||
} else {
|
||||
// Small delay to ensure widget scripts are loaded
|
||||
setTimeout(initSchedulePicker, 50);
|
||||
}
|
||||
})();
|
||||
|
||||
// Dim Schedule Picker initialization
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function initDimSchedulePicker() {
|
||||
const container = document.getElementById('dim_schedule_picker_container');
|
||||
if (!container) {
|
||||
console.error('[DimSchedule] Container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if widget registry is available
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[DimSchedule] LEDMatrixWidgets registry not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const widget = window.LEDMatrixWidgets.get('schedule-picker');
|
||||
if (!widget) {
|
||||
console.error('[DimSchedule] schedule-picker widget not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get dim schedule config from template data (injected by Jinja2)
|
||||
const dimScheduleConfig = {{ dim_schedule_config | tojson | safe }} || {};
|
||||
|
||||
// Determine mode
|
||||
let mode = 'global';
|
||||
if (dimScheduleConfig.mode) {
|
||||
mode = dimScheduleConfig.mode.replace('-', '_');
|
||||
} else if (dimScheduleConfig.days) {
|
||||
mode = 'per_day';
|
||||
}
|
||||
|
||||
// Convert config to widget format
|
||||
const widgetValue = {
|
||||
enabled: dimScheduleConfig.enabled || false,
|
||||
mode: mode,
|
||||
start_time: dimScheduleConfig.start_time || '20:00',
|
||||
end_time: dimScheduleConfig.end_time || '07:00',
|
||||
days: dimScheduleConfig.days || {}
|
||||
};
|
||||
|
||||
// If no days config, initialize with defaults for dim schedule (20:00-07:00)
|
||||
if (!dimScheduleConfig.days || Object.keys(dimScheduleConfig.days).length === 0) {
|
||||
widgetValue.days = {};
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
days.forEach(function(day) {
|
||||
widgetValue.days[day] = {
|
||||
enabled: true,
|
||||
start_time: '20:00',
|
||||
end_time: '07:00'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Render the widget with a different field ID
|
||||
widget.render(container, {
|
||||
'x-options': {
|
||||
showModeToggle: true,
|
||||
showEnableToggle: true,
|
||||
compactMode: false
|
||||
}
|
||||
}, widgetValue, {
|
||||
fieldId: 'dim_schedule'
|
||||
});
|
||||
|
||||
debugLog('[DimSchedule] Dim schedule picker widget initialized');
|
||||
}
|
||||
|
||||
// Handle dim schedule form submission response
|
||||
window.handleDimScheduleResponse = function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
let response;
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {
|
||||
response = { status: 'error', message: 'Invalid response from server' };
|
||||
}
|
||||
|
||||
const message = response.message || (response.status === 'success' ? 'Dim schedule settings saved' : 'Error saving dim schedule');
|
||||
const type = response.status || 'info';
|
||||
|
||||
// Use global notification function if available
|
||||
window.showNotification(message, type);
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initDimSchedulePicker);
|
||||
} else {
|
||||
setTimeout(initDimSchedulePicker, 100);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user