mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* Add settings tooltips and search to the web UI Help users quickly find settings and understand how each one works. Tooltips: a new delegated controller (static/v3/js/tooltips.js) drives an accessible (i) info tooltip that appears on hover, keyboard focus, and tap. A shared `help_tip` Jinja macro (partials/_macros.html) emits the trigger; the plugin config macro and the core settings partials now surface help text through it. Per the design, the always-visible field help paragraphs are folded into the tooltip to declutter the forms, and the hardware/display settings carry authored detail (default, range, recommendation). Search: a global header search box finds settings across every settings tab — even ones not yet opened — via a lazy client-side index built by scanning the same field markup (static/v3/js/settings-search.js). Selecting a result switches tabs, waits for the field to load, then scrolls to and flashes it. A per-tab filter box hides non-matching fields on the current tab. Plugin settings get tooltips for free by reusing each field's schema `description`; every settings field also gets a stable `setting-<tab>-<key>` anchor id for search navigation. Styling uses the existing --color-* theme vars so light/dark mode both work, and honors prefers-reduced-motion. Adds Flask render smoke tests that assert each settings partial ships tooltips, anchors, and a filter box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address Codacy static-analysis findings in settings JS Refactor the two new modules to clear the flagged patterns without any behavior change: - Build the search dropdown with DOM nodes + textContent instead of innerHTML string concatenation, removing the XSS sinks and the manual escapeHtml helper it needed. - Replace numeric index access (index[i], terms[j], opts[idx], currentResults[i]) with array iteration methods, NodeList.item(), and Array.prototype.at() to clear detect-object-injection. - Use === via a shared termsMatch() helper, optional-catch binding, and drop a useless initial assignment. Verified with ESLint (eslint:recommended + eslint-plugin-security) at zero findings and re-ran the headless-Chromium behavior test (tooltip, per-tab filter, global search navigation) — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Reduce complexity of revealAncestors in settings search Extract isNodeHidden() and revealNode() helpers so revealAncestors drops below the cyclomatic-complexity threshold. No behavior change; verified with the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Resolve remaining Codacy findings in settings search - Validate plugin ids against a strict allowlist (mirroring the server's _SAFE_PLUGIN_ID_RE) before they can appear in a fetch path, so the request URL is never built from unvalidated input (Codacy: user-controlled URL). - Document that the fetched HTML is parsed into an inert document (scripts never run, never inserted into the live DOM) purely to read field text for the search index. - Declare block-scoped locals with const instead of var where they were nested inside conditionals (Codacy: var not at function root). No behavior change; re-verified with ESLint and the headless-Chromium test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Serve the settings search index from the server as JSON Move index building off the client so there is no client-side HTML fetching or DOM parsing (resolves Codacy's variable-fetch and DOMParser flags on the read-only, same-origin index build). - Add GET /v3/settings/search-index (pages_v3.py): renders the settings partials server-side and extracts each field's anchor id, key, label, tooltip, and section with a small stdlib HTMLParser, then caches the result keyed on the installed-plugin set. Parsing the rendered HTML keeps anchor ids identical to the live DOM, so the index cannot drift. - settings-search.js: buildIndex() now does a single fetch of the literal endpoint + .json(); removed the per-partial fetch loop, DOMParser, scanDoc, CORE_TABS, and the plugin-id allowlist. Search, keyboard nav, navigation, and the per-tab filter are unchanged. Net: fewer requests and no client-side HTML parsing. Verified with a new endpoint test in test_web_settings_ui.py (12 pass) and the headless-Chromium test (tooltip, filter, search navigate + flash all green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Address CodeRabbit review on settings search/tooltips - pages_v3: include Durations tab in the search index so setting-durations-* fields are actually indexed - test_web_settings_ui: assert setting-durations-clock is present in the search-index endpoint response - settings-search.js: on index fetch failure, reset buildPromise instead of caching an empty (truthy) index so search can retry - settings-search.js: filterScope returns null (not document) when no tab container matches, and the caller guards, so the per-tab filter can't hide fields across unrelated tabs - settings-search.js: refresh the stale header comment to describe the server-side JSON index flow - app.css: cap #settings-search-results height with overflow-y so the dropdown scrolls instead of overflowing small screens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix stuck search dropdown; add plugin-tab nested-settings filter Global search: - Close the results dropdown on input blur (guarded, with a short delay) so it reliably dismisses when focus leaves — previously it could linger because the only outside-close was a document click that Alpine/HTMX handlers can swallow. - Clear the query text after navigating to a result so refocusing the box doesn't re-open stale results. - Also dismiss on htmx:afterSwap (tab changes / navigation). Per-tab filter (now on plugin tabs too): - Render the shared settings_filter box in the plugin Configuration panel. It auto-wires: the delegated input handler and filterScope already target .plugin-config-tab. - Teach applyTabFilter to reveal matches inside collapsed nested sections (render_nested_section defaults them shut), hide nested-section wrappers with no matches, and restore the original collapsed layout when cleared (only re-collapsing sections the filter itself opened). - Count a visible nested-section as content for its parent heading so the heading isn't hidden while a subsection below still has matches. Adds a plugin-config render test (filter box + nested anchors + tooltips). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Close search dropdown via capture-phase outside-click The dropdown could stay open after clicking away because the only outside-click close was a bubble-phase document listener. The v3 UI is one Alpine app() component full of HTMX/Alpine/widget click handlers; when a click lands inside an element that calls stopPropagation(), the event never bubbles to document and the close never runs. - Replace the bubble-phase document 'click' close with a capture-phase 'pointerdown' listener scoped to #settings-search-wrap. Capture runs before any bubbling stopPropagation can swallow the event, so it always fires; pointerdown also covers touch on the Pi screen. Clicking a result stays inside the wrap, so selection is unaffected. - Guard the debounced input handler so a delayed render can't re-open the box after focus has left (type-then-click-away race). Keeps the existing blur / Escape / htmx:afterSwap closes as secondary paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gZxznuxw8L92FUMBN3Nqz * Fix settings search dropdown not visually closing .hidden has no effect in this app: app.css is a hand-picked utility subset (no Tailwind build step) and never defines .hidden { display: none }. openResults()/closeResults() only toggled the class, so the dropdown stayed rendered (display: block) even once closeResults() ran - confirmed via computed style in a headless browser. Set style.display directly, matching the fallback already used by revealNode()/collapseNode() elsewhere in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
317 lines
12 KiB
HTML
317 lines
12 KiB
HTML
{% import 'v3/partials/_macros.html' as ui %}
|
||
<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>
|
||
<p class="mt-1 text-sm text-gray-600">Configure when the LED matrix display should be active. You can set global hours or customize times for each day of the week.</p>
|
||
</div>
|
||
|
||
{{ ui.settings_filter() }}
|
||
|
||
<form id="schedule_form"
|
||
hx-post="/api/v3/config/schedule"
|
||
hx-ext="json-enc"
|
||
hx-headers='{"Content-Type": "application/json"}'
|
||
hx-swap="none"
|
||
hx-on:htmx:after-request="handleScheduleResponse(event)"
|
||
class="space-y-6">
|
||
|
||
<!-- Schedule Picker Widget Container -->
|
||
<div id="schedule_picker_container"></div>
|
||
|
||
<!-- Submit Button -->
|
||
<div class="flex justify-end">
|
||
<button type="submit"
|
||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||
<i class="fas fa-save mr-2"></i>
|
||
Save Schedule Settings
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Dim Schedule Section -->
|
||
<div class="bg-white rounded-lg shadow p-6 mt-6">
|
||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||
<h2 class="text-lg font-semibold text-gray-900">Dim Schedule Settings</h2>
|
||
<p class="mt-1 text-sm text-gray-600">Configure when the display should automatically dim to a lower brightness. Dimming only applies when the display is ON (active per the schedule above). The dim schedule will not turn the display back on if it's off.</p>
|
||
</div>
|
||
|
||
<form id="dim_schedule_form"
|
||
hx-post="/api/v3/config/dim-schedule"
|
||
hx-ext="json-enc"
|
||
hx-headers='{"Content-Type": "application/json"}'
|
||
hx-swap="none"
|
||
hx-on:htmx:after-request="handleDimScheduleResponse(event)"
|
||
class="space-y-6">
|
||
|
||
<!-- Dim Brightness Level -->
|
||
<div class="form-group bg-gray-50 rounded-lg p-4 mb-4" id="setting-schedule-dim_brightness" data-setting-key="dim_schedule.dim_brightness">
|
||
<label for="dim_brightness" class="block text-sm font-medium text-gray-700 mb-2">
|
||
Dim Brightness Level{{ ui.help_tip('Brightness the display drops to during dim hours (0–100%).\nApplies only while the display is on. Your normal brightness is currently ' ~ normal_brightness ~ '%.', 'Dim Brightness Level') }}
|
||
</label>
|
||
<div class="flex items-center space-x-4">
|
||
<input type="range"
|
||
id="dim_brightness"
|
||
name="dim_brightness"
|
||
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 + '%'">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Dim Schedule Picker Widget Container -->
|
||
<div id="dim_schedule_picker_container"></div>
|
||
|
||
<!-- Submit Button -->
|
||
<div class="flex justify-end">
|
||
<button type="submit"
|
||
class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
||
<i class="fas fa-save mr-2"></i>
|
||
Save Dim Schedule Settings
|
||
</button>
|
||
</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'
|
||
});
|
||
|
||
console.log('[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
|
||
if (typeof window.showNotification === 'function') {
|
||
window.showNotification(message, type);
|
||
} else {
|
||
// Fallback notification
|
||
const colors = {
|
||
success: 'bg-green-500',
|
||
error: 'bg-red-500',
|
||
warning: 'bg-yellow-500',
|
||
info: 'bg-blue-500'
|
||
};
|
||
|
||
const notification = document.createElement('div');
|
||
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
|
||
notification.textContent = message;
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(function() {
|
||
notification.style.transition = 'opacity 0.5s';
|
||
notification.style.opacity = '0';
|
||
setTimeout(function() { notification.remove(); }, 500);
|
||
}, 3000);
|
||
}
|
||
};
|
||
|
||
// 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'
|
||
});
|
||
|
||
console.log('[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
|
||
if (typeof window.showNotification === 'function') {
|
||
window.showNotification(message, type);
|
||
} else {
|
||
// Fallback notification
|
||
const colors = {
|
||
success: 'bg-green-500',
|
||
error: 'bg-red-500',
|
||
warning: 'bg-yellow-500',
|
||
info: 'bg-blue-500'
|
||
};
|
||
|
||
const notification = document.createElement('div');
|
||
notification.className = 'fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ' + (colors[type] || colors.info) + ' text-white';
|
||
notification.textContent = message;
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(function() {
|
||
notification.style.transition = 'opacity 0.5s';
|
||
notification.style.opacity = '0';
|
||
setTimeout(function() { notification.remove(); }, 500);
|
||
}, 3000);
|
||
}
|
||
};
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initDimSchedulePicker);
|
||
} else {
|
||
setTimeout(initDimSchedulePicker, 100);
|
||
}
|
||
})();
|
||
</script>
|