mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(web): harden, polish and optimize the web UI per the Sept 2026 audit (#568)
* fix(web): harden, polish and optimize the web UI per the September 2026 audit Works through docs/archive/WEB_UI_AUDIT_2026-09.md (health 8/20). Implementation integrity (P0) - app.css now defines every utility class the templates and JS use, including .hidden, so the ~145 JS show/hide toggles work. Button reset, and base component rules (.btn, .form-control) wrapped in :where() so utility classes on the same element win. New static-audit test fails when a used utility class has no rule. Accessibility - Focus rings render (the old ring rule referenced undefined variables); one :focus-visible outline everywhere; skip link; labelled nav landmarks. - Shared dialog helper (js/utils/dialog.js): role/aria-modal, focus trap, Escape, focus return, applied to every modal. - Named icon-only buttons and labelled ~70 form fields. - Toasts announced once; errors persist >= 10s; one showNotification. - Captive WiFi page: live region, timeouts, dark mode, 16px inputs. Performance (Pi Zero 2 W) - SSE streams and tab timers pause when hidden or off-tab; the display stream only runs while a preview is visible. app-shell.js deferred. - Widget scripts served as one versioned bundle (/assets/widgets.js): 52 -> 21 script tags, 66 -> 35 requests on first load. - Stdlib gzip fallback when flask-compress is missing: first-load JS/CSS 1358 KB -> 291 KB on the wire. SSE untouched. Theming and responsive - File managers, form fields and Fonts upload on theme tokens; bare inputs themed in dark mode; no more white surfaces. - No horizontal overflow at 375px on any tab; 44px touch targets on coarse pointers; reduced-motion respected; header title truncates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings on #568 - json-file-manager: focus-trap releases kept in a Map (no dynamic property access or delete; no value-returning forEach callback) - notification / schedule-picker: style and day-label lookups via Map - app.js: move the pending-queue assignment out of the expression - diff_viewer / error_handler: named function declarations instead of arrow consts No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: check the OAuth widget ships in the widget bundle base.html no longer tags widget scripts one by one; they load through /assets/widgets.js. Assert the page requests the bundle and the bundle contains google-oauth.js, which is what the test was protecting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): address review feedback on #568 - widget bundle version fingerprints every file (name, mtime_ns, size) - gzip fallback appends Accept-Encoding to an existing Vary header - dialog helper: releasing a non-top dialog no longer moves focus out of the dialog the user is in - labels: file-upload targets its file input; fallback config fields get label for/id pairs; native color input has a fallback name - utility audit also reads class names inside bound :class expressions Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): give the native color-picker input an accessible name CodeRabbit flagged this on PR #568 as an outside-diff finding (never posted inline, so it was missed in the round of fixes that addressed the other 6 review comments). The <input type="color"> only carried a title attribute; screen readers don't reliably announce title, and there's no other label naming the control when showHexInput is false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear Codacy findings in app-shell.js - drop the unused catch binding on the SSE JSON parse - move the pending-notification queue assignment out of the expression No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): contain plugin widgets/ dir and bound style-editor retries From CodeRabbit review on #568 (code that arrived with the main merge): - serve_plugin_widget resolves widgets/ with resolve_under before resolving the manifest script under it, so a symlinked widgets directory can't become the containment base (CWE-22). New test. - style-editor init stops polling after ~10s when the widget never registers and leaves the plain fallback fields in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+654
-70
@@ -26,6 +26,9 @@
|
||||
--color-info-bg: #dbeafe;
|
||||
--color-purple-bg: #f3e8ff;
|
||||
--color-purple-text: #6b21a8;
|
||||
--color-field-bg: #ffffff;
|
||||
--color-field-border: #d1d5db;
|
||||
--color-focus-ring: #2563eb;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 5%);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 10%), 0 1px 2px 0 rgb(0 0 0 / 6%);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 10%), 0 2px 4px -1px rgb(0 0 0 / 6%);
|
||||
@@ -57,6 +60,9 @@
|
||||
--color-info-bg: #1e3a5f;
|
||||
--color-purple-bg: #2e1065;
|
||||
--color-purple-text: #c4b5fd;
|
||||
--color-field-bg: #374151;
|
||||
--color-field-border: #4b5563;
|
||||
--color-focus-ring: #60a5fa;
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 30%);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 40%), 0 1px 2px 0 rgb(0 0 0 / 30%);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 40%), 0 2px 4px -1px rgb(0 0 0 / 30%);
|
||||
@@ -105,6 +111,77 @@ body {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Border utilities (.border, .border-t, ...) set width and style only; this
|
||||
gives them the theme's border color unless a border-* color class says
|
||||
otherwise. */
|
||||
*, ::before, ::after {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Button reset. Markup styles buttons with utility classes (bg-*, px-*,
|
||||
border); without it, any button the classes don't fully style falls back to
|
||||
the browser's gray outset look. Element selector, so every class wins. */
|
||||
:where(button) {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
line-height: inherit;
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:where(button:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Inline code (paths, commands) must not push a row wider than a phone. */
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* One keyboard focus indicator for everything. Pointer clicks don't show it
|
||||
(:focus-visible); components with their own ring keep theirs. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgb(37 99 235 / 25%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::selection {
|
||||
background-color: rgb(96 165 250 / 35%);
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
scrollbar-color: #4b5563 transparent;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: -4rem;
|
||||
z-index: 100;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
@@ -285,10 +362,10 @@ h4 { font-size: 1.125rem; }
|
||||
.hover\:text-gray-700:hover { color: #374151; }
|
||||
.hover\:border-gray-300:hover { border-color: #d1d5db; }
|
||||
|
||||
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
/* Only suppress the outline for pointer focus; keyboard focus keeps it. */
|
||||
.focus\:outline-none:focus:not(:focus-visible) { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
.focus\:ring-2:focus {
|
||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-shadow, 0 0 #0000);
|
||||
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(2px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000);
|
||||
}
|
||||
/* Optimized: Split complex selector onto multiple lines for readability */
|
||||
.focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
|
||||
@@ -353,12 +430,16 @@ a, button, input, select, textarea {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure logs content doesn't cause overflow issues */
|
||||
/* Ensure logs content doesn't cause overflow issues. content-visibility lets
|
||||
the Pi skip layout and paint for the rows scrolled out of view; the
|
||||
intrinsic size keeps the scrollbar stable. */
|
||||
.log-entry {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 1.5rem;
|
||||
}
|
||||
|
||||
/* Ensure proper containment of logs */
|
||||
@@ -398,17 +479,356 @@ a, button, input, select, textarea {
|
||||
/* Logs container responsive height - simplified for better scrolling */
|
||||
@media (max-width: 768px) {
|
||||
#logs-container {
|
||||
height: 400px !important;
|
||||
min-height: 300px !important;
|
||||
height: clamp(260px, 60vh, 480px) !important;
|
||||
height: clamp(260px, 60svh, 480px) !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#logs-container {
|
||||
height: 350px !important;
|
||||
min-height: 250px !important;
|
||||
}
|
||||
}
|
||||
/* ===== Utility layer (fills the Tailwind-style gaps) =====
|
||||
Templates and JS are authored in Tailwind class names, but there is no
|
||||
Tailwind build: this file is the whole stylesheet. Every utility class the
|
||||
markup uses must be defined here or it silently does nothing
|
||||
(test/test_web_static_audit.py enforces this). Values follow Tailwind v3.
|
||||
Keep `.hidden` after the display utilities so JS show/hide wins. */
|
||||
.-translate-y-1\/2 { --tw-translate-y: -50%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.accent-blue-600 { accent-color: #2563eb; }
|
||||
.align-bottom { vertical-align: bottom; }
|
||||
.appearance-none { appearance: none; -webkit-appearance: none; }
|
||||
.backdrop-blur-sm { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
.bg-amber-100 { --tw-bg-opacity: 1; background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-amber-50 { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-amber-500\/10 { background-color: rgb(245 158 11 / 0.1); }
|
||||
.bg-amber-600 { --tw-bg-opacity: 1; background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-black { --tw-bg-opacity: 1; background-color: rgb(0 0 0 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-blue-100 { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-blue-50 { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-300 { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-400 { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-500 { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-gray-600 { --tw-bg-opacity: 1; background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-green-50 { --tw-bg-opacity: 1; background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-opacity-20 { --tw-bg-opacity: 0.2; }
|
||||
.bg-opacity-70 { --tw-bg-opacity: 0.7; }
|
||||
.bg-opacity-75 { --tw-bg-opacity: 0.75; }
|
||||
.bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-purple-50 { --tw-bg-opacity: 1; background-color: rgb(250 245 255 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-purple-700 { --tw-bg-opacity: 1; background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-red-50 { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-red-800 { --tw-bg-opacity: 1; background-color: rgb(153 27 27 / var(--tw-bg-opacity, 1)); }
|
||||
.bg-yellow-50 { --tw-bg-opacity: 1; background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1)); }
|
||||
.block { display: block; }
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-2 { border-width: 2px; border-style: solid; }
|
||||
.border-amber-200 { border-color: #fde68a; }
|
||||
.border-amber-300 { border-color: #fcd34d; }
|
||||
.border-amber-500\/60 { border-color: rgb(245 158 11 / 0.6); }
|
||||
.border-blue-200 { border-color: #bfdbfe; }
|
||||
.border-blue-300 { border-color: #93c5fd; }
|
||||
.border-dashed { border-style: dashed; }
|
||||
.border-gray-100 { border-color: #f3f4f6; }
|
||||
.border-gray-400 { border-color: #9ca3af; }
|
||||
.border-gray-600 { border-color: #4b5563; }
|
||||
.border-green-200 { border-color: #bbf7d0; }
|
||||
.border-green-300 { border-color: #86efac; }
|
||||
.border-green-400 { border-color: #4ade80; }
|
||||
.border-green-500 { border-color: #22c55e; }
|
||||
.border-l-0 { border-left-width: 0px; border-left-style: solid; }
|
||||
.border-purple-200 { border-color: #e9d5ff; }
|
||||
.border-purple-300 { border-color: #d8b4fe; }
|
||||
.border-r-0 { border-right-width: 0px; border-right-style: solid; }
|
||||
.border-red-200 { border-color: #fecaca; }
|
||||
.border-red-300 { border-color: #fca5a5; }
|
||||
.border-red-400 { border-color: #f87171; }
|
||||
.border-red-500 { border-color: #ef4444; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-yellow-200 { border-color: #fef08a; }
|
||||
.border-yellow-300 { border-color: #fde047; }
|
||||
.border-yellow-400 { border-color: #facc15; }
|
||||
.break-all { word-break: break-all; }
|
||||
.break-words { overflow-wrap: break-word; }
|
||||
.capitalize { text-transform: capitalize; }
|
||||
.col-span-full { grid-column: 1 / -1; }
|
||||
.cursor-move { cursor: move; }
|
||||
.cursor-not-allowed { cursor: not-allowed; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-wait { cursor: wait; }
|
||||
.divide-gray-100 > :not(:first-child) { border-color: #f3f4f6; }
|
||||
.divide-gray-200 > :not(:first-child) { border-color: #e5e7eb; }
|
||||
.divide-y > :not(:first-child) { border-top-width: 1px; border-top-style: solid; }
|
||||
.duration-150 { transition-duration: 150ms; }
|
||||
.duration-200 { transition-duration: 200ms; }
|
||||
.duration-300 { transition-duration: 300ms; }
|
||||
.ease-in-out { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
|
||||
.ease-out { transition-timing-function: cubic-bezier(0, 0, 0.2, 1); }
|
||||
.flex-\[3\] { flex: 3 1 0%; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
|
||||
.font-normal { font-weight: 400; }
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.h-1 { height: 0.25rem; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-20 { height: 5rem; }
|
||||
.h-3 { height: 0.75rem; }
|
||||
.h-40 { height: 10rem; }
|
||||
.h-5 { height: 1.25rem; }
|
||||
.h-6 { height: 1.5rem; }
|
||||
.h-64 { height: 16rem; }
|
||||
.h-7 { height: 1.75rem; }
|
||||
.h-8 { height: 2rem; }
|
||||
.h-9 { height: 2.25rem; }
|
||||
.h-full { height: 100%; }
|
||||
.inline { display: inline; }
|
||||
.inline-block { display: inline-block; }
|
||||
.inset-0 { inset: 0px; }
|
||||
.inset-y-0 { top: 0px; bottom: 0px; }
|
||||
.italic { font-style: italic; }
|
||||
.items-end { align-items: flex-end; }
|
||||
.items-start { align-items: flex-start; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
.leading-6 { line-height: 1.5rem; }
|
||||
.leading-none { line-height: 1; }
|
||||
.left-0 { left: 0px; }
|
||||
.left-3 { left: 0.75rem; }
|
||||
.left-\[3px\] { left: 3px; }
|
||||
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.line-through { text-decoration-line: line-through; }
|
||||
.list-disc { list-style-type: disc; }
|
||||
.list-inside { list-style-position: inside; }
|
||||
.max-h-32 { max-height: 8rem; }
|
||||
.max-h-48 { max-height: 12rem; }
|
||||
.max-h-64 { max-height: 16rem; }
|
||||
.max-h-96 { max-height: 24rem; }
|
||||
.max-h-screen { max-height: 100vh; }
|
||||
.max-w-\[14rem\] { max-width: 14rem; }
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-md { max-width: 28rem; }
|
||||
.max-w-xs { max-width: 20rem; }
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-5 { margin-bottom: 1.25rem; }
|
||||
.min-h-\[100px\] { min-height: 100px; }
|
||||
.min-h-screen { min-height: 100vh; }
|
||||
.min-w-\[12rem\] { min-width: 12rem; }
|
||||
.min-w-full { min-width: 100%; }
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-1\.5 { margin-left: 0.375rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.ml-5 { margin-left: 1.25rem; }
|
||||
.ml-6 { margin-left: 1.5rem; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.mr-3 { margin-right: 0.75rem; }
|
||||
.mt-0\.5 { margin-top: 0.125rem; }
|
||||
.mt-8 { margin-top: 2rem; }
|
||||
.mt-auto { margin-top: auto; }
|
||||
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
|
||||
.my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.object-cover { object-fit: cover; }
|
||||
.opacity-50 { opacity: 0.5; }
|
||||
.opacity-60 { opacity: 0.6; }
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
.overflow-y-auto { overflow-y: auto; }
|
||||
.p-0 { padding: 0px; }
|
||||
.p-0\.5 { padding: 0.125rem; }
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.pb-20 { padding-bottom: 5rem; }
|
||||
.pb-3 { padding-bottom: 0.75rem; }
|
||||
.pl-3 { padding-left: 0.75rem; }
|
||||
.pl-5 { padding-left: 1.25rem; }
|
||||
.pl-8 { padding-left: 2rem; }
|
||||
.pl-9 { padding-left: 2.25rem; }
|
||||
.pointer-events-auto { pointer-events: auto; }
|
||||
.pointer-events-none { pointer-events: none; }
|
||||
.pr-10 { padding-right: 2.5rem; }
|
||||
.pr-2 { padding-right: 0.5rem; }
|
||||
.pr-20 { padding-right: 5rem; }
|
||||
.pr-3 { padding-right: 0.75rem; }
|
||||
.pr-4 { padding-right: 1rem; }
|
||||
.pr-8 { padding-right: 2rem; }
|
||||
.pt-2 { padding-top: 0.5rem; }
|
||||
.pt-3 { padding-top: 0.75rem; }
|
||||
.pt-4 { padding-top: 1rem; }
|
||||
.pt-5 { padding-top: 1.25rem; }
|
||||
.pt-6 { padding-top: 1.5rem; }
|
||||
.pt-8 { padding-top: 2rem; }
|
||||
.px-1 { padding-left: 0.25rem; padding-right: 0.25rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.py-0\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-12 { padding-top: 3rem; padding-bottom: 3rem; }
|
||||
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
|
||||
.right-0 { right: 0px; }
|
||||
.right-2 { right: 0.5rem; }
|
||||
.ring-0 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 var(--tw-ring-offset-width, 0px) var(--tw-ring-color, transparent); }
|
||||
.rounded-b-lg { border-bottom-left-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
|
||||
.rounded-l-md { border-top-left-radius: 0.375rem; border-bottom-left-radius: 0.375rem; }
|
||||
.rounded-r-lg { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; }
|
||||
.rounded-r-md { border-top-right-radius: 0.375rem; border-bottom-right-radius: 0.375rem; }
|
||||
.rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; }
|
||||
.self-center { align-self: center; }
|
||||
.shadow-inner { box-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 5%); }
|
||||
.shadow-xl { box-shadow: 0 20px 25px -5px rgb(0 0 0 / 10%), 0 8px 10px -6px rgb(0 0 0 / 10%); }
|
||||
.shrink-0 { flex-shrink: 0; }
|
||||
.space-x-3 > * + * { margin-left: 0.75rem; }
|
||||
.space-y-3 > * + * { margin-top: 0.75rem; }
|
||||
.space-y-5 > * + * { margin-top: 1.25rem; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
|
||||
.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
|
||||
.text-amber-200\/90 { color: rgb(253 230 138 / 0.9); }
|
||||
.text-amber-300 { color: #fcd34d; }
|
||||
.text-amber-500 { color: #f59e0b; }
|
||||
.text-amber-600 { color: #d97706; }
|
||||
.text-amber-700 { color: #b45309; }
|
||||
.text-amber-800 { color: #92400e; }
|
||||
.text-amber-900 { color: #78350f; }
|
||||
.text-black { color: #000000; }
|
||||
.text-blue-400 { color: #60a5fa; }
|
||||
.text-blue-500 { color: #3b82f6; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.text-blue-700 { color: #1d4ed8; }
|
||||
.text-blue-800 { color: #1e40af; }
|
||||
.text-blue-900 { color: #1e3a8a; }
|
||||
.text-center { text-align: center; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-800 { color: #1f2937; }
|
||||
.text-green-500 { color: #22c55e; }
|
||||
.text-green-700 { color: #15803d; }
|
||||
.text-green-900 { color: #14532d; }
|
||||
.text-left { text-align: left; }
|
||||
.text-md { font-size: 1rem; line-height: 1.5rem; }
|
||||
.text-purple-600 { color: #9333ea; }
|
||||
.text-purple-700 { color: #7e22ce; }
|
||||
.text-purple-800 { color: #6b21a8; }
|
||||
.text-red-400 { color: #f87171; }
|
||||
.text-red-500 { color: #ef4444; }
|
||||
.text-red-700 { color: #b91c1c; }
|
||||
.text-red-900 { color: #7f1d1d; }
|
||||
.text-right { text-align: right; }
|
||||
.text-yellow-400 { color: #facc15; }
|
||||
.text-yellow-500 { color: #eab308; }
|
||||
.text-yellow-600 { color: #ca8a04; }
|
||||
.text-yellow-700 { color: #a16207; }
|
||||
.text-yellow-900 { color: #713f12; }
|
||||
.top-1\/2 { top: 50%; }
|
||||
.top-2 { top: 0.5rem; }
|
||||
.top-\[3px\] { top: 3px; }
|
||||
.tracking-wider { letter-spacing: 0.05em; }
|
||||
.transform { transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-colors { transition-property: color, background-color, border-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
||||
.translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-1 { --tw-translate-x: 0.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-5 { --tw-translate-x: 1.25rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-6 { --tw-translate-x: 1.5rem; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.translate-x-full { --tw-translate-x: 100%; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.underline { text-decoration-line: underline; text-underline-offset: 0.15em; }
|
||||
.uppercase { text-transform: uppercase; }
|
||||
.w-1\/2 { width: 50%; }
|
||||
.w-1\/3 { width: 33.333333%; }
|
||||
.w-1\/4 { width: 25%; }
|
||||
.w-10 { width: 2.5rem; }
|
||||
.w-11 { width: 2.75rem; }
|
||||
.w-12 { width: 3rem; }
|
||||
.w-14 { width: 3.5rem; }
|
||||
.w-16 { width: 4rem; }
|
||||
.w-2\/3 { width: 66.666667%; }
|
||||
.w-2\/5 { width: 40%; }
|
||||
.w-20 { width: 5rem; }
|
||||
.w-24 { width: 6rem; }
|
||||
.w-3 { width: 0.75rem; }
|
||||
.w-3\/4 { width: 75%; }
|
||||
.w-3\/5 { width: 60%; }
|
||||
.w-32 { width: 8rem; }
|
||||
.w-4\/5 { width: 80%; }
|
||||
.w-4\/6 { width: 66.666667%; }
|
||||
.w-48 { width: 12rem; }
|
||||
.w-5 { width: 1.25rem; }
|
||||
.w-5\/6 { width: 83.333333%; }
|
||||
.w-6 { width: 1.5rem; }
|
||||
.w-8 { width: 2rem; }
|
||||
.w-80 { width: 20rem; }
|
||||
.w-96 { width: 24rem; }
|
||||
.w-px { width: 1px; }
|
||||
.whitespace-nowrap { white-space: nowrap; }
|
||||
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||
.-m-2 { margin: -0.5rem; }
|
||||
.list-none { list-style-type: none; }
|
||||
.pl-0 { padding-left: 0px; }
|
||||
.w-auto { width: auto; }
|
||||
.pb-1 { padding-bottom: 0.25rem; }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* State and pseudo variants */
|
||||
.disabled\:bg-gray-400:disabled { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
|
||||
.disabled\:opacity-50:disabled { opacity: 0.5; }
|
||||
.disabled\:text-gray-400:disabled { color: #6b7280; }
|
||||
.file\:bg-blue-50::file-selector-button { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.file\:border-0::file-selector-button { border-width: 0px; }
|
||||
.file\:font-medium::file-selector-button { font-weight: 500; }
|
||||
.file\:mr-4::file-selector-button { margin-right: 1rem; }
|
||||
.file\:px-4::file-selector-button { padding-left: 1rem; padding-right: 1rem; }
|
||||
.file\:py-2::file-selector-button { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.file\:rounded-md::file-selector-button { border-radius: 0.375rem; }
|
||||
.file\:text-blue-700::file-selector-button { color: #1d4ed8; }
|
||||
.file\:text-sm::file-selector-button { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.first\:border-t-0:first-child { border-top-width: 0px; border-top-style: solid; }
|
||||
.focus\:border-blue-500:focus { border-color: #3b82f6; }
|
||||
.focus\:ring-indigo-500:focus { --tw-ring-color: #6366f1; }
|
||||
.focus\:ring-offset-2:focus { --tw-ring-offset-width: 2px; --tw-ring-offset-shadow: 0 0 0 2px var(--color-surface, #fff); }
|
||||
.focus\:shadow-md:focus { box-shadow: var(--shadow-md); }
|
||||
.group:hover .group-hover\:scale-105 { --tw-scale-x: 1.05; --tw-scale-y: 1.05; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.hover\:bg-amber-50:hover { --tw-bg-opacity: 1; background-color: rgb(255 251 235 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-amber-700:hover { --tw-bg-opacity: 1; background-color: rgb(180 83 9 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-100:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-50:hover { --tw-bg-opacity: 1; background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-blue-700:hover { --tw-bg-opacity: 1; background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-100:hover { --tw-bg-opacity: 1; background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-300:hover { --tw-bg-opacity: 1; background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-400:hover { --tw-bg-opacity: 1; background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-gray-700:hover { --tw-bg-opacity: 1; background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-opacity-20:hover { --tw-bg-opacity: 0.2; }
|
||||
.hover\:bg-opacity-30:hover { --tw-bg-opacity: 0.3; }
|
||||
.hover\:bg-red-50:hover { --tw-bg-opacity: 1; background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-red-900:hover { --tw-bg-opacity: 1; background-color: rgb(127 29 29 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:bg-white:hover { --tw-bg-opacity: 1; background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:border-blue-400:hover { border-color: #60a5fa; }
|
||||
.hover\:file\:bg-blue-100::file-selector-button:hover { --tw-bg-opacity: 1; background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1)); }
|
||||
.hover\:opacity-100:hover { opacity: 1; }
|
||||
.hover\:scale-110:hover { --tw-scale-x: 1.1; --tw-scale-y: 1.1; transform: translate(var(--tw-translate-x, 0), var(--tw-translate-y, 0)) rotate(var(--tw-rotate, 0)) scale(var(--tw-scale-x, 1), var(--tw-scale-y, 1)); }
|
||||
.hover\:shadow-md:hover { box-shadow: var(--shadow-md); }
|
||||
.hover\:text-blue-600:hover { color: #2563eb; }
|
||||
.hover\:text-blue-700:hover { color: #1d4ed8; }
|
||||
.hover\:text-blue-800:hover { color: #1e40af; }
|
||||
.hover\:text-gray-600:hover { color: #374151; }
|
||||
.hover\:text-gray-900:hover { color: #111827; }
|
||||
.hover\:text-red-300:hover { color: #fca5a5; }
|
||||
.hover\:text-red-600:hover { color: #dc2626; }
|
||||
.hover\:text-red-800:hover { color: #991b1b; }
|
||||
.hover\:text-red-900:hover { color: #7f1d1d; }
|
||||
.hover\:text-white:hover { color: #ffffff; }
|
||||
.hover\:text-yellow-700:hover { color: #a16207; }
|
||||
.hover\:text-yellow-800:hover { color: #854d0e; }
|
||||
.hover\:text-yellow-900:hover { color: #713f12; }
|
||||
.hover\:underline:hover { text-decoration-line: underline; text-underline-offset: 0.15em; }
|
||||
.last\:border-0:last-child { border-width: 0px; }
|
||||
.peer:checked ~ .peer-checked\:bg-green-500 { --tw-bg-opacity: 1; background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1)); }
|
||||
.peer:focus-visible ~ .peer-focus\:outline-none { outline: 2px solid transparent; outline-offset: 2px; }
|
||||
.peer:focus-visible ~ .peer-focus\:ring-4 { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 0 0 0 calc(4px + var(--tw-ring-offset-width, 0px)) var(--tw-ring-color, rgb(59 130 246 / 50%)), var(--tw-shadow, 0 0 #0000); }
|
||||
.peer:focus-visible ~ .peer-focus\:ring-blue-300 { --tw-ring-color: #93c5fd; }
|
||||
.placeholder\:text-gray-400::placeholder { color: #6b7280; }
|
||||
.placeholder\:text-gray-500::placeholder { color: #4b5563; }
|
||||
|
||||
.hover\:bg-gray-200:hover { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); }
|
||||
|
||||
.hover\:text-gray-800:hover { color: #1f2937; }
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (min-width: 640px) {
|
||||
@@ -416,6 +836,30 @@ a, button, input, select, textarea {
|
||||
.sm\:block { display: block; }
|
||||
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.sm\:align-middle { vertical-align: middle; }
|
||||
.sm\:flex { display: flex; }
|
||||
.sm\:flex-row-reverse { flex-direction: row-reverse; }
|
||||
.sm\:h-10 { height: 2.5rem; }
|
||||
.sm\:hidden { display: none; }
|
||||
.sm\:inline { display: inline; }
|
||||
.sm\:items-start { align-items: flex-start; }
|
||||
.sm\:max-w-4xl { max-width: 56rem; }
|
||||
.sm\:max-w-lg { max-width: 32rem; }
|
||||
.sm\:ml-3 { margin-left: 0.75rem; }
|
||||
.sm\:ml-4 { margin-left: 1rem; }
|
||||
.sm\:mt-0 { margin-top: 0px; }
|
||||
.sm\:mx-0 { margin-left: 0px; margin-right: 0px; }
|
||||
.sm\:my-8 { margin-top: 2rem; margin-bottom: 2rem; }
|
||||
.sm\:p-0 { padding: 0px; }
|
||||
.sm\:p-6 { padding: 1.5rem; }
|
||||
.sm\:pb-4 { padding-bottom: 1rem; }
|
||||
.sm\:text-left { text-align: left; }
|
||||
.sm\:text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.sm\:w-10 { width: 2.5rem; }
|
||||
.sm\:w-auto { width: auto; }
|
||||
.sm\:w-full { width: 100%; }
|
||||
.sm\:flex-nowrap { flex-wrap: nowrap; }
|
||||
.sm\:gap-4 { gap: 1rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@@ -601,8 +1045,9 @@ a, button, input, select, textarea {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Enhanced Button styles */
|
||||
.btn {
|
||||
/* Enhanced Button styles. :where() keeps the base at zero specificity so
|
||||
utility classes on the same element (px-3 py-1, rounded) take effect. */
|
||||
:where(.btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -637,10 +1082,9 @@ a, button, input, select, textarea {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
outline: 2px solid transparent;
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 3px rgb(37 99 235 / 30%);
|
||||
}
|
||||
|
||||
/* Global button text contrast fix: Ensure buttons with white backgrounds have dark text */
|
||||
@@ -661,14 +1105,16 @@ button.bg-white {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
/* :where() so utilities on the field (pl-8 for a search icon, w-48,
|
||||
h-4 w-4 on checkboxes) override the component defaults. */
|
||||
:where(.form-control) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--color-field-border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: #ffffff;
|
||||
color: #111827; /* text-gray-900 - ensure dark text on white background */
|
||||
background-color: var(--color-field-bg);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
transition: border-color 0.15s ease-in-out;
|
||||
@@ -678,12 +1124,12 @@ button.bg-white {
|
||||
.form-control:focus {
|
||||
border-color: var(--color-primary);
|
||||
/* Using outline instead of box-shadow for focus state (better performance) */
|
||||
outline: 2px solid rgb(37 99 235 / 20%);
|
||||
outline-offset: 2px;
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.form-control:disabled {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--color-background);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -718,28 +1164,12 @@ button.bg-white {
|
||||
/* Added CSS containment for better performance isolation */
|
||||
}
|
||||
|
||||
.plugin-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.plugin-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--color-primary);
|
||||
/* Removed box-shadow transition for better performance */
|
||||
}
|
||||
|
||||
.plugin-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plugin-card:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
@@ -1080,7 +1510,7 @@ button.bg-white {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
color: #374151; /* text-gray-700 for better readability */
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
cursor: pointer;
|
||||
@@ -1097,30 +1527,30 @@ button.bg-white {
|
||||
|
||||
/* Inactive state - improved contrast */
|
||||
.nav-tab:not(.nav-tab-active) {
|
||||
color: #374151; /* text-gray-700 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.nav-tab:not(.nav-tab-active) i {
|
||||
color: #374151; /* text-gray-700 */
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Hover state - enhanced visibility */
|
||||
.nav-tab:not(.nav-tab-active):hover {
|
||||
color: #111827; /* text-gray-900 */
|
||||
background-color: #f3f4f6; /* bg-gray-100 */
|
||||
border-bottom-color: #d1d5db; /* border-gray-300 */
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-border-light);
|
||||
border-bottom-color: var(--color-border);
|
||||
}
|
||||
|
||||
.nav-tab:not(.nav-tab-active):hover i {
|
||||
color: #111827; /* text-gray-900 */
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Active state - prominent with gradient background */
|
||||
.nav-tab-active {
|
||||
color: #1d4ed8; /* text-blue-700 */
|
||||
color: var(--color-primary-hover);
|
||||
font-weight: 600;
|
||||
border-bottom-width: 3px;
|
||||
border-bottom-color: #2563eb; /* border-blue-600 */
|
||||
border-bottom-color: var(--color-primary);
|
||||
background: linear-gradient(135deg, rgb(37 99 235 / 10%) 0%, rgb(59 130 246 / 5%) 100%);
|
||||
box-shadow: 0 2px 4px rgb(37 99 235 / 10%);
|
||||
}
|
||||
@@ -1155,17 +1585,6 @@ button.bg-white {
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
[data-theme="dark"] .form-control {
|
||||
background-color: var(--color-border);
|
||||
color: var(--color-text-primary);
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:focus {
|
||||
border-color: var(--color-primary);
|
||||
outline-color: rgb(59 130 246 / 30%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:disabled {
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
@@ -1275,6 +1694,84 @@ button.bg-white {
|
||||
[data-theme="dark"] .text-green-800 { color: #6ee7b7; }
|
||||
[data-theme="dark"] .text-green-700 { color: #6ee7b7; }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
[data-theme="dark"] .bg-amber-100 { background-color: rgb(245 158 11 / 18%); }
|
||||
[data-theme="dark"] .bg-amber-50 { background-color: rgb(245 158 11 / 12%); }
|
||||
[data-theme="dark"] .bg-blue-100 { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .bg-gray-300 { background-color: #4b5563; }
|
||||
[data-theme="dark"] .bg-gray-400 { background-color: #6b7280; }
|
||||
[data-theme="dark"] .bg-purple-100 { background-color: rgb(168 85 247 / 18%); }
|
||||
[data-theme="dark"] .bg-purple-50 { background-color: rgb(168 85 247 / 12%); }
|
||||
[data-theme="dark"] .border-amber-200 { border-color: rgb(245 158 11 / 40%); }
|
||||
[data-theme="dark"] .border-amber-300 { border-color: rgb(245 158 11 / 55%); }
|
||||
[data-theme="dark"] .border-blue-300 { border-color: rgb(59 130 246 / 55%); }
|
||||
[data-theme="dark"] .border-gray-100 { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .border-gray-400 { border-color: #6b7280; }
|
||||
[data-theme="dark"] .border-green-300 { border-color: rgb(34 197 94 / 55%); }
|
||||
[data-theme="dark"] .border-purple-200 { border-color: rgb(168 85 247 / 40%); }
|
||||
[data-theme="dark"] .border-purple-300 { border-color: rgb(168 85 247 / 55%); }
|
||||
[data-theme="dark"] .border-red-300 { border-color: rgb(239 68 68 / 55%); }
|
||||
[data-theme="dark"] .border-yellow-300 { border-color: rgb(234 179 8 / 55%); }
|
||||
[data-theme="dark"] .divide-gray-100 > :not(:first-child) { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .divide-gray-200 > :not(:first-child) { border-color: var(--color-border); }
|
||||
[data-theme="dark"] .text-amber-500 { color: #fbbf24; }
|
||||
[data-theme="dark"] .text-amber-600 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-700 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-800 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-amber-900 { color: #fcd34d; }
|
||||
[data-theme="dark"] .text-black { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .text-blue-500 { color: #60a5fa; }
|
||||
[data-theme="dark"] .text-blue-600 { color: #93c5fd; }
|
||||
[data-theme="dark"] .text-blue-900 { color: #93c5fd; }
|
||||
[data-theme="dark"] .text-gray-700 { color: var(--color-text-secondary); }
|
||||
[data-theme="dark"] .text-gray-800 { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .text-green-500 { color: #4ade80; }
|
||||
[data-theme="dark"] .text-green-900 { color: #86efac; }
|
||||
[data-theme="dark"] .text-purple-600 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-purple-700 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-purple-800 { color: #d8b4fe; }
|
||||
[data-theme="dark"] .text-red-500 { color: #f87171; }
|
||||
[data-theme="dark"] .text-red-900 { color: #fca5a5; }
|
||||
[data-theme="dark"] .text-yellow-500 { color: #facc15; }
|
||||
[data-theme="dark"] .text-yellow-900 { color: #fde047; }
|
||||
[data-theme="dark"] .disabled\:bg-gray-400:disabled { background-color: #6b7280; }
|
||||
[data-theme="dark"] .file\:bg-blue-50::file-selector-button { background-color: rgb(59 130 246 / 12%); }
|
||||
[data-theme="dark"] .file\:text-blue-700::file-selector-button { color: #93c5fd; }
|
||||
[data-theme="dark"] .hover\:bg-amber-50:hover { background-color: rgb(245 158 11 / 12%); }
|
||||
[data-theme="dark"] .hover\:bg-blue-100:hover { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .hover\:bg-blue-50:hover { background-color: rgb(59 130 246 / 12%); }
|
||||
[data-theme="dark"] .hover\:bg-gray-100:hover { background-color: var(--color-border); }
|
||||
[data-theme="dark"] .hover\:bg-gray-300:hover { background-color: #4b5563; }
|
||||
[data-theme="dark"] .hover\:bg-gray-400:hover { background-color: #6b7280; }
|
||||
[data-theme="dark"] .hover\:bg-red-50:hover { background-color: rgb(239 68 68 / 12%); }
|
||||
[data-theme="dark"] .hover\:file\:bg-blue-100::file-selector-button:hover { background-color: rgb(59 130 246 / 18%); }
|
||||
[data-theme="dark"] .hover\:text-blue-600:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-blue-700:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-blue-800:hover { color: #bfdbfe; }
|
||||
[data-theme="dark"] .hover\:text-gray-600:hover { color: var(--color-text-tertiary); }
|
||||
[data-theme="dark"] .hover\:text-gray-900:hover { color: var(--color-text-primary); }
|
||||
[data-theme="dark"] .hover\:text-red-600:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-red-800:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-red-900:hover { color: #fecaca; }
|
||||
[data-theme="dark"] .hover\:text-yellow-700:hover { color: #fef08a; }
|
||||
[data-theme="dark"] .hover\:text-yellow-800:hover { color: #fef08a; }
|
||||
[data-theme="dark"] .hover\:text-yellow-900:hover { color: #fef08a; }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
[data-theme="dark"] .hover\:text-gray-800:hover { color: var(--color-text-primary); }
|
||||
|
||||
/* Dark theme for the generated color utilities */
|
||||
|
||||
|
||||
/* Theme toggle button */
|
||||
.theme-toggle-btn {
|
||||
display: inline-flex;
|
||||
@@ -1432,10 +1929,10 @@ button.bg-white {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--color-surface, #ffffff);
|
||||
}
|
||||
.style-editor-head > :first-child {
|
||||
background-color: #f9fafb;
|
||||
background-color: var(--color-background, #f9fafb);
|
||||
}
|
||||
.style-editor-row {
|
||||
/* Set by the widget: the column count depends on what the plugin
|
||||
@@ -1455,27 +1952,27 @@ button.bg-white {
|
||||
width: 100%;
|
||||
}
|
||||
.style-editor-row input[type="number"]:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
background-color: var(--color-border-light, #f3f4f6);
|
||||
color: var(--color-text-tertiary, #9ca3af);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.style-editor-colour {
|
||||
width: 2rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--color-field-border, #d1d5db);
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.style-editor-tab {
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
border: 1px solid var(--color-field-border, #d1d5db);
|
||||
background-color: var(--color-background, #f9fafb);
|
||||
color: var(--color-text-secondary, #374151);
|
||||
}
|
||||
.style-editor-tab.is-active {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
background-color: var(--color-primary, #2563eb);
|
||||
border-color: var(--color-primary, #2563eb);
|
||||
color: #ffffff;
|
||||
}
|
||||
.style-editor-panel[hidden] {
|
||||
@@ -1494,3 +1991,90 @@ button.bg-white {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* A label wrapping a visually hidden file input shows the input's focus. */
|
||||
.file-trigger:focus-within {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dark theme: native fields that no component or utility styles would
|
||||
otherwise keep the browser's white surface. */
|
||||
[data-theme="dark"] :where(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea) {
|
||||
background-color: var(--color-field-bg);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-field-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] ::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Range tracks and native controls follow the theme accent instead of the
|
||||
browser's default light chrome. */
|
||||
input[type="range"],
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
progress {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ===== Guards that must stay at the end of the file ===== */
|
||||
|
||||
/* `.hidden` has to beat component classes that set their own display. */
|
||||
.btn.hidden,
|
||||
.badge.hidden,
|
||||
.status-indicator.hidden,
|
||||
.help-tip.hidden,
|
||||
.nav-tab.hidden,
|
||||
.theme-toggle-btn.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
/* .theme-toggle-btn (display: inline-flex) is declared after md:hidden,
|
||||
which left the mobile menu button visible on desktop. */
|
||||
.theme-toggle-btn.md\:hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch screens: controls in the page content get a 44px target. Switches
|
||||
and inline help triggers keep their drawn size. */
|
||||
@media (pointer: coarse) {
|
||||
#tab-content button:not([role="switch"], .help-tip),
|
||||
#tab-content a.inline-flex,
|
||||
#tab-content select,
|
||||
#tab-content input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"], .sr-only) {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: no entrances, slides, pulses or smooth scrolling.
|
||||
Spinners keep turning, slowly, because they are the only busy signal. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html,
|
||||
#logs-container {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
.fa-spin,
|
||||
.fa-spinner,
|
||||
.animate-spin,
|
||||
.jfm-spin,
|
||||
.spinner {
|
||||
animation-duration: 1.5s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
.card:hover,
|
||||
.plugin-card:hover,
|
||||
.btn:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,28 @@
|
||||
// LED Matrix v3 JavaScript
|
||||
// Additional helpers for HTMX and Alpine.js integration
|
||||
|
||||
// Global notification system
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
// Use Alpine.js notification if available
|
||||
if (window.Alpine) {
|
||||
// This would trigger the Alpine.js notification system
|
||||
const event = new CustomEvent('show-notification', {
|
||||
// Global notification system — implemented by widgets/notification.js.
|
||||
// This fallback only exists if nothing defined showNotification earlier; it
|
||||
// hands off to the widget when registered, otherwise queues the message for
|
||||
// the widget to show once it loads.
|
||||
if (typeof window.showNotification !== 'function') {
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
const registry = window.LEDMatrixWidgets;
|
||||
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
||||
if (widget && typeof widget.show === 'function') {
|
||||
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
}
|
||||
if (!Array.isArray(window.__pendingNotifications)) {
|
||||
window.__pendingNotifications = [];
|
||||
}
|
||||
window.__pendingNotifications.push([message, type]);
|
||||
document.dispatchEvent(new CustomEvent('show-notification', {
|
||||
detail: { message, type }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
} else {
|
||||
// Fallback notification — user-facing last resort, so never gated
|
||||
console.info(`${type}: ${message}`);
|
||||
}
|
||||
};
|
||||
}));
|
||||
// User-facing last resort, so never gated
|
||||
console.info(`${(type && type.type) || type}: ${message}`);
|
||||
};
|
||||
}
|
||||
|
||||
// HTMX response handlers
|
||||
document.body.addEventListener('htmx:beforeRequest', function(event) {
|
||||
@@ -180,30 +188,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
} catch { /* no-op */ }
|
||||
});
|
||||
|
||||
// SSE reconnection helper — closes and reopens both SSE streams,
|
||||
// reattaching the open/error handlers defined in base.html.
|
||||
window.reconnectSSE = function() {
|
||||
if (window.statsSource) {
|
||||
window.statsSource.close();
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateSystemStats === 'function') updateSystemStats(data);
|
||||
};
|
||||
if (window._statsOpenHandler) window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
if (window._statsErrorHandler) window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
}
|
||||
|
||||
if (window.displaySource) {
|
||||
window.displaySource.close();
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
|
||||
};
|
||||
if (window._displayErrorHandler) window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
}
|
||||
};
|
||||
// SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
|
||||
// js/app-shell.js — do not open EventSources for stats/display here.
|
||||
|
||||
// Utility functions
|
||||
window.hexToRgb = function(hex) {
|
||||
@@ -462,6 +448,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
|
||||
img.src = 'data:image/png;base64,' + window._lastPreviewFrame;
|
||||
}
|
||||
}
|
||||
// The display SSE stream is only open while a preview is on screen
|
||||
if (window.LEDStreams) window.LEDStreams.refresh();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
/* global debugLog */
|
||||
// SSE wiring + full Alpine app() implementation and tab logic
|
||||
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
|
||||
// Assign to window so reconnectSSE() in app.js can reach them.
|
||||
window.statsSource = new EventSource('/api/v3/stream/stats');
|
||||
window.displaySource = new EventSource('/api/v3/stream/display');
|
||||
|
||||
window.statsSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateSystemStats(data);
|
||||
};
|
||||
|
||||
window.displaySource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
updateDisplayPreview(data);
|
||||
};
|
||||
|
||||
function _setConnectionStatus(connected, reconnecting) {
|
||||
function _setConnectionStatus(connected, reconnecting, paused) {
|
||||
const el = document.getElementById('connection-status');
|
||||
if (!el) return;
|
||||
if (connected) {
|
||||
if (paused) {
|
||||
// Intentionally closed while the page is hidden — not an error.
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-gray-200 border border-gray-300 rounded-full"></div>
|
||||
<span class="text-gray-600" title="Live updates pause while this page is in the background">Paused</span>
|
||||
`;
|
||||
} else if (connected) {
|
||||
el.innerHTML = `
|
||||
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span class="text-gray-600">Connected</span>
|
||||
@@ -38,8 +30,8 @@
|
||||
|
||||
var _statsErrorCount = 0;
|
||||
|
||||
// Named on window so reconnectSSE() in app.js can reattach them after
|
||||
// replacing the EventSource instances.
|
||||
// Kept on window for backward compatibility; LEDStreams attaches them
|
||||
// to every EventSource it opens.
|
||||
window._statsOpenHandler = function() {
|
||||
_statsErrorCount = 0;
|
||||
_setConnectionStatus(true, false);
|
||||
@@ -56,9 +48,173 @@
|
||||
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
|
||||
};
|
||||
|
||||
window.statsSource.addEventListener('open', window._statsOpenHandler);
|
||||
window.statsSource.addEventListener('error', window._statsErrorHandler);
|
||||
window.displaySource.addEventListener('error', window._displayErrorHandler);
|
||||
// ===== Tab/page visibility — shared by partials =====
|
||||
// LEDVisibility.onActive(tab, start, stop[, key]) runs start() when
|
||||
// `tab` is the active tab AND the page is visible, and stop() when
|
||||
// either stops being true. start() should refresh immediately. The
|
||||
// registration is keyed (default: tab name), so a partial re-injected
|
||||
// by HTMX replaces its previous registration — the old stop() runs
|
||||
// first — instead of stacking intervals. Returns an unregister fn.
|
||||
// Tab changes are also broadcast as `ledmatrix:tab-changed` on document.
|
||||
window.LEDVisibility = (function() {
|
||||
const regs = new Map();
|
||||
let lastTab = null;
|
||||
|
||||
function activeTab() {
|
||||
const el = document.querySelector('[x-data="app()"]');
|
||||
const data = el && el._x_dataStack && el._x_dataStack[0];
|
||||
return (data && data.activeTab) || lastTab;
|
||||
}
|
||||
function isActive(tab) {
|
||||
return !document.hidden && activeTab() === tab;
|
||||
}
|
||||
function evaluate(reg) {
|
||||
const want = isActive(reg.tab);
|
||||
if (want === reg.running) return;
|
||||
reg.running = want;
|
||||
try {
|
||||
(want ? reg.start : reg.stop)();
|
||||
} catch (e) {
|
||||
console.error('[LEDVisibility] ' + reg.key + (want ? ' start' : ' stop') + ' failed:', e);
|
||||
}
|
||||
}
|
||||
function onActive(tab, start, stop, key) {
|
||||
key = key || tab;
|
||||
const prev = regs.get(key);
|
||||
if (prev && prev.running) {
|
||||
prev.running = false;
|
||||
try { prev.stop(); } catch (e) { console.error('[LEDVisibility] ' + key + ' stop failed:', e); }
|
||||
}
|
||||
const reg = { tab: tab, start: start, stop: stop, key: key, running: false };
|
||||
regs.set(key, reg);
|
||||
evaluate(reg);
|
||||
return function() {
|
||||
if (regs.get(key) !== reg) return;
|
||||
regs.delete(key);
|
||||
if (reg.running) { reg.running = false; stop(); }
|
||||
};
|
||||
}
|
||||
function refresh() {
|
||||
regs.forEach(evaluate);
|
||||
if (window.LEDStreams) window.LEDStreams.refresh();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
document.addEventListener('ledmatrix:tab-changed', function(e) {
|
||||
lastTab = (e.detail && e.detail.tab) || lastTab;
|
||||
refresh();
|
||||
});
|
||||
|
||||
return { activeTab: activeTab, isActive: isActive, onActive: onActive, refresh: refresh };
|
||||
})();
|
||||
|
||||
// ===== Live SSE streams — the single owner =====
|
||||
// /stream/stats feeds the header stats and connection badge on every
|
||||
// tab, so it stays open while the page is visible. /stream/display
|
||||
// pushes base64 PNG frames and is only opened while a preview is on
|
||||
// screen: the Overview live preview, or the floating preview when open.
|
||||
// Both close while the page is hidden. The server's broadcaster ends its
|
||||
// generator thread once the last subscriber leaves, so closing here
|
||||
// saves Pi CPU, not only bandwidth.
|
||||
//
|
||||
// window.statsSource / window.displaySource remain public. An
|
||||
// EventSource can't be reopened, so each reopen creates a new one;
|
||||
// listeners other code attached with addEventListener (e.g. the Tools
|
||||
// power panel) are recorded and re-attached to the replacement.
|
||||
window.LEDStreams = (function() {
|
||||
const CONFIG = {
|
||||
stats: {
|
||||
url: '/api/v3/stream/stats',
|
||||
prop: 'statsSource',
|
||||
onmessage: function(data) { updateSystemStats(data); },
|
||||
wire: function(src) {
|
||||
src.onopen = window._statsOpenHandler;
|
||||
src.onerror = window._statsErrorHandler;
|
||||
}
|
||||
},
|
||||
display: {
|
||||
url: '/api/v3/stream/display',
|
||||
prop: 'displaySource',
|
||||
onmessage: function(data) { updateDisplayPreview(data); },
|
||||
wire: function(src) { src.onerror = window._displayErrorHandler; }
|
||||
}
|
||||
};
|
||||
const open = { stats: null, display: null };
|
||||
const external = { stats: [], display: [] };
|
||||
|
||||
function openStream(name) {
|
||||
if (open[name]) return;
|
||||
const cfg = CONFIG[name];
|
||||
const src = new EventSource(cfg.url);
|
||||
const add = src.addEventListener.bind(src);
|
||||
const remove = src.removeEventListener.bind(src);
|
||||
src.addEventListener = function(type, listener, options) {
|
||||
external[name].push([type, listener, options]);
|
||||
add(type, listener, options);
|
||||
};
|
||||
src.removeEventListener = function(type, listener, options) {
|
||||
external[name] = external[name].filter(function(l) {
|
||||
return !(l[0] === type && l[1] === listener);
|
||||
});
|
||||
remove(type, listener, options);
|
||||
};
|
||||
external[name].forEach(function(l) { add(l[0], l[1], l[2]); });
|
||||
src.onmessage = function(event) {
|
||||
let data;
|
||||
try { data = JSON.parse(event.data); } catch { return; }
|
||||
cfg.onmessage(data);
|
||||
};
|
||||
cfg.wire(src);
|
||||
open[name] = src;
|
||||
window[cfg.prop] = src;
|
||||
}
|
||||
|
||||
// The closed instance stays on window so late `addEventListener`
|
||||
// calls are still recorded and replayed on the next open.
|
||||
function closeStream(name) {
|
||||
if (!open[name]) return;
|
||||
open[name].close();
|
||||
open[name] = null;
|
||||
}
|
||||
|
||||
function previewOnScreen() {
|
||||
if (window.LEDVisibility.activeTab() === 'overview') return true;
|
||||
const panel = document.getElementById('floating-preview');
|
||||
return !!(panel && panel.style.display === 'block');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (document.hidden) {
|
||||
// Also covers a page first loaded in a background tab.
|
||||
closeStream('stats');
|
||||
closeStream('display');
|
||||
_setConnectionStatus(false, false, true);
|
||||
return;
|
||||
}
|
||||
if (!open.stats) {
|
||||
_statsErrorCount = 0;
|
||||
openStream('stats');
|
||||
}
|
||||
if (previewOnScreen()) openStream('display');
|
||||
else closeStream('display');
|
||||
}
|
||||
|
||||
function reconnect() {
|
||||
closeStream('stats');
|
||||
closeStream('display');
|
||||
refresh();
|
||||
}
|
||||
|
||||
return { refresh: refresh, reconnect: reconnect, isOpen: function(name) { return !!open[name]; } };
|
||||
})();
|
||||
|
||||
// Public helper (previously duplicated in app.js).
|
||||
window.reconnectSSE = function() { window.LEDStreams.reconnect(); };
|
||||
|
||||
// Header stats start immediately; the display stream opens once the
|
||||
// active tab is known (Alpine init dispatches ledmatrix:tab-changed).
|
||||
window.LEDStreams.refresh();
|
||||
document.addEventListener('DOMContentLoaded', function() { window.LEDStreams.refresh(); });
|
||||
|
||||
// Reset any time the currently-active warning clears, so a future
|
||||
// (new) occurrence shows the banner again even if this one was dismissed.
|
||||
@@ -323,6 +479,10 @@
|
||||
if (typeof window.updateFloatingPreviewVisibility === 'function') {
|
||||
window.updateFloatingPreviewVisibility(newTab);
|
||||
}
|
||||
// Pause/resume per-tab timers and SSE (LEDVisibility)
|
||||
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
||||
detail: { tab: newTab, previous: oldTab }
|
||||
}));
|
||||
// Trigger content load when tab changes
|
||||
this.$nextTick(() => {
|
||||
this.loadTabContent(newTab);
|
||||
@@ -335,6 +495,9 @@
|
||||
if (typeof window.updateNavAriaCurrent === 'function') {
|
||||
window.updateNavAriaCurrent(this.activeTab);
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
|
||||
detail: { tab: this.activeTab, previous: null }
|
||||
}));
|
||||
});
|
||||
|
||||
// Listen for plugin updates from pluginManager
|
||||
@@ -1878,18 +2041,21 @@
|
||||
});
|
||||
};
|
||||
|
||||
// showNotification is provided by notification.js widget
|
||||
// This fallback is only used if the widget hasn't loaded yet
|
||||
// showNotification is implemented by the notification.js widget.
|
||||
// Until it loads, this fallback queues messages; the widget shows the
|
||||
// queue as soon as it registers (and then replaces this function).
|
||||
if (typeof window.showNotification !== 'function') {
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
debugLog(`[${type.toUpperCase()}]`, message);
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
||||
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||
} text-white`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000);
|
||||
const registry = window.LEDMatrixWidgets;
|
||||
const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
|
||||
if (widget && typeof widget.show === 'function') {
|
||||
return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
}
|
||||
if (!Array.isArray(window.__pendingNotifications)) {
|
||||
window.__pendingNotifications = [];
|
||||
}
|
||||
window.__pendingNotifications.push([message, type]);
|
||||
debugLog(`[${String((type && type.type) || type).toUpperCase()}]`, message);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -211,28 +211,28 @@ const ConfigDiffViewer = {
|
||||
modalContainer.className = 'fixed inset-0 z-50 overflow-y-auto';
|
||||
modalContainer.innerHTML = `
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="this.closest('#config-diff-modal-container').remove(); window.__configDiffResolve(false);"></div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></div>
|
||||
|
||||
<div id="config-diff-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full" style="position:relative;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
|
||||
<i class="fas fa-code-branch text-blue-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
|
||||
<div class="mt-4 max-h-96 overflow-y-auto">
|
||||
<h3 id="config-diff-modal-title" class="text-lg leading-6 font-medium text-gray-900">Review Configuration Changes</h3>
|
||||
<div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
|
||||
${this.formatDiff(differences)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button id="config-diff-confirm-btn"
|
||||
<button type="button" id="config-diff-confirm-btn"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
<i class="fas fa-check mr-2"></i>Save Changes
|
||||
<i class="fas fa-check mr-2" aria-hidden="true"></i>Save Changes
|
||||
</button>
|
||||
<button id="config-diff-cancel-btn"
|
||||
<button type="button" id="config-diff-cancel-btn"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
@@ -240,27 +240,41 @@ const ConfigDiffViewer = {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
document.body.appendChild(modalContainer);
|
||||
|
||||
// Store resolve function globally (hack for onclick handlers)
|
||||
window.__configDiffResolve = resolve;
|
||||
|
||||
|
||||
let release = null;
|
||||
let settled = false;
|
||||
// Single close path: remove the modal, release the focus trap
|
||||
// (returning focus to where the user was) and settle the promise.
|
||||
function finish(result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
modalContainer.remove();
|
||||
if (window.__configDiffResolve === finish) window.__configDiffResolve = undefined;
|
||||
if (release) release();
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
// Kept for backwards compatibility with code that calls it directly.
|
||||
window.__configDiffResolve = finish;
|
||||
|
||||
// Attach event listeners
|
||||
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
|
||||
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
modalContainer.remove();
|
||||
delete window.__configDiffResolve;
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
modalContainer.remove();
|
||||
delete window.__configDiffResolve;
|
||||
resolve(false);
|
||||
});
|
||||
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
|
||||
|
||||
confirmBtn.addEventListener('click', () => finish(true));
|
||||
cancelBtn.addEventListener('click', () => finish(false));
|
||||
if (backdrop) backdrop.addEventListener('click', () => finish(false));
|
||||
|
||||
if (window.LEDDialog) {
|
||||
release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
|
||||
labelledBy: 'config-diff-modal-title',
|
||||
initialFocus: confirmBtn,
|
||||
onEscape: () => finish(false)
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -134,9 +134,7 @@
|
||||
|
||||
function openResults() {
|
||||
resultsBox.classList.remove('hidden');
|
||||
// .hidden has no effect without a matching CSS rule (this app's stylesheet
|
||||
// is a hand-picked utility subset, not full Tailwind) - force it directly,
|
||||
// same as the revealNode/collapseNode fallback below.
|
||||
// Clear any inline display left by closeResults(); the class does the rest.
|
||||
resultsBox.style.display = '';
|
||||
if (input) input.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Shared modal-dialog accessibility helper.
|
||||
*
|
||||
* The UI has several modal implementations (inline markup toggled with
|
||||
* style.display, markup injected and removed, the `hidden` class). This helper
|
||||
* deliberately does not show or hide anything — each caller keeps its own
|
||||
* visibility logic — it only supplies what every modal was missing:
|
||||
*
|
||||
* - role="dialog", aria-modal="true" and aria-labelledby on the panel
|
||||
* - initial focus inside the panel
|
||||
* - Tab / Shift+Tab kept inside the panel while it is open
|
||||
* - Escape routed to the caller's close function
|
||||
* - focus returned to whatever was focused before the dialog opened
|
||||
*
|
||||
* Usage:
|
||||
* const release = window.LEDDialog.trap(panelEl, {
|
||||
* labelledBy: 'my-modal-title', // id of the visible heading (optional)
|
||||
* label: 'Plugin files', // aria-label when there is no heading
|
||||
* initialFocus: inputEl, // element or selector (optional)
|
||||
* onEscape: closeMyModal // omit to ignore Escape
|
||||
* });
|
||||
* // ...when the modal closes (from any path):
|
||||
* release();
|
||||
*
|
||||
* Nested dialogs stack: only the top-most trap handles Tab and Escape.
|
||||
* release() is idempotent, and calling trap() again on the same panel
|
||||
* releases the previous trap first.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]', 'area[href]', 'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])',
|
||||
'textarea:not([disabled])', 'iframe', '[contenteditable="true"]',
|
||||
'[tabindex]:not([tabindex="-1"])'
|
||||
].join(',');
|
||||
|
||||
const stack = [];
|
||||
|
||||
function visible(el) {
|
||||
return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
||||
}
|
||||
|
||||
function focusables(panel) {
|
||||
return Array.prototype.filter.call(panel.querySelectorAll(FOCUSABLE), visible);
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
const top = stack[stack.length - 1];
|
||||
if (!top) return;
|
||||
if (e.key === 'Escape' && top.onEscape) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
top.onEscape(e);
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
const items = focusables(top.panel);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
top.panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (!top.panel.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function trap(panel, opts) {
|
||||
if (!panel) return function () {};
|
||||
opts = opts || {};
|
||||
|
||||
const existing = stack.find(function (t) { return t.panel === panel; });
|
||||
if (existing) existing.release();
|
||||
|
||||
panel.setAttribute('role', opts.role || 'dialog');
|
||||
panel.setAttribute('aria-modal', 'true');
|
||||
if (opts.labelledBy) {
|
||||
panel.setAttribute('aria-labelledby', opts.labelledBy);
|
||||
} else if (opts.label) {
|
||||
panel.setAttribute('aria-label', opts.label);
|
||||
}
|
||||
if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
|
||||
|
||||
const entry = {
|
||||
panel: panel,
|
||||
onEscape: opts.onEscape || null,
|
||||
returnTo: document.activeElement,
|
||||
released: false,
|
||||
release: null
|
||||
};
|
||||
|
||||
entry.release = function () {
|
||||
if (entry.released) return;
|
||||
entry.released = true;
|
||||
const wasTop = stack[stack.length - 1] === entry;
|
||||
const i = stack.indexOf(entry);
|
||||
if (i !== -1) stack.splice(i, 1);
|
||||
if (stack.length === 0) document.removeEventListener('keydown', onKeydown, true);
|
||||
// Releasing a dialog underneath another one must not pull focus
|
||||
// out of the dialog the user is actually in.
|
||||
if (!wasTop) return;
|
||||
const target = entry.returnTo;
|
||||
const active = stack[stack.length - 1];
|
||||
if (active && !(target && active.panel.contains(target))) {
|
||||
active.panel.focus();
|
||||
} else if (target && typeof target.focus === 'function' && document.contains(target)) {
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
stack.push(entry);
|
||||
if (stack.length === 1) document.addEventListener('keydown', onKeydown, true);
|
||||
|
||||
// Focus after the caller has made the panel visible.
|
||||
requestAnimationFrame(function () {
|
||||
if (entry.released) return;
|
||||
let target = opts.initialFocus;
|
||||
if (typeof target === 'string') target = panel.querySelector(target);
|
||||
if (!target || !visible(target)) target = focusables(panel)[0] || panel;
|
||||
target.focus();
|
||||
});
|
||||
|
||||
return entry.release;
|
||||
}
|
||||
|
||||
window.LEDDialog = { trap: trap };
|
||||
})();
|
||||
@@ -7,95 +7,97 @@
|
||||
|
||||
/**
|
||||
* Comprehensive error code to user-friendly message mapping.
|
||||
* Used only when the server response carries no message of its own.
|
||||
*/
|
||||
const ERROR_MESSAGES = {
|
||||
// Configuration errors
|
||||
'CONFIG_SAVE_FAILED': 'Failed to save configuration',
|
||||
'CONFIG_LOAD_FAILED': 'Failed to load configuration',
|
||||
'CONFIG_VALIDATION_FAILED': 'Configuration validation failed',
|
||||
'CONFIG_ROLLBACK_FAILED': 'Failed to rollback configuration',
|
||||
|
||||
'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again",
|
||||
'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again",
|
||||
'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again',
|
||||
'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore",
|
||||
|
||||
// Plugin errors
|
||||
'PLUGIN_NOT_FOUND': 'Plugin not found',
|
||||
'PLUGIN_INSTALL_FAILED': 'Failed to install plugin',
|
||||
'PLUGIN_UPDATE_FAILED': 'Failed to update plugin',
|
||||
'PLUGIN_UNINSTALL_FAILED': 'Failed to uninstall plugin',
|
||||
'PLUGIN_LOAD_FAILED': 'Failed to load plugin',
|
||||
'PLUGIN_OPERATION_CONFLICT': 'Plugin operation conflict - another operation is in progress',
|
||||
|
||||
'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager",
|
||||
'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again",
|
||||
'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment",
|
||||
'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment",
|
||||
'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details",
|
||||
'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again',
|
||||
|
||||
// Validation errors
|
||||
'VALIDATION_ERROR': 'Validation error',
|
||||
'SCHEMA_VALIDATION_FAILED': 'Configuration schema validation failed',
|
||||
'INVALID_INPUT': 'Invalid input provided',
|
||||
|
||||
'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again',
|
||||
'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again",
|
||||
'INVALID_INPUT': "That value isn't valid. Check it and try again",
|
||||
|
||||
// Network errors
|
||||
'NETWORK_ERROR': 'Network error occurred',
|
||||
'API_ERROR': 'API request failed',
|
||||
'TIMEOUT': 'Operation timed out',
|
||||
|
||||
'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again",
|
||||
'API_ERROR': 'The request failed. Try again in a moment',
|
||||
'TIMEOUT': 'This took too long to respond. Try again in a moment',
|
||||
|
||||
// Permission errors
|
||||
'PERMISSION_DENIED': 'Permission denied',
|
||||
'FILE_PERMISSION_ERROR': 'File permission error',
|
||||
|
||||
'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide",
|
||||
'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide",
|
||||
|
||||
// System errors
|
||||
'SYSTEM_ERROR': 'System error occurred',
|
||||
'SERVICE_UNAVAILABLE': 'Service unavailable',
|
||||
|
||||
'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details',
|
||||
'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab',
|
||||
|
||||
// Unknown errors
|
||||
'UNKNOWN_ERROR': 'An unknown error occurred'
|
||||
'UNKNOWN_ERROR': 'Something went wrong. Try again, and check the Logs tab if it keeps happening'
|
||||
};
|
||||
|
||||
/**
|
||||
* Error code to troubleshooting documentation links.
|
||||
*/
|
||||
const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md';
|
||||
const ERROR_DOCS = {
|
||||
'CONFIG_SAVE_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#configuration-errors',
|
||||
'CONFIG_VALIDATION_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#validation-errors',
|
||||
'PLUGIN_INSTALL_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-installation',
|
||||
'PLUGIN_OPERATION_CONFLICT': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-operations',
|
||||
'PERMISSION_DENIED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions',
|
||||
'FILE_PERMISSION_ERROR': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions'
|
||||
'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
|
||||
'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
|
||||
'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues',
|
||||
'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues',
|
||||
'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues',
|
||||
'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues'
|
||||
};
|
||||
|
||||
/**
|
||||
* Format error message for display to user.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @returns {string} Formatted error message
|
||||
*/
|
||||
function formatError(error) {
|
||||
if (!error) {
|
||||
return 'An unknown error occurred';
|
||||
return ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
|
||||
// If error is a string, return it
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
// If error has a message, use it
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
|
||||
// If error has error_code, format it
|
||||
if (error.error_code) {
|
||||
const message = ERROR_MESSAGES[error.error_code] || error.message || 'An error occurred';
|
||||
|
||||
const message = ERROR_MESSAGES[error.error_code] || error.message || ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
|
||||
// Add details if available
|
||||
if (error.details) {
|
||||
return `${message}: ${error.details}`;
|
||||
}
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return 'An error occurred';
|
||||
|
||||
return ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested fixes for an error.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @returns {Array<string>} Array of suggested fixes
|
||||
*/
|
||||
@@ -103,13 +105,13 @@ function getSuggestedFixes(error) {
|
||||
if (!error || !error.suggested_fixes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return error.suggested_fixes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error with suggestions in a rich UI.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
* @param {string} context - Optional context about what was being done
|
||||
* @param {Object} options - Display options
|
||||
@@ -121,19 +123,19 @@ function displayError(error, context = null, options = {}) {
|
||||
const suggestions = getSuggestedFixes(error);
|
||||
const errorCode = error?.error_code;
|
||||
const docLink = errorCode ? ERROR_DOCS[errorCode] : null;
|
||||
|
||||
|
||||
// Build full message
|
||||
let fullMessage = message;
|
||||
if (context) {
|
||||
fullMessage = `${context}: ${message}`;
|
||||
}
|
||||
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n');
|
||||
}
|
||||
|
||||
|
||||
// If showDetails is true, show a rich error modal
|
||||
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error.details)) {
|
||||
if (options.showDetails !== false && (suggestions.length > 0 || docLink || error?.details)) {
|
||||
showErrorModal(error, context, message, suggestions, docLink);
|
||||
} else {
|
||||
// Simple notification
|
||||
@@ -146,9 +148,12 @@ function displayError(error, context = null, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Release function for the open error modal's focus trap (see utils/dialog.js).
|
||||
let errorModalRelease = null;
|
||||
|
||||
/**
|
||||
* Show a rich error modal with details, suggestions, and copy button.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object
|
||||
* @param {string} context - Context
|
||||
* @param {string} message - Formatted message
|
||||
@@ -156,6 +161,9 @@ function displayError(error, context = null, options = {}) {
|
||||
* @param {string} docLink - Documentation link
|
||||
*/
|
||||
function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
error = error || {};
|
||||
suggestions = suggestions || [];
|
||||
|
||||
// Create modal container if it doesn't exist
|
||||
let modalContainer = document.getElementById('error-modal-container');
|
||||
if (!modalContainer) {
|
||||
@@ -165,7 +173,13 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
modalContainer.style.display = 'none';
|
||||
document.body.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
|
||||
// Re-opening while open: release the previous trap before replacing it.
|
||||
if (errorModalRelease) {
|
||||
errorModalRelease();
|
||||
errorModalRelease = null;
|
||||
}
|
||||
|
||||
// Build modal content
|
||||
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : '';
|
||||
const suggestionsHtml = suggestions.length > 0 ? `
|
||||
@@ -176,15 +190,15 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</ul>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const docLinkHtml = docLink ? `
|
||||
<div class="mt-4">
|
||||
<a href="${docLink}" target="_blank" class="text-blue-600 hover:text-blue-800 text-sm underline">
|
||||
<i class="fas fa-book mr-1"></i>View troubleshooting guide
|
||||
<a href="${escapeHtml(docLink)}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 text-sm underline">
|
||||
<i class="fas fa-book mr-1" aria-hidden="true"></i>View troubleshooting guide<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;"> (opens in a new tab)</span>
|
||||
</a>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const detailsHtml = error.details ? `
|
||||
<div class="mt-4">
|
||||
<details class="cursor-pointer">
|
||||
@@ -193,26 +207,26 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</details>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
const errorCodeHtml = error.error_code ? `
|
||||
<div class="mt-2 text-xs text-gray-500">
|
||||
Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
|
||||
modalContainer.innerHTML = `
|
||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onclick="window.errorHandler.closeErrorModal()"></div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" onclick="window.errorHandler.closeErrorModal()"></div>
|
||||
|
||||
<div id="error-modal-panel" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full" style="position:relative;">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10" aria-hidden="true">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Error</h3>
|
||||
<div class="mt-2">
|
||||
<h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
|
||||
<div class="mt-2" id="error-modal-description">
|
||||
${contextText}
|
||||
<p class="text-sm text-gray-500">${escapeHtml(message)}</p>
|
||||
${errorCodeHtml}
|
||||
@@ -224,11 +238,11 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button id="error-modal-copy-btn"
|
||||
<button type="button" id="error-modal-copy-btn"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
<i class="fas fa-copy mr-2"></i>Copy Error Details
|
||||
<i class="fas fa-copy mr-2" aria-hidden="true"></i>Copy Error Details
|
||||
</button>
|
||||
<button onclick="window.errorHandler.closeErrorModal()"
|
||||
<button type="button" id="error-modal-close-btn" onclick="window.errorHandler.closeErrorModal()"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Close
|
||||
</button>
|
||||
@@ -236,7 +250,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
// Attach event listener to copy button
|
||||
const copyBtn = modalContainer.querySelector('#error-modal-copy-btn');
|
||||
if (copyBtn) {
|
||||
@@ -244,8 +258,20 @@ function showErrorModal(error, context, message, suggestions, docLink) {
|
||||
copyErrorDetails(error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
modalContainer.style.display = 'block';
|
||||
|
||||
const panel = modalContainer.querySelector('#error-modal-panel');
|
||||
if (panel) {
|
||||
panel.setAttribute('aria-describedby', 'error-modal-description');
|
||||
if (window.LEDDialog) {
|
||||
errorModalRelease = window.LEDDialog.trap(panel, {
|
||||
labelledBy: 'error-modal-title',
|
||||
initialFocus: '#error-modal-close-btn',
|
||||
onEscape: closeErrorModal
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +282,11 @@ function closeErrorModal() {
|
||||
if (modalContainer) {
|
||||
modalContainer.style.display = 'none';
|
||||
}
|
||||
if (errorModalRelease) {
|
||||
const release = errorModalRelease;
|
||||
errorModalRelease = null;
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,27 +308,35 @@ function escapeHtml(text) {
|
||||
|
||||
/**
|
||||
* Copy error details to clipboard.
|
||||
*
|
||||
*
|
||||
* @param {Object} error - Error object from API response
|
||||
*/
|
||||
function copyErrorDetails(error) {
|
||||
const errorText = JSON.stringify(error, null, 2);
|
||||
|
||||
function copyFailed(err) {
|
||||
console.error('Failed to copy error details:', err);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification("Couldn't copy to the clipboard. Open Technical details and copy the text by hand.", 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(errorText).then(() => {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification('Error details copied to clipboard', 'success');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy error details:', err);
|
||||
});
|
||||
}).catch(copyFailed);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = errorText;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
document.body.appendChild(textArea);
|
||||
// Keep focus inside the open dialog while copying.
|
||||
const panel = document.getElementById('error-modal-panel');
|
||||
const host = panel && panel.offsetParent !== null ? panel : document.body;
|
||||
const returnFocus = document.activeElement;
|
||||
host.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
@@ -305,9 +344,10 @@ function copyErrorDetails(error) {
|
||||
showNotification('Error details copied to clipboard', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy error details:', err);
|
||||
copyFailed(err);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
host.removeChild(textArea);
|
||||
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,4 +376,3 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
ERROR_DOCS
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -415,7 +415,8 @@
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash"></i>';
|
||||
removeBtn.setAttribute('aria-label', 'Remove row');
|
||||
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(removeBtn);
|
||||
|
||||
if (hasAdvanced) {
|
||||
@@ -424,7 +425,8 @@
|
||||
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
||||
editBtn.title = 'Edit advanced properties (layout, style…)';
|
||||
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
||||
editBtn.setAttribute('aria-label', 'Edit advanced properties');
|
||||
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
|
||||
actionsCell.appendChild(editBtn);
|
||||
}
|
||||
|
||||
@@ -446,9 +448,8 @@
|
||||
if (!advancedCell) return;
|
||||
|
||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||
// Close any existing modal
|
||||
const existing = document.getElementById('array-row-editor-modal');
|
||||
if (existing) existing.remove();
|
||||
// Close any existing modal (also releases its focus trap)
|
||||
window.closeArrayTableRowEditor();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'array-row-editor-modal';
|
||||
@@ -463,9 +464,10 @@
|
||||
// Header
|
||||
safeSetHTML(dialog, `
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
|
||||
aria-label="Close advanced properties"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
|
||||
</div>`);
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -558,11 +560,27 @@
|
||||
dialog.appendChild(footer);
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
if (window.LEDDialog) {
|
||||
rowEditorRelease = window.LEDDialog.trap(dialog, {
|
||||
labelledBy: 'array-row-editor-title',
|
||||
initialFocus: '[data-modal-prop]',
|
||||
onEscape: window.closeArrayTableRowEditor
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Focus-trap release for the open row editor (see utils/dialog.js).
|
||||
let rowEditorRelease = null;
|
||||
|
||||
window.closeArrayTableRowEditor = function() {
|
||||
const modal = document.getElementById('array-row-editor-modal');
|
||||
if (modal) modal.remove();
|
||||
if (rowEditorRelease) {
|
||||
const release = rowEditorRelease;
|
||||
rowEditorRelease = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
html += `
|
||||
<input type="color"
|
||||
id="${fieldId}_color"
|
||||
title="Choose color"
|
||||
aria-label="Choose color"
|
||||
value="${currentValue}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
|
||||
@@ -122,6 +124,7 @@
|
||||
id="${fieldId}_hex"
|
||||
value="${currentValue.substring(1)}"
|
||||
maxlength="6"
|
||||
aria-label="Hex color code"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
|
||||
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
|
||||
@@ -161,7 +164,8 @@
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)"
|
||||
class="w-6 h-6 rounded border border-gray-300 hover:scale-110 transition-transform ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
|
||||
style="background-color: ${escapeHtml(normalized)};"
|
||||
title="${escapeHtml(normalized)}">
|
||||
title="${escapeHtml(normalized)}"
|
||||
aria-label="Use color ${escapeHtml(normalized)}">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-calendar-alt text-gray-400"></i>
|
||||
<i class="fas fa-calendar-alt text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -96,8 +96,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear date">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,8 @@
|
||||
</div>`;
|
||||
html += `<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image">
|
||||
<i class="fas fa-times"></i>
|
||||
class="flex-shrink-0 text-red-400 hover:text-red-600 p-1" title="Remove image" aria-label="Remove image">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
html += '</div>';
|
||||
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
<!-- Schedule Mode -->
|
||||
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<label for="schedule_mode_${sanitizedId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<select id="schedule_mode_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -855,8 +855,8 @@
|
||||
<!-- Time Range Mode -->
|
||||
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
id="schedule_start_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -865,8 +865,8 @@
|
||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
<label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
id="schedule_end_${sanitizedId}"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
@@ -900,6 +900,7 @@
|
||||
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
||||
<input type="time"
|
||||
id="day_${day}_start_${sanitizedId}"
|
||||
aria-label="${day} start time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
@@ -909,6 +910,7 @@
|
||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
||||
<input type="time"
|
||||
id="day_${day}_end_${sanitizedId}"
|
||||
aria-label="${day} end time"
|
||||
data-field-id="${escapeHtml(fieldId)}"
|
||||
data-image-id="${sanitizedId}"
|
||||
data-image-idx="${imageIdx}"
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
this._editFile = null;
|
||||
this._deleteFile = null;
|
||||
this._keyHandler = this._onKey.bind(this);
|
||||
// Focus-trap release functions for open modals (utils/dialog.js)
|
||||
this._releases = new Map();
|
||||
|
||||
this._inject();
|
||||
this._bind();
|
||||
@@ -67,9 +69,30 @@
|
||||
|
||||
_destroy() {
|
||||
document.removeEventListener('keydown', this._keyHandler);
|
||||
for (const key of Array.from(this._releases.keys())) this._release(key);
|
||||
this.el._jfmInstance = null;
|
||||
}
|
||||
|
||||
// Open a modal's focus trap. Escape is routed here, so _onKey no
|
||||
// longer handles it.
|
||||
_trap(key, modal, titleId, initialFocus, onClose) {
|
||||
if (!modal || !window.LEDDialog) return;
|
||||
const box = modal.querySelector('.jfm-modal-box');
|
||||
this._releases.set(key, window.LEDDialog.trap(box, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: onClose
|
||||
}));
|
||||
}
|
||||
|
||||
_release(key) {
|
||||
const release = this._releases.get(key);
|
||||
if (release) {
|
||||
this._releases.delete(key);
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM Injection ────────────────────────────────────────────────────
|
||||
|
||||
_inject() {
|
||||
@@ -108,21 +131,22 @@
|
||||
</div>` : ''}
|
||||
|
||||
<!-- ── Edit modal ─────────────────────────────────────── -->
|
||||
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-edit-modal" hidden>
|
||||
<div class="jfm-modal-box jfm-modal-wide">
|
||||
<div class="jfm-modal-head">
|
||||
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
|
||||
<div class="jfm-modal-tools">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">×</button>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
|
||||
<div id="${u}-edit-err" class="jfm-err-bar" role="alert" hidden></div>
|
||||
<textarea id="${u}-editor" class="jfm-editor"
|
||||
spellcheck="false" autocomplete="off"
|
||||
autocorrect="off" autocapitalize="off"
|
||||
aria-label="JSON editor"></textarea>
|
||||
aria-label="JSON editor" aria-describedby="${u}-editor-hint"></textarea>
|
||||
<span id="${u}-editor-hint" hidden>Tab inserts two spaces. Shift+Tab moves focus back to the toolbar. Ctrl+S saves.</span>
|
||||
<div class="jfm-modal-foot">
|
||||
<span id="${u}-charcount" class="jfm-stat"></span>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
|
||||
@@ -133,10 +157,10 @@
|
||||
|
||||
<!-- ── Delete modal ───────────────────────────────────── -->
|
||||
${hasDelete ? `
|
||||
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-del-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Delete file</span>
|
||||
<span id="${u}-del-title" class="jfm-modal-title">Delete file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -144,7 +168,7 @@
|
||||
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
|
||||
</div>
|
||||
<div class="jfm-modal-foot">
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-del" id="${u}-del-cancel">Cancel</button>
|
||||
<button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,10 +176,10 @@
|
||||
|
||||
<!-- ── Create modal ───────────────────────────────────── -->
|
||||
${hasCreate ? `
|
||||
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
|
||||
<div class="jfm-modal" id="${u}-create-modal" hidden>
|
||||
<div class="jfm-modal-box">
|
||||
<div class="jfm-modal-head">
|
||||
<span class="jfm-modal-title">Create new file</span>
|
||||
<span id="${u}-create-title" class="jfm-modal-title">Create new file</span>
|
||||
<button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="jfm-modal-body">
|
||||
@@ -189,93 +213,106 @@
|
||||
}
|
||||
|
||||
_css(u) {
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"])
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// Shades the tokens don't cover are local variables with a dark override.
|
||||
return `<style>
|
||||
#${u}{font-family:inherit;color:#111827;}
|
||||
#${u}{--jfm-border-strong:#d1d5db;--jfm-border-hover:#9ca3af;--jfm-subtle:#f3f4f6;--jfm-accent-soft:#93c5fd;--jfm-danger-border:#fecaca;--jfm-danger-text:#991b1b;--jfm-text-muted:#6b7280;}
|
||||
[data-theme="dark"] #${u}{--jfm-border-strong:#4b5563;--jfm-border-hover:#6b7280;--jfm-subtle:#374151;--jfm-accent-soft:#1e40af;--jfm-danger-border:#991b1b;--jfm-danger-text:#fecaca;--jfm-text-muted:#9ca3af;}
|
||||
#${u}{font-family:inherit;color:var(--color-text-primary,#111827);}
|
||||
#${u} *{box-sizing:border-box;}
|
||||
|
||||
/* Header */
|
||||
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;}
|
||||
#${u} .jfm-header-left{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:#111827;}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
|
||||
#${u} .jfm-dir{font-size:.75rem;color:var(--jfm-text-muted);background:var(--jfm-subtle);padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;}
|
||||
#${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
|
||||
|
||||
/* Buttons */
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid #d1d5db;background:#fff;color:#374151;font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:#f9fafb;border-color:#9ca3af;}
|
||||
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;}
|
||||
#${u} .jfm-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.4375rem .875rem;border-radius:.375rem;border:1px solid var(--jfm-border-strong);background:var(--color-surface,#fff);color:var(--color-text-secondary,#374151);font-size:.875rem;font-weight:500;cursor:pointer;transition:background .12s,border-color .12s,opacity .12s;line-height:1.25;}
|
||||
#${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
|
||||
#${u} .jfm-btn:focus-visible,#${u} .jfm-close-btn:focus-visible{outline:2px solid var(--color-primary,#3b82f6);outline-offset:1px;}
|
||||
#${u} .jfm-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;}
|
||||
#${u} .jfm-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;}
|
||||
#${u} .jfm-btn-danger{background:#ef4444;border-color:#ef4444;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:#f3f4f6;color:#374151;}
|
||||
#${u} .jfm-btn-primary{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
|
||||
#${u} .jfm-btn-primary:hover:not(:disabled){background:var(--color-primary-hover,#2563eb);border-color:var(--color-primary-hover,#2563eb);}
|
||||
#${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
|
||||
#${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
|
||||
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-btn-ghost:hover:not(:disabled){background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
#${u} .jfm-close-btn{display:flex;align-items:center;justify-content:center;width:2rem;height:2rem;border:none;background:none;color:var(--jfm-text-muted);font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;}
|
||||
#${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
|
||||
|
||||
/* File list */
|
||||
#${u} .jfm-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:.625rem;margin-bottom:1rem;min-height:5rem;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:#6b7280;font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:#9ca3af;}
|
||||
#${u} .jfm-loading{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:.5rem;padding:2rem;color:var(--jfm-text-muted);font-size:.875rem;}
|
||||
#${u} .jfm-empty{grid-column:1/-1;text-align:center;padding:2.5rem 1rem;color:var(--jfm-text-muted);}
|
||||
#${u} .jfm-empty-icon{font-size:2.25rem;margin-bottom:.625rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-title{font-weight:600;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
|
||||
|
||||
/* File cards */
|
||||
#${u} .jfm-card{border:1px solid #e5e7eb;border-radius:.5rem;padding:.875rem;background:#fff;display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);}
|
||||
#${u} .jfm-card{border:1px solid var(--color-border,#e5e7eb);border-radius:.5rem;padding:.875rem;background:var(--color-surface,#fff);display:flex;flex-direction:column;gap:.5rem;transition:border-color .15s,box-shadow .15s;}
|
||||
#${u} .jfm-card:hover{border-color:var(--jfm-accent-soft);box-shadow:var(--shadow-md,0 2px 8px rgba(59,130,246,.1));}
|
||||
#${u} .jfm-card.jfm-off{opacity:.6;}
|
||||
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:#111827;flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid #f3f4f6;margin-top:.125rem;}
|
||||
#${u} .jfm-card-name{font-weight:600;font-size:.9375rem;word-break:break-word;color:var(--color-text-primary,#111827);flex:1;}
|
||||
#${u} .jfm-card-meta{font-size:.75rem;color:var(--jfm-text-muted);display:flex;flex-direction:column;gap:.125rem;line-height:1.5;}
|
||||
#${u} .jfm-card-actions{display:flex;gap:.375rem;padding-top:.5rem;border-top:1px solid var(--color-border-light,#f3f4f6);margin-top:.125rem;}
|
||||
#${u} .jfm-card-actions .jfm-btn{flex:1;justify-content:center;}
|
||||
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
|
||||
|
||||
/* Toggle */
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:#6b7280;white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle{display:flex;align-items:center;gap:.3125rem;font-size:.75rem;color:var(--jfm-text-muted);white-space:nowrap;flex-shrink:0;}
|
||||
#${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
|
||||
|
||||
/* Upload zone */
|
||||
#${u} .jfm-upload-wrap{margin-top:.25rem;}
|
||||
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;}
|
||||
#${u} .jfm-dropzone{border:2px dashed #d1d5db;border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:#f9fafb;user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:#3b82f6;background:#eff6ff;border-style:solid;outline:none;}
|
||||
#${u} .jfm-dropzone{border:2px dashed var(--jfm-border-strong);border-radius:.5rem;padding:1.25rem 1rem;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;background:var(--color-background,#f9fafb);user-select:none;}
|
||||
#${u} .jfm-dropzone:hover,#${u} .jfm-dropzone:focus-visible,#${u} .jfm-dropzone.jfm-over{border-color:var(--color-primary,#3b82f6);background:var(--color-info-bg,#eff6ff);border-style:solid;outline:none;}
|
||||
#${u} .jfm-drop-icon{font-size:1.75rem;display:block;margin-bottom:.375rem;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:#374151;margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;}
|
||||
#${u} .jfm-drop-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
|
||||
#${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
|
||||
|
||||
/* Modals */
|
||||
#${u} .jfm-modal{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(1px);}
|
||||
#${u} .jfm-modal[hidden]{display:none;}
|
||||
#${u} .jfm-modal-box{background:#fff;border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box{background:var(--color-surface,#fff);color:var(--color-text-primary,#111827);border-radius:.5rem;box-shadow:0 20px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;width:100%;max-width:440px;max-height:92vh;}
|
||||
#${u} .jfm-modal-box:focus{outline:none;}
|
||||
#${u} .jfm-modal-wide{max-width:880px;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid #e5e7eb;flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--color-border,#e5e7eb);flex-shrink:0;gap:.5rem;}
|
||||
#${u} .jfm-modal-title{font-weight:600;font-size:.9375rem;color:var(--color-text-primary,#111827);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
#${u} .jfm-modal-tools{display:flex;gap:.25rem;align-items:center;flex-shrink:0;}
|
||||
#${u} .jfm-modal-body{padding:1.125rem;overflow-y:auto;flex:1;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid #e5e7eb;flex-shrink:0;background:#f9fafb;border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:#9ca3af;font-variant-numeric:tabular-nums;}
|
||||
#${u} .jfm-modal-foot{display:flex;gap:.5rem;justify-content:flex-end;align-items:center;padding:.75rem 1.125rem;border-top:1px solid var(--color-border,#e5e7eb);flex-shrink:0;background:var(--color-background,#f9fafb);border-radius:0 0 .5rem .5rem;}
|
||||
#${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
|
||||
|
||||
/* JSON editor */
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:#1e293b;background:#fafafa;tab-size:2;}
|
||||
#${u} .jfm-err-bar{background:#fef2f2;border-bottom:1px solid #fecaca;color:#991b1b;font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-editor{display:block;width:100%;min-height:400px;height:58vh;max-height:64vh;resize:vertical;font-family:'Courier New',Consolas,ui-monospace,monospace;font-size:.8rem;line-height:1.55;padding:.75rem 1rem;border:none;border-radius:0;outline:none;white-space:pre;overflow:auto;color:var(--color-text-primary,#1e293b);background:var(--color-background,#fafafa);tab-size:2;}
|
||||
#${u} .jfm-editor:focus-visible{box-shadow:inset 0 0 0 2px var(--color-primary,#3b82f6);}
|
||||
#${u} .jfm-err-bar{background:var(--color-error-bg,#fef2f2);border-bottom:1px solid var(--jfm-danger-border);color:var(--jfm-danger-text);font-size:.8125rem;padding:.5rem 1.125rem;flex-shrink:0;line-height:1.4;}
|
||||
#${u} .jfm-err-bar[hidden]{display:none;}
|
||||
|
||||
/* Create form */
|
||||
#${u} .jfm-field{margin-bottom:.875rem;}
|
||||
#${u} .jfm-field:last-child{margin-bottom:0;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:#374151;margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:#9ca3af;margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;}
|
||||
#${u} .jfm-field label{display:block;font-size:.875rem;font-weight:500;color:var(--color-text-secondary,#374151);margin-bottom:.3125rem;}
|
||||
#${u} .jfm-field input{width:100%;padding:.4375rem .75rem;border:1px solid var(--jfm-border-strong);border-radius:.375rem;font-size:.875rem;color:var(--color-text-primary,#111827);background:var(--color-surface,#fff);}
|
||||
#${u} .jfm-field input:focus{outline:none;border-color:var(--color-primary,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,.25);}
|
||||
#${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
|
||||
#${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
|
||||
|
||||
/* Spinner */
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
#${u} .jfm-spin{display:inline-block;width:.9rem;height:.9rem;border:2px solid var(--color-border,#e5e7eb);border-top-color:var(--color-primary,#3b82f6);border-radius:50%;animation:jfm-spin-${u} .6s linear infinite;vertical-align:middle;}
|
||||
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
#${u} .jfm-btn,#${u} .jfm-card,#${u} .jfm-dropzone{transition:none;}
|
||||
#${u} .jfm-spin{animation-duration:1.5s;}
|
||||
#${u} .jfm-modal{backdrop-filter:none;}
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
@@ -319,7 +356,9 @@
|
||||
if (this._editorEl) {
|
||||
this._editorEl.addEventListener('input', () => this._updateStat());
|
||||
this._editorEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Tab') {
|
||||
// Plain Tab indents; Shift+Tab is left alone so keyboard
|
||||
// users can always move focus out of the editor.
|
||||
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
const s = this._editorEl.selectionStart;
|
||||
const end = this._editorEl.selectionEnd;
|
||||
@@ -336,15 +375,8 @@
|
||||
}
|
||||
|
||||
_onKey(e) {
|
||||
// Escape is handled by the dialog focus trap (see _trap).
|
||||
const editOpen = this._editModal && !this._editModal.hidden;
|
||||
const delOpen = this._delModal && !this._delModal.hidden;
|
||||
const createOpen = this._createModal && !this._createModal.hidden;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if (editOpen) { this._closeEdit(); return; }
|
||||
if (delOpen) { this._closeDel(); return; }
|
||||
if (createOpen) { this._closeCreate(); return; }
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
|
||||
e.preventDefault();
|
||||
this._doSave();
|
||||
@@ -488,6 +520,7 @@
|
||||
this._editorEl.value = 'Loading…';
|
||||
this._updateStat();
|
||||
this._editModal.hidden = false;
|
||||
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
|
||||
|
||||
try {
|
||||
const data = await this._api('get', { filename });
|
||||
@@ -498,7 +531,7 @@
|
||||
this._editorEl.setSelectionRange(0, 0);
|
||||
this._editorEl.scrollTop = 0;
|
||||
} catch (err) {
|
||||
this._showErr('Failed to load file: ' + err.message);
|
||||
this._showErr(`Couldn't load this file (${err.message}). Close the editor and try again.`);
|
||||
this._editorEl.value = '';
|
||||
}
|
||||
}
|
||||
@@ -507,6 +540,7 @@
|
||||
if (this._editModal) this._editModal.hidden = true;
|
||||
this._editFile = null;
|
||||
this._clearErr();
|
||||
this._release('edit');
|
||||
}
|
||||
|
||||
_formatJson() {
|
||||
@@ -562,12 +596,18 @@
|
||||
this._deleteFile = filename;
|
||||
const el = document.getElementById(`${this._uid}-del-name`);
|
||||
if (el) el.textContent = filename;
|
||||
if (this._delModal) this._delModal.hidden = false;
|
||||
if (this._delModal) {
|
||||
this._delModal.hidden = false;
|
||||
// Destructive dialog: start on Cancel.
|
||||
this._trap('del', this._delModal, `${this._uid}-del-title`,
|
||||
document.getElementById(`${this._uid}-del-cancel`), () => this._closeDel());
|
||||
}
|
||||
}
|
||||
|
||||
_closeDel() {
|
||||
if (this._delModal) this._delModal.hidden = true;
|
||||
this._deleteFile = null;
|
||||
this._release('del');
|
||||
}
|
||||
|
||||
async _doDelete() {
|
||||
@@ -597,11 +637,13 @@
|
||||
});
|
||||
this._createModal.hidden = false;
|
||||
const first = this.createFields[0];
|
||||
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
|
||||
const firstInput = first ? document.getElementById(`${this._uid}-cf-${first.key}`) : null;
|
||||
this._trap('create', this._createModal, `${this._uid}-create-title`, firstInput, () => this._closeCreate());
|
||||
}
|
||||
|
||||
_closeCreate() {
|
||||
if (this._createModal) this._createModal.hidden = true;
|
||||
this._release('create');
|
||||
}
|
||||
|
||||
async _doCreate() {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* LEDMatrix Notification Widget
|
||||
*
|
||||
* Global notification/toast system for displaying messages to users.
|
||||
* Consolidates all notification functionality into a single widget.
|
||||
* This is the single implementation: the early fallbacks in app-shell.js,
|
||||
* app.js and partials/fonts.html only queue messages until this widget has
|
||||
* loaded (see window.__pendingNotifications) and then delegate to it.
|
||||
*
|
||||
* Usage:
|
||||
* window.showNotification('Message here', 'success');
|
||||
@@ -11,6 +13,16 @@
|
||||
*
|
||||
* Types: success, error, warning, info (default)
|
||||
*
|
||||
* Accessibility:
|
||||
* - Toasts are announced through two persistent, visually hidden live
|
||||
* regions: errors use an assertive region (role="alert"), everything else
|
||||
* a polite one (role="status"). The visible toast is not itself a live
|
||||
* region, so each message is announced exactly once.
|
||||
* - Errors stay on screen for at least ERROR_MIN_DURATION and always have a
|
||||
* dismiss button.
|
||||
* - Auto-dismiss pauses while the pointer is over a toast or focus is in it.
|
||||
* - Slide animations are skipped under prefers-reduced-motion.
|
||||
*
|
||||
* @module NotificationWidget
|
||||
*/
|
||||
|
||||
@@ -26,35 +38,51 @@
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
containerId: 'notifications',
|
||||
politeRegionId: 'notifications-live-polite',
|
||||
assertiveRegionId: 'notifications-live-assertive',
|
||||
defaultDuration: 4000,
|
||||
warningDuration: 6000,
|
||||
errorMinDuration: 10000,
|
||||
fadeOutDuration: 300,
|
||||
maxNotifications: 5,
|
||||
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
|
||||
};
|
||||
|
||||
// Type-specific styling
|
||||
// Type-specific styling. `bg` class names are kept for anyone styling
|
||||
// against them; `color` is applied inline so white text meets 4.5:1
|
||||
// contrast even where the utility classes are missing or lighter.
|
||||
const TYPE_STYLES = {
|
||||
success: {
|
||||
bg: 'bg-green-500',
|
||||
color: '#047857',
|
||||
icon: 'fa-check-circle',
|
||||
label: 'Success'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-500',
|
||||
color: '#b91c1c',
|
||||
icon: 'fa-exclamation-circle',
|
||||
label: 'Error'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-500',
|
||||
color: '#b45309',
|
||||
icon: 'fa-exclamation-triangle',
|
||||
label: 'Warning'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-500',
|
||||
color: '#1d4ed8',
|
||||
icon: 'fa-info-circle',
|
||||
label: 'Info'
|
||||
}
|
||||
};
|
||||
// Lookups go through a Map so an arbitrary type string can't reach
|
||||
// Object.prototype properties.
|
||||
const STYLE_BY_TYPE = new Map(Object.entries(TYPE_STYLES));
|
||||
|
||||
const VISUALLY_HIDDEN = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;' +
|
||||
'overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
||||
|
||||
// Track active notifications
|
||||
let activeNotifications = [];
|
||||
@@ -62,8 +90,14 @@
|
||||
// keyed by notification id (cleaned up on dismiss). A Map rather than a
|
||||
// plain object so ids can never collide with prototype properties.
|
||||
const actionCallbacks = new Map();
|
||||
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
|
||||
const timers = new Map();
|
||||
let notificationCounter = 0;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the notifications container
|
||||
* @returns {HTMLElement} Container element
|
||||
@@ -75,14 +109,55 @@
|
||||
container = document.createElement('div');
|
||||
container.id = CONFIG.containerId;
|
||||
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
if (!container.dataset.ledNotifications) {
|
||||
container.dataset.ledNotifications = 'true';
|
||||
// Announcements go through the dedicated live regions below; a
|
||||
// live container would announce every toast a second time.
|
||||
container.removeAttribute('aria-live');
|
||||
container.setAttribute('role', 'region');
|
||||
container.setAttribute('aria-label', 'Notifications');
|
||||
// Let clicks pass through the empty parts of the stack even when
|
||||
// the utility classes are not defined.
|
||||
container.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
getLiveRegion(CONFIG.politeRegionId, 'status', 'polite');
|
||||
getLiveRegion(CONFIG.assertiveRegionId, 'alert', 'assertive');
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getLiveRegion(id, role, politeness) {
|
||||
let region = document.getElementById(id);
|
||||
if (!region) {
|
||||
region = document.createElement('div');
|
||||
region.id = id;
|
||||
region.setAttribute('role', role);
|
||||
region.setAttribute('aria-live', politeness);
|
||||
region.setAttribute('aria-atomic', 'true');
|
||||
region.style.cssText = VISUALLY_HIDDEN;
|
||||
document.body.appendChild(region);
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers once.
|
||||
*/
|
||||
function announce(text, assertive) {
|
||||
const region = getLiveRegion(
|
||||
assertive ? CONFIG.assertiveRegionId : CONFIG.politeRegionId,
|
||||
assertive ? 'alert' : 'status',
|
||||
assertive ? 'assertive' : 'polite'
|
||||
);
|
||||
// Clear first so repeating the same message is announced again.
|
||||
region.textContent = '';
|
||||
setTimeout(() => { region.textContent = text; }, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
@@ -94,16 +169,55 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function clearTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (t && t.timer) clearTimeout(t.timer);
|
||||
timers.delete(notificationId);
|
||||
}
|
||||
|
||||
function startTimer(notificationId, ms) {
|
||||
const t = timers.get(notificationId) || {};
|
||||
if (t.timer) clearTimeout(t.timer);
|
||||
t.remaining = ms;
|
||||
t.startedAt = Date.now();
|
||||
t.timer = setTimeout(() => removeNotification(notificationId), ms);
|
||||
timers.set(notificationId, t);
|
||||
}
|
||||
|
||||
function pauseTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || !t.timer) return;
|
||||
clearTimeout(t.timer);
|
||||
t.timer = null;
|
||||
t.remaining = Math.max(0, t.remaining - (Date.now() - t.startedAt));
|
||||
}
|
||||
|
||||
function resumeTimer(notificationId) {
|
||||
const t = timers.get(notificationId);
|
||||
if (!t || t.timer) return;
|
||||
// Give the reader a moment after they move away.
|
||||
startTimer(notificationId, Math.max(t.remaining, 2000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a notification by ID
|
||||
* @param {string} notificationId - Notification ID
|
||||
* @param {boolean} immediate - Skip fade animation
|
||||
*/
|
||||
function removeNotification(notificationId, immediate = false) {
|
||||
clearTimer(notificationId);
|
||||
const notification = document.getElementById(notificationId);
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
|
||||
if (!notification) return;
|
||||
|
||||
if (immediate) {
|
||||
// If focus is inside the toast, don't strand it on a removed node.
|
||||
const hadFocus = notification.contains(document.activeElement);
|
||||
|
||||
if (immediate || prefersReducedMotion()) {
|
||||
notification.remove();
|
||||
} else {
|
||||
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
|
||||
@@ -115,9 +229,16 @@
|
||||
}, CONFIG.fadeOutDuration);
|
||||
}
|
||||
|
||||
// Remove from tracking array
|
||||
activeNotifications = activeNotifications.filter(id => id !== notificationId);
|
||||
actionCallbacks.delete(notificationId);
|
||||
if (hadFocus) {
|
||||
const next = activeNotifications.length
|
||||
? document.querySelector(`#${activeNotifications[activeNotifications.length - 1]} button`)
|
||||
: null;
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,15 +252,26 @@
|
||||
if (typeof options === 'string') {
|
||||
options = { type: options };
|
||||
}
|
||||
options = options || {};
|
||||
|
||||
const type = options.type || 'info';
|
||||
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
|
||||
const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
|
||||
const isError = type === 'error';
|
||||
let duration = options.duration !== undefined
|
||||
? Number(options.duration)
|
||||
: (isError ? CONFIG.errorMinDuration
|
||||
: type === 'warning' ? CONFIG.warningDuration : CONFIG.defaultDuration);
|
||||
// Errors must stay long enough to read; 0 still means "until dismissed".
|
||||
if (isError && duration > 0 && duration < CONFIG.errorMinDuration) {
|
||||
duration = CONFIG.errorMinDuration;
|
||||
}
|
||||
const showIcon = options.showIcon !== false;
|
||||
const dismissible = options.dismissible !== false;
|
||||
// Errors always get a dismiss button.
|
||||
const dismissible = isError || duration <= 0 || options.dismissible !== false;
|
||||
|
||||
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
|
||||
const style = STYLE_BY_TYPE.get(type);
|
||||
const container = getContainer();
|
||||
const notificationId = `notification_${++notificationCounter}`;
|
||||
const reduceMotion = prefersReducedMotion();
|
||||
|
||||
// Enforce max notifications limit
|
||||
while (activeNotifications.length >= CONFIG.maxNotifications) {
|
||||
@@ -150,18 +282,25 @@
|
||||
const notification = document.createElement('div');
|
||||
notification.id = notificationId;
|
||||
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
notification.setAttribute('role', 'alert');
|
||||
notification.style.backgroundColor = style.color;
|
||||
notification.style.color = '#ffffff';
|
||||
notification.style.pointerEvents = 'auto';
|
||||
notification.dataset.type = type;
|
||||
if (reduceMotion) {
|
||||
notification.style.transition = 'none';
|
||||
} else {
|
||||
notification.style.opacity = '0';
|
||||
notification.style.transform = 'translateX(100%)';
|
||||
}
|
||||
|
||||
// Build content
|
||||
let html = '';
|
||||
|
||||
if (showIcon) {
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
|
||||
html += `<i class="fas ${style.icon} flex-shrink-0" aria-hidden="true"></i>`;
|
||||
}
|
||||
|
||||
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
|
||||
html += `<span class="flex-1 text-sm"><span style="${VISUALLY_HIDDEN}">${style.label}: </span>${escapeHtml(message)}</span>`;
|
||||
|
||||
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
|
||||
// The callback is stored by id and invoked via triggerAction, which
|
||||
@@ -171,7 +310,8 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
|
||||
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150"
|
||||
style="background:rgba(255,255,255,.2);color:inherit;border:0;">
|
||||
${escapeHtml(options.actionLabel)}
|
||||
</button>
|
||||
`;
|
||||
@@ -182,8 +322,9 @@
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
|
||||
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
|
||||
aria-label="Dismiss notification">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
style="width:1.75rem;height:1.75rem;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:inherit;border:0;border-radius:9999px;cursor:pointer;"
|
||||
aria-label="Dismiss ${style.label.toLowerCase()} notification">
|
||||
<svg class="w-3 h-3" width="12" height="12" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@@ -194,17 +335,29 @@
|
||||
container.appendChild(notification);
|
||||
activeNotifications.push(notificationId);
|
||||
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
announce(`${style.label}: ${String(message)}`, isError);
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss)
|
||||
if (!reduceMotion) {
|
||||
// Trigger animation (need to wait for DOM update)
|
||||
requestAnimationFrame(() => {
|
||||
notification.style.opacity = '1';
|
||||
notification.style.transform = 'translateX(0)';
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-dismiss (0 = no auto-dismiss), paused while hovered or focused
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(notificationId);
|
||||
}, duration);
|
||||
startTimer(notificationId, duration);
|
||||
notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
if (!notification.contains(document.activeElement)) resumeTimer(notificationId);
|
||||
});
|
||||
notification.addEventListener('focusin', () => pauseTimer(notificationId));
|
||||
notification.addEventListener('focusout', (e) => {
|
||||
if (!notification.contains(e.relatedTarget) && !notification.matches(':hover')) {
|
||||
resumeTimer(notificationId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
@@ -224,16 +377,17 @@
|
||||
// Register the widget
|
||||
window.LEDMatrixWidgets.register('notification', {
|
||||
name: 'Notification Widget',
|
||||
version: '1.0.0',
|
||||
version: '1.1.0',
|
||||
|
||||
/**
|
||||
* Show a notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.type - Notification type: success, error, warning, info
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
|
||||
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss).
|
||||
* Errors are kept for at least 10s.
|
||||
* @param {boolean} options.showIcon - Show type icon (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true)
|
||||
* @param {boolean} options.dismissible - Show dismiss button (default: true; always true for errors)
|
||||
* @returns {string} Notification ID
|
||||
*/
|
||||
show: showNotification,
|
||||
@@ -295,15 +449,28 @@
|
||||
// Global shorthand function (backwards compatible with existing code).
|
||||
// Accepts either the legacy type string or a full options object
|
||||
// ({ type, duration, actionLabel, onAction, ... }).
|
||||
// This always replaces the early fallbacks, whatever order they loaded in.
|
||||
window.showNotification = function(message, type = 'info') {
|
||||
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
|
||||
};
|
||||
window.showNotification.__ledNotificationWidget = true;
|
||||
|
||||
// Initialize container on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', getContainer);
|
||||
} else {
|
||||
function flushPending() {
|
||||
getContainer();
|
||||
const pending = window.__pendingNotifications;
|
||||
window.__pendingNotifications = [];
|
||||
if (Array.isArray(pending)) {
|
||||
pending.forEach(args => {
|
||||
try { window.showNotification(args[0], args[1]); } catch (e) { console.error(e); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize container (and show anything queued before we loaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', flushPending);
|
||||
} else {
|
||||
flushPending();
|
||||
}
|
||||
|
||||
console.log('[NotificationWidget] Notification widget registered');
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}">
|
||||
<i class="fas fa-minus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-r-0 border-gray-300 hover:bg-gray-200 ${prefix ? '' : 'rounded-l-md'}"
|
||||
aria-label="Decrease" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -128,8 +129,9 @@
|
||||
html += `
|
||||
<button type="button"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')"
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}">
|
||||
<i class="fas fa-plus text-xs"></i>
|
||||
class="inline-flex items-center px-3 py-2 text-gray-600 bg-gray-100 border border-l-0 border-gray-300 hover:bg-gray-200 ${suffix ? '' : 'rounded-r-md'}"
|
||||
aria-label="Increase" aria-controls="${fieldId}_input">
|
||||
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -142,8 +142,9 @@
|
||||
id="${fieldId}_toggle"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||
title="Show/hide password">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye"></i>
|
||||
title="Show/hide password"
|
||||
aria-label="Show password" aria-controls="${fieldId}_input">
|
||||
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
@@ -297,16 +298,19 @@
|
||||
const safeId = sanitizeId(fieldId);
|
||||
const input = document.getElementById(`${safeId}_input`);
|
||||
const icon = document.getElementById(`${safeId}_icon`);
|
||||
const toggleBtn = document.getElementById(`${safeId}_toggle`);
|
||||
|
||||
if (input && icon) {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,108 +56,141 @@
|
||||
if (!document.getElementById('pfm-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'pfm-styles';
|
||||
// Colors come from the app theme tokens (app.css :root / [data-theme="dark"]),
|
||||
// with the original light values as fallbacks for pages without app.css.
|
||||
// The few shades the tokens don't cover are widget-local variables with
|
||||
// a dark-theme override.
|
||||
style.textContent = `
|
||||
.pfm-root { font-family: inherit; }
|
||||
.pfm-root, .pfm-overlay {
|
||||
--pfm-border-strong:#d1d5db; --pfm-subtle:#f3f4f6; --pfm-subtle-hover:#e5e7eb;
|
||||
--pfm-danger-border:#fecaca; --pfm-danger-text:#991b1b; --pfm-today:#fef9c3;
|
||||
--pfm-text-muted:#6b7280;
|
||||
}
|
||||
[data-theme="dark"] .pfm-root, [data-theme="dark"] .pfm-overlay {
|
||||
--pfm-border-strong:#4b5563; --pfm-subtle:#374151; --pfm-subtle-hover:#4b5563;
|
||||
--pfm-danger-border:#991b1b; --pfm-danger-text:#fecaca; --pfm-today:#422006;
|
||||
--pfm-text-muted:#9ca3af;
|
||||
}
|
||||
.pfm-root { font-family: inherit; color:var(--color-text-primary,#111827); }
|
||||
.pfm-header { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.75rem; }
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem;
|
||||
.pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
|
||||
.pfm-upload { border:2px dashed var(--pfm-border-strong); border-radius:.5rem; padding:1.25rem;
|
||||
text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; }
|
||||
.pfm-upload p { font-size:.875rem; color:#4b5563; margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:#9ca3af; }
|
||||
.pfm-upload:hover,.pfm-upload.dragover { border-color:var(--color-primary,#3b82f6); background:var(--color-info-bg,#eff6ff); }
|
||||
.pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
|
||||
.pfm-upload small { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-upload-icon { font-size:1.5rem; color:var(--pfm-text-muted); }
|
||||
.pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:.75rem; margin-top:.75rem; }
|
||||
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem;
|
||||
background:#fff; transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); }
|
||||
.pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
|
||||
background:var(--color-surface,#fff); transition:box-shadow .15s; }
|
||||
.pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
|
||||
.pfm-card.disabled { opacity:.55; }
|
||||
.pfm-card-top { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-bottom:.5rem; }
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:#f3f4f6; border-radius:.375rem;
|
||||
.pfm-card-icon { width:2rem; height:2rem; background:var(--pfm-subtle); border-radius:.375rem;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
color:#6b7280; font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:#6b7280; line-height:1.5; }
|
||||
color:var(--pfm-text-muted); font-size:1rem; }
|
||||
.pfm-card-name { font-weight:600; color:var(--color-text-primary,#111827); font-size:.875rem; margin:.375rem 0 .125rem; }
|
||||
.pfm-card-meta { font-size:.75rem; color:var(--pfm-text-muted); line-height:1.5; }
|
||||
.pfm-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
|
||||
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
|
||||
border-radius:.375rem; font-size:.8125rem; font-weight:500;
|
||||
border:none; cursor:pointer; transition:background .15s; }
|
||||
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:#1d4ed8; }
|
||||
.pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-btn:disabled { opacity:.5; cursor:not-allowed; }
|
||||
.pfm-btn-primary { background:var(--color-primary,#2563eb); color:#fff; flex:1; justify-content:center; }
|
||||
.pfm-btn-primary:hover { background:var(--color-primary-hover,#1d4ed8); }
|
||||
/* Solid fills with white text: fixed shades read well on both themes. */
|
||||
.pfm-btn-danger { background:#dc2626; color:#fff; }
|
||||
.pfm-btn-danger:hover { background:#b91c1c; }
|
||||
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; }
|
||||
.pfm-btn-secondary:hover { background:#e5e7eb; }
|
||||
.pfm-btn-secondary { background:var(--pfm-subtle); color:var(--color-text-secondary,#374151); border:1px solid var(--pfm-border-strong); }
|
||||
.pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
|
||||
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
|
||||
.pfm-btn-create { background:#059669; color:#fff; }
|
||||
.pfm-btn-create:hover { background:#047857; }
|
||||
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:#6b7280; }
|
||||
.pfm-toggle-label { font-size:.75rem; color:var(--pfm-text-muted); }
|
||||
.pfm-toggle-cb { position:relative; display:inline-block; width:2rem; height:1.125rem; }
|
||||
.pfm-toggle-cb input { opacity:0; width:0; height:0; }
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:#d1d5db; border-radius:9999px;
|
||||
.pfm-toggle-slider { position:absolute; inset:0; background:var(--pfm-border-strong); border-radius:9999px;
|
||||
cursor:pointer; transition:background .2s; }
|
||||
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
|
||||
left:.1875rem; bottom:.1875rem; background:#fff;
|
||||
border-radius:50%; transition:transform .2s; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
|
||||
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:#9ca3af; }
|
||||
.pfm-toggle-cb input:focus-visible + .pfm-toggle-slider { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
|
||||
.pfm-empty { text-align:center; padding:2rem; color:var(--pfm-text-muted); }
|
||||
.pfm-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
|
||||
.pfm-error-text { color:var(--color-error,#dc2626); }
|
||||
|
||||
/* Modal */
|
||||
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
z-index:9999; padding:2rem 1rem; overflow-y:auto; }
|
||||
.pfm-modal { background:#fff; border-radius:.75rem; width:100%; max-width:56rem;
|
||||
.pfm-modal { background:var(--color-surface,#fff); color:var(--color-text-primary,#111827);
|
||||
border-radius:.75rem; width:100%; max-width:56rem;
|
||||
box-shadow:0 20px 50px rgba(0,0,0,.3); margin:auto; }
|
||||
.pfm-modal:focus { outline:none; }
|
||||
.pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
|
||||
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; }
|
||||
padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
|
||||
.pfm-modal-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
|
||||
.pfm-modal-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
|
||||
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
|
||||
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb;
|
||||
background:#f9fafb; border-radius:0 0 .75rem .75rem; }
|
||||
padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
|
||||
background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
|
||||
|
||||
/* Entry table */
|
||||
.pfm-table-wrap { overflow-x:auto; }
|
||||
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
|
||||
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb;
|
||||
.pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
|
||||
font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
|
||||
white-space:nowrap; position:sticky; top:0; }
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid #f3f4f6;
|
||||
.pfm-table td { padding:.375rem .625rem; border-bottom:1px solid var(--color-border-light,#f3f4f6);
|
||||
vertical-align:top; }
|
||||
.pfm-table tr.today-row td { background:#fef9c3; }
|
||||
.pfm-table tr.today-row td { background:var(--pfm-today); }
|
||||
.pfm-table td input, .pfm-table td textarea {
|
||||
width:100%; border:1px solid #d1d5db; border-radius:.25rem;
|
||||
width:100%; border:1px solid var(--pfm-border-strong); border-radius:.25rem;
|
||||
padding:.25rem .375rem; font-size:.8125rem; font-family:inherit;
|
||||
resize:vertical; background:#fff; }
|
||||
resize:vertical; background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-table td input:focus, .pfm-table td textarea:focus {
|
||||
outline:none; border-color:#3b82f6; }
|
||||
outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-day-col { width:3rem; text-align:center; font-weight:600;
|
||||
color:#6b7280; white-space:nowrap; }
|
||||
color:var(--pfm-text-muted); white-space:nowrap; }
|
||||
.pfm-table-info { font-size:.75rem; color:var(--pfm-text-muted); margin-bottom:.375rem; }
|
||||
.pfm-pagination { display:flex; align-items:center; justify-content:space-between;
|
||||
margin-top:.75rem; font-size:.8125rem; color:#6b7280; }
|
||||
margin-top:.75rem; font-size:.8125rem; color:var(--pfm-text-muted); }
|
||||
.pfm-page-jump { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db;
|
||||
border-radius:.25rem; text-align:center; }
|
||||
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.25rem; text-align:center;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-ta { width:100%; font-family:monospace; font-size:.75rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; padding:.5rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-json-err { color:var(--color-error,#dc2626); font-size:.75rem; margin-top:.25rem; }
|
||||
|
||||
/* Form in create modal */
|
||||
.pfm-field { margin-bottom:.875rem; }
|
||||
.pfm-field label { display:block; font-size:.875rem; font-weight:500;
|
||||
color:#374151; margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db;
|
||||
border-radius:.375rem; font-size:.875rem; }
|
||||
.pfm-field input:focus { outline:none; border-color:#3b82f6; }
|
||||
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; }
|
||||
color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
|
||||
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
|
||||
border-radius:.375rem; font-size:.875rem;
|
||||
background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
|
||||
.pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
|
||||
.pfm-field-hint { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.2rem; }
|
||||
.pfm-field-error { font-size:.75rem; color:var(--color-error,#dc2626); margin-top:.2rem; }
|
||||
|
||||
/* Delete danger box */
|
||||
.pfm-danger-box { background:#fef2f2; border:1px solid #fecaca;
|
||||
.pfm-danger-box { background:var(--color-error-bg,#fef2f2); border:1px solid var(--pfm-danger-border);
|
||||
border-radius:.5rem; padding:.875rem; font-size:.875rem;
|
||||
color:#991b1b; }
|
||||
color:var(--pfm-danger-text); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pfm-upload, .pfm-card, .pfm-btn, .pfm-toggle-slider, .pfm-toggle-slider:before { transition:none; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -356,7 +389,8 @@
|
||||
delBtn.dataset.pfmAction = 'delete';
|
||||
delBtn.dataset.pfmField = fieldId;
|
||||
delBtn.dataset.pfmFile = f.filename;
|
||||
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||
delBtn.setAttribute('aria-label', 'Delete ' + f.filename);
|
||||
delBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>'; // static
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
card.appendChild(actions);
|
||||
@@ -375,18 +409,18 @@
|
||||
modal.className = 'pfm-modal';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_modal_title"><i class="fas fa-edit mr-2" aria-hidden="true"></i>${escHtml(filename)}</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i>Loading…</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1"></i>Save
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||
<i class="fas fa-save mr-1" aria-hidden="true"></i>Save
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
@@ -394,11 +428,12 @@
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||
trapModal(fieldId, modal, `${fieldId}_modal_title`);
|
||||
|
||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||
if (!data || data.status !== 'success' || !body) {
|
||||
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||
if (body) safeSetHTML(body, `<div class="pfm-empty pfm-error-text">Couldn't load ${escHtml(filename)}. Close this window and try again.</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -413,10 +448,10 @@
|
||||
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||
st._editData = null;
|
||||
safeSetHTML(body, `
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
|
||||
aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
|
||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`);
|
||||
<div id="${escHtml(fieldId)}_json_err" class="pfm-json-err" role="alert"></div>`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -461,7 +496,7 @@
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
safeSetHTML(container, `
|
||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||
<div class="pfm-table-info">
|
||||
${total} entries total
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||
onclick="(function(){const targetPage=Math.ceil(${todayDoy}/${perPage});window._pfmTablePage('${fieldId}',targetPage);setTimeout(function(){const row=document.querySelector('tr[data-day=\\'${todayDoy}\\']');if(row)row.scrollIntoView({block:'center'});},60);})()">
|
||||
@@ -579,9 +614,9 @@
|
||||
modal.style.maxWidth = '28rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_del_title"><i class="fas fa-trash mr-2" aria-hidden="true"></i>Delete File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
@@ -591,15 +626,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||
<i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
|
||||
</button>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
const cancelBtn = modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
cancelBtn.addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||
// Destructive dialog: start on Cancel.
|
||||
trapModal(fieldId, modal, `${fieldId}_del_title`, cancelBtn);
|
||||
};
|
||||
|
||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||
@@ -611,7 +649,7 @@
|
||||
window._pfmCloseModal(fieldId);
|
||||
await loadFiles(fieldId);
|
||||
} else {
|
||||
notify('Delete failed: ' + (result.message || ''), 'error');
|
||||
notify("Couldn't delete the file: " + (result.message || 'unknown error') + '. Try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -626,13 +664,13 @@
|
||||
modal.style.maxWidth = '32rem';
|
||||
safeSetHTML(modal, `
|
||||
<div class="pfm-modal-header">
|
||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||
<i class="fas fa-times"></i>
|
||||
<span class="pfm-modal-title" id="${escHtml(fieldId)}_cre_title"><i class="fas fa-plus-circle mr-2" aria-hidden="true"></i>Create New File</span>
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close" aria-label="Close">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="pfm-modal-body">
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" role="alert" style="margin-bottom:.5rem;"></div>
|
||||
${fields.map(f => `
|
||||
<div class="pfm-field">
|
||||
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||
@@ -643,16 +681,16 @@
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div class="pfm-modal-footer">
|
||||
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1"></i>Create
|
||||
<button type="button" class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||
<button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
|
||||
</button>
|
||||
</div>
|
||||
</div>`);
|
||||
overlay.appendChild(modal);
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||
trapModal(fieldId, modal, `${fieldId}_cre_title`, modal.querySelector('.pfm-field input'));
|
||||
};
|
||||
|
||||
window._pfmConfirmCreate = async function (fieldId) {
|
||||
@@ -739,11 +777,28 @@
|
||||
return overlay;
|
||||
}
|
||||
|
||||
// Dialog semantics + focus trap for an open modal (utils/dialog.js).
|
||||
// Every close path goes through _pfmCloseModal, which releases it.
|
||||
function trapModal(fieldId, modal, titleId, initialFocus) {
|
||||
if (!window.LEDDialog) return;
|
||||
const st = getState(fieldId);
|
||||
st._releaseDialog = window.LEDDialog.trap(modal, {
|
||||
labelledBy: titleId,
|
||||
initialFocus: initialFocus || null,
|
||||
onEscape: () => window._pfmCloseModal(fieldId)
|
||||
});
|
||||
}
|
||||
|
||||
window._pfmCloseModal = function (fieldId) {
|
||||
const st = getState(fieldId);
|
||||
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
|
||||
st._editData = null;
|
||||
st._editFilename = null;
|
||||
if (st._releaseDialog) {
|
||||
const release = st._releaseDialog;
|
||||
st._releaseDialog = null;
|
||||
release();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Widget registration ──────────────────────────────────────────────────
|
||||
@@ -793,7 +848,7 @@
|
||||
<input type="file" id="${fieldId}_file_input" accept=".json"
|
||||
style="display:none"
|
||||
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''">
|
||||
<i class="fas fa-cloud-upload-alt" style="font-size:1.5rem;color:#9ca3af;"></i>
|
||||
<i class="fas fa-cloud-upload-alt pfm-upload-icon" aria-hidden="true"></i>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<small>${escHtml(st.uploadHint)}</small>
|
||||
</div>` : ''}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
saturday: 'Saturday',
|
||||
sunday: 'Sunday'
|
||||
};
|
||||
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
|
||||
|
||||
// Use BaseWidget utilities if available
|
||||
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
|
||||
@@ -283,17 +284,19 @@
|
||||
// Render each day row
|
||||
DAYS.forEach(day => {
|
||||
const dayConfig = schedule.days[day];
|
||||
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
|
||||
const disabled = !dayConfig.enabled;
|
||||
const disabledClass = disabled ? 'bg-gray-100' : '';
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="text-sm font-medium text-gray-900">${escapeHtml(DAY_LABELS[day])}</span>
|
||||
<span class="text-sm font-medium text-gray-900">${dayLabel}</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="checkbox"
|
||||
id="${fieldId}_${day}_enabled"
|
||||
aria-label="${dayLabel} enabled"
|
||||
${dayConfig.enabled ? 'checked' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.checked)"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
@@ -301,6 +304,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_start"
|
||||
aria-label="${dayLabel} start time"
|
||||
value="${escapeHtml(dayConfig.start_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
@@ -309,6 +313,7 @@
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<input type="time"
|
||||
id="${fieldId}_${day}_end"
|
||||
aria-label="${dayLabel} end time"
|
||||
value="${escapeHtml(dayConfig.end_time)}"
|
||||
${disabled ? 'disabled' : ''}
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
|
||||
|
||||
@@ -121,8 +121,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
|
||||
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'} text-black pr-10">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<i class="fas fa-clock text-gray-400"></i>
|
||||
<i class="fas fa-clock text-gray-400" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -101,8 +101,8 @@
|
||||
id="${fieldId}_clear"
|
||||
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')"
|
||||
class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
|
||||
title="Clear">
|
||||
<i class="fas fa-times"></i>
|
||||
title="Clear" aria-label="Clear time">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1044,20 +1044,11 @@ window.initPluginsPage = function() {
|
||||
setupInstalledFilterListeners();
|
||||
setupStoreFilterListeners();
|
||||
|
||||
if (closeOnDemandModalBtn) {
|
||||
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
|
||||
document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal);
|
||||
}
|
||||
if (cancelOnDemandBtn) {
|
||||
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
|
||||
document.getElementById('cancel-on-demand').addEventListener('click', closeOnDemandModal);
|
||||
}
|
||||
if (onDemandForm) {
|
||||
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
|
||||
document.getElementById('on-demand-form').addEventListener('submit', submitOnDemandRequest);
|
||||
}
|
||||
if (onDemandModal) {
|
||||
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
|
||||
// The on-demand modal lives in base.html and survives tab swaps, so bind it
|
||||
// through the one guarded initializer instead of cloning its controls here
|
||||
// (cloning copied data-initialized and left two code paths owning listeners).
|
||||
if (closeOnDemandModalBtn || cancelOnDemandBtn || onDemandForm || onDemandModal) {
|
||||
initializeOnDemandModal();
|
||||
}
|
||||
|
||||
// Load on-demand status silently (false = don't show notification)
|
||||
@@ -1385,7 +1376,7 @@ function getInstalledFilter() {
|
||||
el: 'installed-search',
|
||||
clearEl: 'installed-search-clear',
|
||||
fields: ['name', 'id', 'description', 'author', 'category', 'tags'],
|
||||
debounceMs: 200,
|
||||
debounceMs: 150,
|
||||
},
|
||||
sort: {
|
||||
el: 'installed-sort',
|
||||
@@ -1551,7 +1542,7 @@ function renderInstalledCards(plugins, total) {
|
||||
return JSON.stringify(text || '');
|
||||
};
|
||||
|
||||
container.innerHTML = plugins.map(plugin => {
|
||||
setGridHtmlIfChanged(container, plugins.map(plugin => {
|
||||
// Convert enabled to boolean for consistent rendering
|
||||
const enabledBool = Boolean(plugin.enabled);
|
||||
|
||||
@@ -1582,15 +1573,19 @@ function renderInstalledCards(plugins, total) {
|
||||
<!-- Toggle Switch in Top Right -->
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<label class="relative inline-flex items-center cursor-pointer group">
|
||||
<!-- input.peer must stay immediately before the visible pill:
|
||||
peer-focus:* only reaches a following sibling. -->
|
||||
<input type="checkbox"
|
||||
class="sr-only peer"
|
||||
role="switch"
|
||||
aria-label="Enable ${escapeAttr(plugin.name || plugin.id)}"
|
||||
id="toggle-${escapedPluginId}"
|
||||
${enabledBool ? 'checked' : ''}
|
||||
data-plugin-id="${escapedPluginId}"
|
||||
data-action="toggle">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300" aria-hidden="true">
|
||||
<!-- Toggle Switch -->
|
||||
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner">
|
||||
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} rounded-full transition-colors duration-200 ease-in-out shadow-inner">
|
||||
<div class="absolute top-[3px] left-[3px] bg-white ${enabledBool ? 'translate-x-full' : ''} border-2 ${enabledBool ? 'border-green-500' : 'border-gray-400'} rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">
|
||||
${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
|
||||
</div>
|
||||
@@ -1638,9 +1633,30 @@ function renderInstalledCards(plugins, total) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
// Filter/search changes re-run the card renderers. When the markup they
|
||||
// produce is identical to what is already in the grid (same visible set, same
|
||||
// state), skip the innerHTML rebuild: it saves layout work on small Pis and
|
||||
// keeps focus and any typed branch names in place. The cache lives on the
|
||||
// element, so an HTMX swap that replaces the grid starts fresh, and it is
|
||||
// tied to the first node it produced, so any other write to the grid (empty
|
||||
// state, loading skeleton, error message) invalidates it automatically.
|
||||
function setGridHtmlIfChanged(container, html) {
|
||||
if (container._lastRenderedHtml === html &&
|
||||
container._lastRenderedFirst &&
|
||||
container._lastRenderedFirst === container.firstElementChild) {
|
||||
return false;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
container._lastRenderedHtml = html;
|
||||
container._lastRenderedFirst = container.firstElementChild;
|
||||
return true;
|
||||
}
|
||||
// Shared with the Starlark section, which lives in a separate IIFE below.
|
||||
window.__pmSetGridHtmlIfChanged = setGridHtmlIfChanged;
|
||||
|
||||
// Set up event delegation for plugin action buttons (fallback if onclick doesn't work)
|
||||
// Only set up once per container to avoid redundant listeners, which is what
|
||||
// makes it safe to rebuild the grid's innerHTML on every keystroke.
|
||||
@@ -1875,11 +1891,73 @@ function loadOnDemandStatus(fromRefreshButton = false) {
|
||||
});
|
||||
}
|
||||
|
||||
// The 15s on-demand status poll only runs while someone can see it: the page
|
||||
// is visible and the Plugin Manager tab is the active one. On a Pi Zero 2 W a
|
||||
// forgotten background tab should cost nothing. When it becomes visible again
|
||||
// it refreshes immediately, then resumes the interval.
|
||||
const ON_DEMAND_POLL_MS = 15000;
|
||||
|
||||
function isPluginManagerTabVisible() {
|
||||
if (document.hidden) return false;
|
||||
try {
|
||||
if (window.Alpine && typeof window.Alpine.$data === 'function') {
|
||||
const data = window.Alpine.$data(document.body);
|
||||
if (data && typeof data.activeTab === 'string') {
|
||||
return data.activeTab === 'plugins';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Alpine not initialised on body yet; fall through to the DOM check.
|
||||
}
|
||||
const content = document.getElementById('plugins-content');
|
||||
return !!(content && content.offsetParent !== null);
|
||||
}
|
||||
|
||||
function syncOnDemandStatusPolling() {
|
||||
const shouldRun = isPluginManagerTabVisible();
|
||||
if (shouldRun && !onDemandStatusInterval) {
|
||||
loadOnDemandStatus(false);
|
||||
onDemandStatusInterval = setInterval(() => {
|
||||
if (!isPluginManagerTabVisible()) {
|
||||
clearInterval(onDemandStatusInterval);
|
||||
onDemandStatusInterval = null;
|
||||
return;
|
||||
}
|
||||
loadOnDemandStatus(false);
|
||||
}, ON_DEMAND_POLL_MS);
|
||||
} else if (!shouldRun && onDemandStatusInterval) {
|
||||
clearInterval(onDemandStatusInterval);
|
||||
onDemandStatusInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startOnDemandStatusPolling() {
|
||||
if (onDemandStatusInterval) {
|
||||
clearInterval(onDemandStatusInterval);
|
||||
onDemandStatusInterval = null;
|
||||
}
|
||||
// The caller has just loaded status, so start the interval without a
|
||||
// second immediate fetch; later resumes go through syncOnDemandStatusPolling.
|
||||
if (isPluginManagerTabVisible()) {
|
||||
onDemandStatusInterval = setInterval(() => {
|
||||
if (!isPluginManagerTabVisible()) {
|
||||
clearInterval(onDemandStatusInterval);
|
||||
onDemandStatusInterval = null;
|
||||
return;
|
||||
}
|
||||
loadOnDemandStatus(false);
|
||||
}, ON_DEMAND_POLL_MS);
|
||||
}
|
||||
|
||||
if (!window.__onDemandPollingListenersBound) {
|
||||
window.__onDemandPollingListenersBound = true;
|
||||
document.addEventListener('visibilitychange', syncOnDemandStatusPolling);
|
||||
// Tab switches: nav clicks change Alpine's activeTab, and HTMX swaps
|
||||
// tab content in. Defer so Alpine has applied x-show first.
|
||||
document.addEventListener('click', () => setTimeout(syncOnDemandStatusPolling, 0));
|
||||
document.addEventListener('htmx:afterSettle', syncOnDemandStatusPolling);
|
||||
window.addEventListener('popstate', () => setTimeout(syncOnDemandStatusPolling, 0));
|
||||
}
|
||||
onDemandStatusInterval = setInterval(() => loadOnDemandStatus(false), 15000);
|
||||
}
|
||||
|
||||
window.loadOnDemandStatus = loadOnDemandStatus;
|
||||
@@ -2105,6 +2183,17 @@ window.__openOnDemandModalImpl = function(pluginId) {
|
||||
modalContent.style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
// Dialog semantics, focus trap, Escape, and focus return. Opening again
|
||||
// while already open (e.g. a double click) keeps the existing trap so
|
||||
// focus still returns to the control that first opened it.
|
||||
if (modalContent && window.LEDDialog && !onDemandDialogRelease) {
|
||||
onDemandDialogRelease = window.LEDDialog.trap(modalContent, {
|
||||
labelledBy: 'on-demand-modal-title',
|
||||
initialFocus: modeSelect,
|
||||
onEscape: closeOnDemandModal
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll to top of page to ensure modal is visible
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
@@ -2132,12 +2221,21 @@ window.__openOnDemandModalImpl = function(pluginId) {
|
||||
// Replace the stub with the real implementation
|
||||
window.openOnDemandModal = window.__openOnDemandModalImpl;
|
||||
|
||||
// Release handle for the on-demand modal's focus trap (window.LEDDialog).
|
||||
// Every close path funnels through closeOnDemandModal, which releases it once.
|
||||
let onDemandDialogRelease = null;
|
||||
|
||||
function closeOnDemandModal() {
|
||||
const modal = document.getElementById('on-demand-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
currentOnDemandPluginId = null;
|
||||
if (onDemandDialogRelease) {
|
||||
const release = onDemandDialogRelease;
|
||||
onDemandDialogRelease = null;
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
function submitOnDemandRequest(event) {
|
||||
@@ -2366,7 +2464,7 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
|
||||
if (logoValue.path) {
|
||||
html += `
|
||||
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
|
||||
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" class="w-16 h-16 object-cover rounded border">
|
||||
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
||||
<button type="button"
|
||||
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
|
||||
class="text-red-600 hover:text-red-800">
|
||||
@@ -2817,7 +2915,7 @@ window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, p
|
||||
// Get current item index from data-index attribute for remove button
|
||||
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
|
||||
imageContainer.innerHTML = `
|
||||
<img src="/${escapedPath}" alt="Logo" class="w-16 h-16 object-cover rounded border">
|
||||
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
||||
<button type="button"
|
||||
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
|
||||
class="text-red-600 hover:text-red-800">
|
||||
@@ -3666,7 +3764,7 @@ function getStoreFilter() {
|
||||
search: {
|
||||
el: 'plugin-search',
|
||||
fields: ['name', 'description', 'author', 'id', 'category', 'tags'],
|
||||
debounceMs: 300,
|
||||
debounceMs: 150,
|
||||
},
|
||||
sort: {
|
||||
el: 'store-sort',
|
||||
@@ -3781,7 +3879,7 @@ function renderPluginStore(plugins) {
|
||||
return JSON.stringify(text || '');
|
||||
};
|
||||
|
||||
container.innerHTML = plugins.map(plugin => {
|
||||
setGridHtmlIfChanged(container, plugins.map(plugin => {
|
||||
const installed = isStorePluginInstalled(plugin);
|
||||
return `
|
||||
<div class="plugin-card">
|
||||
@@ -3830,7 +3928,7 @@ function renderPluginStore(plugins) {
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
// Expose functions to window for onclick handlers
|
||||
@@ -4004,8 +4102,8 @@ function renderSavedRepositories(repositories) {
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
|
||||
</div>
|
||||
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
|
||||
<i class="fas fa-trash"></i>
|
||||
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository" aria-label="Remove saved repository ${escapeAttribute(repoName)}">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -4603,14 +4701,17 @@ window.toggleGithubTokenVisibility = function() {
|
||||
const icon = document.getElementById('github-token-icon');
|
||||
|
||||
if (input && icon) {
|
||||
const btn = icon.closest('button');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.remove('fa-eye');
|
||||
icon.classList.add('fa-eye-slash');
|
||||
if (btn) btn.setAttribute('aria-label', 'Hide token');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.remove('fa-eye-slash');
|
||||
icon.classList.add('fa-eye');
|
||||
if (btn) btn.setAttribute('aria-label', 'Show token');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4989,6 +5090,7 @@ window.updateImageList = function(fieldId, images) {
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<img src="/${img.path || ''}"
|
||||
alt="${img.filename || ''}"
|
||||
loading="lazy" decoding="async"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
@@ -5006,14 +5108,16 @@ window.updateImageList = function(fieldId, images) {
|
||||
<button type="button"
|
||||
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
|
||||
class="text-blue-600 hover:text-blue-800 p-2"
|
||||
title="Schedule this image">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
title="Schedule this image"
|
||||
aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
||||
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')"
|
||||
class="text-red-600 hover:text-red-800 p-2"
|
||||
title="Delete image">
|
||||
<i class="fas fa-trash"></i>
|
||||
title="Delete image"
|
||||
aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5137,7 +5241,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||
<!-- Schedule Mode -->
|
||||
<div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<label for="schedule_mode_${imageId}" class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
||||
<select id="schedule_mode_${imageId}"
|
||||
onchange="window.updateImageScheduleMode('${fieldId}', '${imageId}', ${imageIdx})"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
@@ -5150,7 +5254,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||
<!-- Time Range Mode -->
|
||||
<div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<label for="schedule_start_${imageId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
||||
<input type="time"
|
||||
id="schedule_start_${imageId}"
|
||||
value="${schedule.start_time || '08:00'}"
|
||||
@@ -5158,7 +5262,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<label for="schedule_end_${imageId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
||||
<input type="time"
|
||||
id="schedule_end_${imageId}"
|
||||
value="${schedule.end_time || '18:00'}"
|
||||
@@ -5188,12 +5292,14 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
||||
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${imageId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
||||
<input type="time"
|
||||
id="day_${day}_start_${imageId}"
|
||||
aria-label="${day} start time"
|
||||
value="${dayConfig.start_time || '08:00'}"
|
||||
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
||||
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
||||
${!dayConfig.enabled ? 'disabled' : ''}>
|
||||
<input type="time"
|
||||
id="day_${day}_end_${imageId}"
|
||||
aria-label="${day} end time"
|
||||
value="${dayConfig.end_time || '18:00'}"
|
||||
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
||||
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
||||
@@ -5375,12 +5481,12 @@ if (typeof window !== 'undefined') {
|
||||
if (propSchema.type === 'boolean') {
|
||||
const checked = propValue ? 'checked' : '';
|
||||
// No name attribute - rely solely on _data field to prevent key leakage
|
||||
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
||||
itemHtml += `<input type="checkbox" data-prop-key="${propKey}" aria-label="${escapeHtml(propLabel)}" ${checked} class="h-4 w-4 text-blue-600" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
||||
} else {
|
||||
// Escape HTML to prevent XSS
|
||||
// No name attribute - rely solely on _data field to prevent key leakage
|
||||
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') : (propValue || '');
|
||||
itemHtml += `<input type="text" data-prop-key="${propKey}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
||||
itemHtml += `<input type="text" data-prop-key="${propKey}" aria-label="${escapeHtml(propLabel)}" value="${escapedValue}" class="block w-full px-3 py-2 border border-gray-300 rounded-md" onchange="window.updateArrayObjectData('${fieldId}')">`;
|
||||
}
|
||||
itemHtml += `</div>`;
|
||||
});
|
||||
@@ -5823,7 +5929,7 @@ document.addEventListener('htmx:afterSettle', function() {
|
||||
search: {
|
||||
el: 'starlark-search',
|
||||
fields: ['name', 'summary', 'desc', 'author', 'id', 'category'],
|
||||
debounceMs: 300,
|
||||
debounceMs: 150,
|
||||
},
|
||||
sort: {
|
||||
el: 'starlark-sort',
|
||||
@@ -5902,7 +6008,9 @@ document.addEventListener('htmx:afterSettle', function() {
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = apps.map(app => {
|
||||
const setGridHtmlIfChanged = window.__pmSetGridHtmlIfChanged ||
|
||||
((el, html) => { el.innerHTML = html; return true; });
|
||||
setGridHtmlIfChanged(grid, apps.map(app => {
|
||||
const installed = isStarlarkInstalled(app.id);
|
||||
return `
|
||||
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
|
||||
@@ -5932,7 +6040,7 @@ document.addEventListener('htmx:afterSettle', function() {
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join(''));
|
||||
|
||||
// Add delegated event listener only once (prevent duplicate handlers)
|
||||
if (!grid.dataset.starlarkHandlerAttached) {
|
||||
|
||||
Reference in New Issue
Block a user