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:
Chuck
2026-09-14 09:42:24 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 69d408b321
commit d1e821c625
45 changed files with 2869 additions and 870 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Regression: the native color input in the color-picker widget must have
an accessible name independent of showHexInput.
CodeRabbit flagged (PR #568) that the <input type="color"> only carried a
`title` attribute -- screen readers don't reliably announce `title`, and
when showHexInput is false there is no other label naming the control.
"""
from pathlib import Path
WIDGET_JS = (
Path(__file__).resolve().parent.parent
/ "web_interface" / "static" / "v3" / "js" / "widgets" / "color-picker.js"
)
def test_native_color_input_has_aria_label():
source = WIDGET_JS.read_text(encoding="utf-8")
start = source.index('<input type="color"')
end = source.index(">", start)
tag = source[start:end]
assert 'aria-label=' in tag, (
"native color <input> lost its accessible name; screen readers need "
"aria-label since it has no associated <label> and showHexInput can "
"be false"
)
+12
View File
@@ -164,6 +164,18 @@ def test_static_asset_served(client, asset):
assert len(resp.data) > 0 assert len(resp.data) > 0
def test_widget_bundle_is_served_and_requested(client):
"""base.html loads every widget through one versioned bundle request."""
page = client.get("/").get_data(as_text=True)
assert "/assets/widgets.js?v=" in page
resp = client.get("/assets/widgets.js")
assert resp.status_code == 200
assert resp.mimetype == "application/javascript"
body = resp.get_data(as_text=True)
assert body.index("/* registry.js */") < body.index("/* notification.js */")
assert "/* json-file-manager.js */" in body
def test_durations_page_groups_by_plugin(client): def test_durations_page_groups_by_plugin(client):
"""One duration input per display mode of each enabled plugin, plus the """One duration input per display mode of each enabled plugin, plus the
leftover group for saved keys no enabled plugin owns.""" leftover group for saved keys no enabled plugin owns."""
+81 -4
View File
@@ -37,11 +37,11 @@ def test_every_used_breakpoint_class_is_defined():
class_attr = re.compile(r'class="([^"]*)"') class_attr = re.compile(r'class="([^"]*)"')
bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES)) bp_class = re.compile(r"\b(%s):[A-Za-z0-9_.-]+" % "|".join(BP_PREFIXES))
for path in _template_files(): for path in _template_files():
for attr in class_attr.findall(path.read_text()): for attr in class_attr.findall(path.read_text(encoding="utf-8")):
for m in bp_class.finditer(attr): for m in bp_class.finditer(attr):
used.add(m.group(0)) used.add(m.group(0))
css = APP_CSS.read_text() css = APP_CSS.read_text(encoding="utf-8")
defined = { defined = {
m.group(0).lstrip(".").replace("\\:", ":") m.group(0).lstrip(".").replace("\\:", ":")
for m in re.finditer( for m in re.finditer(
@@ -56,13 +56,90 @@ def test_every_used_breakpoint_class_is_defined():
) )
_VARIANTS = (
r"(?:(?:sm|md|lg|xl|2xl|hover|focus|focus-visible|active|disabled|first|last"
r"|group-hover|peer-checked|peer-focus|placeholder|file):)*"
)
_UTILITY = re.compile(
"^" + _VARIANTS + r"-?(?:"
r"(?:block|inline|inline-block|inline-flex|flex|grid|hidden|table|contents"
r"|static|fixed|absolute|relative|sticky|truncate|sr-only|transform"
r"|uppercase|lowercase|capitalize|italic|underline|line-through|border|shadow"
r"|rounded|transition|grow|shrink|group|peer)"
r"|(?:m|p)[trblxy]?-[\w./]+"
r"|(?:w|h|min-w|min-h|max-w|max-h|gap|gap-x|gap-y|space-x|space-y|top|right"
r"|bottom|left|inset|inset-x|inset-y|z|opacity|duration|scale|rotate"
r"|translate-x|translate-y|grid-cols|col-span|leading|tracking)-[\w./\[\]]+"
r"|(?:text|bg|border|divide|ring|placeholder|accent)-(?:[a-z]+-\d{2,3}(?:/\d+)?"
r"|white|black|transparent|xs|sm|base|md|lg|xl|[2-5]xl|left|center|right"
r"|opacity-\d+|[trblxy](?:-\d)?|\d|dashed|dotted|offset-\d)"
r"|font-(?:mono|sans|serif|thin|light|normal|medium|semibold|bold|extrabold)"
r"|select-(?:none|all|text|auto)"
r"|(?:rounded|shadow|items|justify|self|flex|whitespace|break|object|list"
r"|overflow|overflow-x|overflow-y|cursor|pointer-events|appearance"
r"|ease|divide|line-clamp)-[a-z0-9]+(?:-[a-z0-9]+)?(?:\[[^\]]+\])?"
r")$"
)
def _css_light_classes(css):
"""Classes that get a rule outside [data-theme="dark"]."""
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
defined = set()
for block in re.finditer(r"([^{}]+)\{", css):
for sel in block.group(1).split(","):
sel = sel.strip()
if sel.startswith("@") or sel.startswith('[data-theme="dark"]'):
continue
for m in re.finditer(r"\.((?:\\.|[\w-])+)", sel):
defined.add(m.group(1).replace("\\", ""))
return defined
def test_every_used_utility_class_is_defined():
"""app.css is the whole stylesheet (no Tailwind build), so a utility class
it doesn't define silently does nothing. `.hidden` was missing for years,
which broke every JS show/hide toggle. Scans templates and static JS."""
attr = re.compile(r"""(?:class|className)\s*[=:]\s*(["'`])(.*?)\1""", re.S)
class_list = re.compile(r"classList\.(?:add|remove|toggle)\(([^)]*)\)")
template_expr = re.compile(r"\{\{.*?\}\}|\{%.*?%\}|\$\{[^}]*\}", re.S)
files = list(_template_files()) + [
p for p in (STATIC / "v3").rglob("*.js")
if "vendor" not in p.parts and not p.name.endswith(".min.js")
]
used = {}
for path in files:
text = template_expr.sub(" ", path.read_text(encoding="utf-8"))
chunks = [m.group(2) for m in attr.finditer(text)]
chunks += re.findall(r"""['"]([^'"]*)['"]""", " ".join(
m.group(1) for m in class_list.finditer(text)))
# :class="{ 'hidden': open }" and ternaries hold class names inside
# string literals; read those as well as the raw chunk.
bound = re.compile(r""":class\s*=\s*"([^"]*)""")
for m in bound.finditer(text):
chunks.append(m.group(1))
for chunk in chunks:
candidates = [chunk] + re.findall(r"""['`]([^'`]*)['`]""", chunk)
for candidate in candidates:
for token in candidate.split():
if _UTILITY.match(token):
used.setdefault(token, path.relative_to(PROJECT_ROOT))
defined = _css_light_classes(APP_CSS.read_text(encoding="utf-8"))
missing = sorted(f"{cls} ({used[cls]})" for cls in used if cls not in defined)
assert not missing, (
"Utility classes used in templates/JS but not defined in app.css "
f"(they silently no-op): {missing}"
)
def test_every_static_url_for_points_to_a_real_file(): def test_every_static_url_for_points_to_a_real_file():
ref = re.compile( ref = re.compile(
r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]" r"url_for\(\s*['\"]static['\"]\s*,\s*filename\s*=\s*['\"]([^'\"]+)['\"]"
) )
missing = [] missing = []
for path in _template_files(): for path in _template_files():
for filename in ref.findall(path.read_text()): for filename in ref.findall(path.read_text(encoding="utf-8")):
if not (STATIC / filename).is_file(): if not (STATIC / filename).is_file():
missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}") missing.append(f"{path.relative_to(PROJECT_ROOT)}: {filename}")
assert not missing, f"Templates reference missing static assets: {missing}" assert not missing, f"Templates reference missing static assets: {missing}"
@@ -73,7 +150,7 @@ def test_js_files_calling_debuglog_declare_the_global():
for path in sorted((STATIC / "v3").rglob("*.js")): for path in sorted((STATIC / "v3").rglob("*.js")):
if "vendor" in path.parts: if "vendor" in path.parts:
continue continue
text = path.read_text() text = path.read_text(encoding="utf-8")
# Calls debugLog( but neither defines it nor declares the global # Calls debugLog( but neither defines it nor declares the global
calls = re.search(r"(?<![.\w])debugLog\(", text) calls = re.search(r"(?<![.\w])debugLog\(", text)
defines = "window.debugLog" in text defines = "window.debugLog" in text
+73 -40
View File
@@ -1,66 +1,99 @@
"""Guard: every widget JS file must be loaded by base.html or explicitly allowlisted. """Guard: every widget JS file must ship in the widget bundle or be allowlisted.
Widget files register themselves with LEDMatrixWidgets at load time; a file Widget files register themselves with LEDMatrixWidgets at load time; a file
that exists but is never <script>-included silently breaks any plugin whose that exists but is never loaded silently breaks any plugin whose config schema
config schema declares that widget (the field renders as an empty container declares that widget (the field renders as an empty container that polls the
that polls the registry forever). base.html's widget list is maintained by registry forever).
hand, so this test keeps it honest.
base.html loads them as one concatenated request (web_interface/widget_bundle.py),
so BUNDLE_ORDER is the hand-maintained list this test keeps honest. It also
checks that base.html actually requests the bundle, and that the bundle's
concatenation order puts the registry and base class first.
""" """
import re import re
import sys
from pathlib import Path from pathlib import Path
from typing import Set from typing import Set
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
from web_interface import widget_bundle # noqa: E402
WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets' WIDGETS_DIR = PROJECT_ROOT / 'web_interface' / 'static' / 'v3' / 'js' / 'widgets'
BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html' BASE_HTML = PROJECT_ROOT / 'web_interface' / 'templates' / 'v3' / 'base.html'
# Matches url_for('static', filename='...') inside actual <script> tags. # Files that must NOT be loaded, with the reason.
SCRIPT_SRC_RE = re.compile( ALLOWLIST = set(widget_bundle.EXCLUDED)
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*filename='([^']+)'\s*\)\s*\}\}[^"]*"""
)
# Files that must NOT be script-included, with the reason.
ALLOWLIST = {
# Documentation example (docs/widget-guide.md); registers the name
# 'color-picker' and would shadow the real color-picker.js if loaded.
'example-color-picker.js',
}
def _included_widget_scripts() -> Set[str]: def _bundled_names() -> Set[str]:
"""Return widget JS basenames referenced by real <script> tags in base.html.""" return set(widget_bundle.BUNDLE_ORDER)
base_html = BASE_HTML.read_text(encoding='utf-8')
return {
Path(filename).name
for filename in SCRIPT_SRC_RE.findall(base_html)
if filename.startswith('v3/js/widgets/')
}
def test_every_widget_script_is_included_in_base_html() -> None: def test_every_widget_script_is_bundled() -> None:
"""Every non-allowlisted widget file must be loaded by a <script> tag.""" """Every non-allowlisted widget file must be in BUNDLE_ORDER."""
assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}' assert WIDGETS_DIR.is_dir(), f'Widget directory missing: {WIDGETS_DIR}'
included = _included_widget_scripts() bundled = _bundled_names()
assert included, 'No widget <script> tags found in base.html — regex or template drift?' assert bundled, 'BUNDLE_ORDER is empty — widget_bundle drift?'
missing = [ missing = [
js_file.name js_file.name
for js_file in sorted(WIDGETS_DIR.glob('*.js')) for js_file in sorted(WIDGETS_DIR.glob('*.js'))
if js_file.name not in ALLOWLIST and js_file.name not in included if js_file.name not in ALLOWLIST and js_file.name not in bundled
] ]
assert not missing, ( assert not missing, (
'Widget files exist but are never <script>-included in base.html ' 'Widget files exist but are never loaded (plugins declaring these '
'(plugins declaring these widgets get blank config fields): ' 'widgets get blank config fields): ' + ', '.join(missing)
+ ', '.join(missing) + '. Add the file to BUNDLE_ORDER in web_interface/widget_bundle.py, '
+ '. Add a script tag to base.html or add the file to ALLOWLIST ' 'or to EXCLUDED with a reason.'
'with a reason.'
) )
def test_allowlisted_widgets_are_not_included() -> None: def test_allowlisted_widgets_are_not_bundled() -> None:
"""Allowlisted (must-not-load) widget files must stay out of base.html.""" """Allowlisted (must-not-load) widget files stay out of the bundle."""
included = _included_widget_scripts() wrongly_included = sorted(ALLOWLIST & _bundled_names())
wrongly_included = [name for name in ALLOWLIST if name in included]
assert not wrongly_included, ( assert not wrongly_included, (
'Allowlisted (must-not-load) widget files are script-included in ' 'Allowlisted (must-not-load) widget files are in the bundle: '
'base.html: ' + ', '.join(wrongly_included) + ', '.join(wrongly_included)
) )
def test_bundle_order_lists_only_existing_files() -> None:
"""A renamed or deleted widget must not linger in BUNDLE_ORDER."""
stale = [name for name in widget_bundle.BUNDLE_ORDER
if not (WIDGETS_DIR / name).is_file()]
assert not stale, f'BUNDLE_ORDER names files that do not exist: {stale}'
def test_registry_and_base_widget_load_first() -> None:
"""Widgets call into the registry and base class as they load."""
order = widget_bundle.BUNDLE_ORDER
assert order[0] == 'registry.js', order[:3]
assert order[1] == 'base-widget.js', order[:3]
def test_base_html_requests_the_bundle() -> None:
"""base.html must load the bundle (and no longer tag widgets one by one)."""
base_html = BASE_HTML.read_text(encoding='utf-8')
assert 'widgets_bundle_url()' in base_html, (
'base.html does not request the widget bundle'
)
per_file = re.findall(
r"""<script\s[^>]*src="\{\{\s*url_for\(\s*'static'\s*,\s*"""
r"""filename='(v3/js/widgets/[^']+)'""",
base_html,
)
assert not per_file, (
'base.html still loads widget files individually alongside the '
f'bundle (they would run twice): {per_file}'
)
def test_bundle_concatenates_every_file() -> None:
"""The built bundle contains each file, separated so sources can't merge."""
body, version = widget_bundle.build_bundle()
assert version and version == widget_bundle.bundle_version()
for name in widget_bundle.BUNDLE_ORDER:
assert f'/* {name} */' in body, f'{name} missing from the built bundle'
for name in ALLOWLIST:
assert f'/* {name} */' not in body, f'{name} must not be bundled'
@@ -99,9 +99,15 @@ class TestTheRoutesExistAtAll:
assert "'google-oauth'" in allow_list_line[0], allow_list_line[0] assert "'google-oauth'" in allow_list_line[0], allow_list_line[0]
def test_the_widget_script_is_served(self): def test_the_widget_script_is_served(self):
# Widgets load through one bundle (web_interface/widget_bundle.py), so
# the page must request the bundle and the bundle must carry the file.
from web_interface import widget_bundle
base = (Path(project_root) / 'web_interface/templates/v3/base.html' base = (Path(project_root) / 'web_interface/templates/v3/base.html'
).read_text(encoding='utf-8') ).read_text(encoding='utf-8')
assert 'widgets/google-oauth.js' in base assert 'widgets_bundle_url()' in base
assert 'google-oauth.js' in widget_bundle.BUNDLE_ORDER
body, _version = widget_bundle.build_bundle()
assert '/* google-oauth.js */' in body
def test_the_status_line_is_announced(self): def test_the_status_line_is_announced(self):
# Every message the widget gives arrives after an async call, so a # Every message the widget gives arrives after an async call, so a
+68
View File
@@ -0,0 +1,68 @@
"""The stdlib gzip fallback must compress the UI's text assets.
flask-compress is optional (app.py tolerates its absence). Without a fallback a
Pi missing it ships ~1.2 MB of uncompressed JavaScript to a phone over WiFi on
every first load and every update. These pin the fallback's contract: text
assets compress and round-trip byte-for-byte, clients that don't ask for gzip
get the original bytes, and small or non-text responses are left alone.
"""
import gzip
from pathlib import Path
import pytest
STATIC = Path(__file__).resolve().parents[2] / "web_interface" / "static"
@pytest.fixture
def client(monkeypatch):
import web_interface.app as web_app
# Exercise the fallback even on machines that do have flask-compress.
monkeypatch.setattr(web_app, "_HAVE_FLASK_COMPRESS", False)
web_app.app.config["TESTING"] = True
with web_app.app.test_client() as c:
yield c
@pytest.mark.parametrize("url,path", [
("/static/v3/plugins_manager.js", "v3/plugins_manager.js"),
("/static/v3/app.css", "v3/app.css"),
])
def test_static_text_asset_is_gzipped_and_round_trips(client, url, path):
resp = client.get(url, headers={"Accept-Encoding": "gzip, deflate"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "Accept-Encoding" in resp.headers.get("Vary", "")
original = (STATIC / path).read_bytes()
assert gzip.decompress(resp.data) == original
assert len(resp.data) < len(original) / 2
assert int(resp.headers["Content-Length"]) == len(resp.data)
def test_repeat_request_serves_identical_compressed_bytes(client):
headers = {"Accept-Encoding": "gzip"}
first = client.get("/static/v3/js/app-shell.js", headers=headers).data
second = client.get("/static/v3/js/app-shell.js", headers=headers).data
assert first == second
def test_client_without_gzip_gets_the_original_bytes(client):
resp = client.get("/static/v3/app.css", headers={"Accept-Encoding": "identity"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
assert resp.data == (STATIC / "v3/app.css").read_bytes()
def test_binary_assets_are_not_recompressed(client):
resp = client.get("/static/v3/icons/icon-192.png", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert "Content-Encoding" not in resp.headers
def test_widget_bundle_is_gzipped_and_immutable(client):
resp = client.get("/assets/widgets.js", headers={"Accept-Encoding": "gzip"})
assert resp.status_code == 200
assert resp.headers.get("Content-Encoding") == "gzip"
assert "immutable" in resp.headers.get("Cache-Control", "")
assert b"/* registry.js */" in gzip.decompress(resp.data)
@@ -165,6 +165,27 @@ class TestPathTraversal:
assert "hunter2" not in r.get_data(as_text=True) assert "hunter2" not in r.get_data(as_text=True)
def test_a_symlinked_widgets_directory_cannot_escape(self, tmp_path, make_client):
"""widgets/ itself is contained before the script is resolved under it,
so a symlink out of the plugin can't become the containment base."""
outside = tmp_path / "outside"
outside.mkdir()
(outside / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
d = tmp_path / "soccer-scoreboard"
d.mkdir()
manifest = {"id": "soccer-scoreboard", "name": "soccer-scoreboard",
"version": "1.0.0",
"widgets": [{"name": "loot", "script": "loot.js"}]}
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
try:
(d / "widgets").symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("symlinks unavailable on this platform")
r = make_client().get(URL.format("soccer-scoreboard", "loot"))
assert r.status_code == 404
assert "hunter2" not in r.get_data(as_text=True)
class TestDegradation: class TestDegradation:
def test_an_unknown_plugin_is_404(self, tmp_path, make_client): def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
assert make_client().get(URL.format("nope", "w")).status_code == 404 assert make_client().get(URL.format("nope", "w")).status_code == 404
+78 -1
View File
@@ -4,6 +4,7 @@ import logging
import os import os
import queue import queue
import re import re
import gzip
import shutil import shutil
import sys import sys
import subprocess import subprocess
@@ -69,10 +70,12 @@ except ImportError:
# Enable gzip/brotli response compression (Flask-Compress skips streaming # Enable gzip/brotli response compression (Flask-Compress skips streaming
# responses, so the SSE endpoints are unaffected). Optional, like limiter: # responses, so the SSE endpoints are unaffected). Optional, like limiter:
# missing package just means uncompressed responses. # missing package just means uncompressed responses.
_HAVE_FLASK_COMPRESS = False
try: try:
from flask_compress import Compress from flask_compress import Compress
Compress(app) Compress(app)
_HAVE_FLASK_COMPRESS = True
except ImportError: except ImportError:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"flask-compress not installed - responses will be served uncompressed. " "flask-compress not installed - responses will be served uncompressed. "
@@ -500,6 +503,80 @@ def add_static_version(endpoint, values):
pass pass
# Gzip fallback for when flask-compress isn't installed. Without it the UI
# ships ~1.2 MB of uncompressed JS to a phone over WiFi. Compressed bytes are
# cached per URL+version, so the Pi compresses each asset once, not per request.
# URL-versioned assets: safe to cache as immutable (and to gzip once).
# /assets/ is the widget bundle from pages_v3 (also reachable under /v3).
_VERSIONED_ASSET_PREFIXES = ('/static/', '/assets/', '/v3/assets/')
_GZIP_MIN_BYTES = 1024
_GZIP_TYPES = (
'text/html', 'text/css', 'text/plain', 'text/javascript',
'application/javascript', 'application/json', 'image/svg+xml',
)
_GZIP_CACHE_MAX_BYTES = 4 * 1024 * 1024
_gzip_cache = {}
_gzip_cache_bytes = 0
_gzip_cache_lock = threading.Lock()
@app.after_request
def compress_text_responses(response):
"""Gzip text responses when flask-compress is absent (SSE untouched)."""
if _HAVE_FLASK_COMPRESS or response.status_code != 200:
return response
if 'Content-Encoding' in response.headers:
return response
# send_file responses report is_streamed (they wrap a file) but have a
# known size; genuinely streamed bodies (SSE, generators) are left alone.
# SSE is also excluded by its text/event-stream mimetype below.
if response.is_streamed and not response.direct_passthrough:
return response
mimetype = (response.mimetype or '').lower()
if mimetype not in _GZIP_TYPES:
return response
if 'gzip' not in (request.headers.get('Accept-Encoding') or '').lower():
return response
cache_key = None
if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
cache_key = (request.full_path, response.headers.get('Last-Modified'))
with _gzip_cache_lock:
cached = _gzip_cache.get(cache_key)
if cached is not None:
return _apply_gzip(response, cached)
# send_file responses stream from disk; materialize before compressing.
if response.direct_passthrough:
response.direct_passthrough = False
data = response.get_data()
if len(data) < _GZIP_MIN_BYTES:
return response
compressed = gzip.compress(data, compresslevel=6)
if len(compressed) >= len(data):
return response
if cache_key is not None:
global _gzip_cache_bytes
with _gzip_cache_lock:
if _gzip_cache_bytes + len(compressed) <= _GZIP_CACHE_MAX_BYTES:
_gzip_cache[cache_key] = compressed
_gzip_cache_bytes += len(compressed)
return _apply_gzip(response, compressed)
def _apply_gzip(response, compressed):
response.set_data(compressed)
response.headers['Content-Encoding'] = 'gzip'
response.headers['Content-Length'] = str(len(compressed))
if 'accept-encoding' not in (response.headers.get('Vary') or '').lower():
response.headers.add('Vary', 'Accept-Encoding')
etag = response.headers.get('ETag')
if etag and 'gzip' not in etag:
response.headers['ETag'] = etag.rstrip('"') + '-gzip"'
return response
# Add security headers and caching to all responses # Add security headers and caching to all responses
@app.after_request @app.after_request
def add_security_headers(response): def add_security_headers(response):
@@ -511,7 +588,7 @@ def add_security_headers(response):
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['X-XSS-Protection'] = '1; mode=block'
# Add caching headers for static assets # Add caching headers for static assets
if request.path.startswith('/static/'): if request.path.startswith(_VERSIONED_ASSET_PREFIXES):
# Cache static assets for 1 year (with versioning via query params) # Cache static assets for 1 year (with versioning via query params)
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT') response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT')
+31 -2
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, flash, jsonify from flask import Blueprint, Response, render_template, flash, jsonify, url_for
from jinja2 import TemplateNotFound from jinja2 import TemplateNotFound
from markupsafe import escape from markupsafe import escape
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -14,6 +14,7 @@ _SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$') _SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
from src.web_interface.secret_helpers import mask_secret_fields from src.web_interface.secret_helpers import mask_secret_fields
from src.common.path_safety import resolve_under, safe_path_component from src.common.path_safety import resolve_under, safe_path_component
from web_interface import widget_bundle
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,6 +27,30 @@ schema_manager = None
pages_v3 = Blueprint('pages_v3', __name__) pages_v3 = Blueprint('pages_v3', __name__)
@pages_v3.route('/assets/widgets.js')
def widgets_bundle():
"""Every widget script in one request (see web_interface/widget_bundle.py).
The individual files stay served from /static for plugin-loader.js and
debugging; this saves the Pi ~30 round trips on each first page load.
Cached as immutable by app.py because the URL carries the version.
"""
body, version = widget_bundle.build_bundle()
response = Response(body, mimetype='application/javascript')
response.headers['X-Widget-Bundle-Version'] = str(version)
return response
@pages_v3.app_context_processor
def inject_widget_bundle_url():
"""`widgets_bundle_url()` for templates, versioned by file mtime."""
return {
'widgets_bundle_url': lambda: url_for(
'pages_v3.widgets_bundle', v=widget_bundle.bundle_version()
)
}
class _SettingsIndexParser(HTMLParser): class _SettingsIndexParser(HTMLParser):
"""Extract searchable settings fields from a rendered partial's HTML. """Extract searchable settings fields from a rendered partial's HTML.
@@ -456,7 +481,11 @@ def serve_plugin_widget(plugin_id, widget_name):
# to ship an undeclared file is not something to confirm. # to ship an undeclared file is not something to confirm.
return 'Not found', 404, {'Content-Type': 'text/plain'} return 'Not found', 404, {'Content-Type': 'text/plain'}
widgets_dir = (plugin_dir / 'widgets').resolve() # Contain widgets/ itself first: a symlinked widgets directory would
# otherwise become the base the script is checked against.
widgets_dir = resolve_under(plugin_dir, 'widgets')
if widgets_dir is None:
return 'Not found', 404, {'Content-Type': 'text/plain'}
# The script name comes from the plugin's manifest, not the request, # The script name comes from the plugin's manifest, not the request,
# so it gets the same containment treatment the URL parts got. # so it gets the same containment treatment the URL parts got.
script_path = resolve_under(widgets_dir, script) script_path = resolve_under(widgets_dir, script)
+654 -70
View File
@@ -26,6 +26,9 @@
--color-info-bg: #dbeafe; --color-info-bg: #dbeafe;
--color-purple-bg: #f3e8ff; --color-purple-bg: #f3e8ff;
--color-purple-text: #6b21a8; --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-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: 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%); --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-info-bg: #1e3a5f;
--color-purple-bg: #2e1065; --color-purple-bg: #2e1065;
--color-purple-text: #c4b5fd; --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-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: 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%); --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); 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 */ /* Utility classes */
.bg-gray-50 { background-color: #f9fafb; } .bg-gray-50 { background-color: #f9fafb; }
.bg-gray-100 { background-color: #f3f4f6; } .bg-gray-100 { background-color: #f3f4f6; }
@@ -285,10 +362,10 @@ h4 { font-size: 1.125rem; }
.hover\:text-gray-700:hover { color: #374151; } .hover\:text-gray-700:hover { color: #374151; }
.hover\:border-gray-300:hover { border-color: #d1d5db; } .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 { .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, 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);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-shadow, 0 0 #0000);
} }
/* Optimized: Split complex selector onto multiple lines for readability */ /* Optimized: Split complex selector onto multiple lines for readability */
.focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; } .focus\:ring-blue-500:focus { --tw-ring-color: #3b82f6; }
@@ -353,12 +430,16 @@ a, button, input, select, textarea {
overflow-y: auto !important; 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 { .log-entry {
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
max-width: 100%; max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
content-visibility: auto;
contain-intrinsic-size: auto 1.5rem;
} }
/* Ensure proper containment of logs */ /* Ensure proper containment of logs */
@@ -398,17 +479,356 @@ a, button, input, select, textarea {
/* Logs container responsive height - simplified for better scrolling */ /* Logs container responsive height - simplified for better scrolling */
@media (max-width: 768px) { @media (max-width: 768px) {
#logs-container { #logs-container {
height: 400px !important; height: clamp(260px, 60vh, 480px) !important;
min-height: 300px !important; height: clamp(260px, 60svh, 480px) !important;
min-height: 0 !important;
} }
} }
@media (max-width: 640px) { /* ===== Utility layer (fills the Tailwind-style gaps) =====
#logs-container { Templates and JS are authored in Tailwind class names, but there is no
height: 350px !important; Tailwind build: this file is the whole stylesheet. Every utility class the
min-height: 250px !important; 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 */ /* Responsive breakpoints */
@media (min-width: 640px) { @media (min-width: 640px) {
@@ -416,6 +836,30 @@ a, button, input, select, textarea {
.sm\:block { display: block; } .sm\:block { display: block; }
.sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; } .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) { @media (min-width: 768px) {
@@ -601,8 +1045,9 @@ a, button, input, select, textarea {
opacity: 0.5; opacity: 0.5;
} }
/* Enhanced Button styles */ /* Enhanced Button styles. :where() keeps the base at zero specificity so
.btn { utility classes on the same element (px-3 py-1, rounded) take effect. */
:where(.btn) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -637,10 +1082,9 @@ a, button, input, select, textarea {
transform: none; transform: none;
} }
.btn:focus { .btn:focus-visible {
outline: 2px solid transparent; outline: 2px solid var(--color-focus-ring);
outline-offset: 2px; 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 */ /* Global button text contrast fix: Ensure buttons with white backgrounds have dark text */
@@ -661,14 +1105,16 @@ button.bg-white {
margin-bottom: 0.25rem; 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; display: block;
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db; border: 1px solid var(--color-field-border);
border-radius: 0.375rem; border-radius: 0.375rem;
background-color: #ffffff; background-color: var(--color-field-bg);
color: #111827; /* text-gray-900 - ensure dark text on white background */ color: var(--color-text-primary);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: 1.25rem;
transition: border-color 0.15s ease-in-out; transition: border-color 0.15s ease-in-out;
@@ -678,12 +1124,12 @@ button.bg-white {
.form-control:focus { .form-control:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
/* Using outline instead of box-shadow for focus state (better performance) */ /* Using outline instead of box-shadow for focus state (better performance) */
outline: 2px solid rgb(37 99 235 / 20%); outline: 2px solid var(--color-focus-ring);
outline-offset: 2px; outline-offset: 1px;
} }
.form-control:disabled { .form-control:disabled {
background-color: #f9fafb; background-color: var(--color-background);
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -718,28 +1164,12 @@ button.bg-white {
/* Added CSS containment for better performance isolation */ /* 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 { .plugin-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
border-color: var(--color-primary); border-color: var(--color-primary);
/* Removed box-shadow transition for better performance */ /* Removed box-shadow transition for better performance */
} }
.plugin-card:hover::before {
opacity: 1;
}
.plugin-card:focus { .plugin-card:focus {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
@@ -1080,7 +1510,7 @@ button.bg-white {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
line-height: 1.5; line-height: 1.5;
color: #374151; /* text-gray-700 for better readability */ color: var(--color-text-secondary);
white-space: nowrap; white-space: nowrap;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
cursor: pointer; cursor: pointer;
@@ -1097,30 +1527,30 @@ button.bg-white {
/* Inactive state - improved contrast */ /* Inactive state - improved contrast */
.nav-tab:not(.nav-tab-active) { .nav-tab:not(.nav-tab-active) {
color: #374151; /* text-gray-700 */ color: var(--color-text-secondary);
} }
.nav-tab:not(.nav-tab-active) i { .nav-tab:not(.nav-tab-active) i {
color: #374151; /* text-gray-700 */ color: var(--color-text-secondary);
} }
/* Hover state - enhanced visibility */ /* Hover state - enhanced visibility */
.nav-tab:not(.nav-tab-active):hover { .nav-tab:not(.nav-tab-active):hover {
color: #111827; /* text-gray-900 */ color: var(--color-text-primary);
background-color: #f3f4f6; /* bg-gray-100 */ background-color: var(--color-border-light);
border-bottom-color: #d1d5db; /* border-gray-300 */ border-bottom-color: var(--color-border);
} }
.nav-tab:not(.nav-tab-active):hover i { .nav-tab:not(.nav-tab-active):hover i {
color: #111827; /* text-gray-900 */ color: var(--color-text-primary);
} }
/* Active state - prominent with gradient background */ /* Active state - prominent with gradient background */
.nav-tab-active { .nav-tab-active {
color: #1d4ed8; /* text-blue-700 */ color: var(--color-primary-hover);
font-weight: 600; font-weight: 600;
border-bottom-width: 3px; 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%); 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%); box-shadow: 0 2px 4px rgb(37 99 235 / 10%);
} }
@@ -1155,17 +1585,6 @@ button.bg-white {
} }
/* Form controls */ /* 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 { [data-theme="dark"] .form-control:disabled {
background-color: var(--color-surface); 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-800 { color: #6ee7b7; }
[data-theme="dark"] .text-green-700 { 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 button */
.theme-toggle-btn { .theme-toggle-btn {
display: inline-flex; display: inline-flex;
@@ -1432,10 +1929,10 @@ button.bg-white {
position: sticky; position: sticky;
left: 0; left: 0;
z-index: 1; z-index: 1;
background-color: #ffffff; background-color: var(--color-surface, #ffffff);
} }
.style-editor-head > :first-child { .style-editor-head > :first-child {
background-color: #f9fafb; background-color: var(--color-background, #f9fafb);
} }
.style-editor-row { .style-editor-row {
/* Set by the widget: the column count depends on what the plugin /* Set by the widget: the column count depends on what the plugin
@@ -1455,27 +1952,27 @@ button.bg-white {
width: 100%; width: 100%;
} }
.style-editor-row input[type="number"]:disabled { .style-editor-row input[type="number"]:disabled {
background-color: #f3f4f6; background-color: var(--color-border-light, #f3f4f6);
color: #9ca3af; color: var(--color-text-tertiary, #9ca3af);
cursor: not-allowed; cursor: not-allowed;
} }
.style-editor-colour { .style-editor-colour {
width: 2rem; width: 2rem;
height: 1.6rem; height: 1.6rem;
padding: 0; padding: 0;
border: 1px solid #d1d5db; border: 1px solid var(--color-field-border, #d1d5db);
border-radius: 0.25rem; border-radius: 0.25rem;
background: none; background: none;
cursor: pointer; cursor: pointer;
} }
.style-editor-tab { .style-editor-tab {
border: 1px solid #d1d5db; border: 1px solid var(--color-field-border, #d1d5db);
background-color: #f9fafb; background-color: var(--color-background, #f9fafb);
color: #374151; color: var(--color-text-secondary, #374151);
} }
.style-editor-tab.is-active { .style-editor-tab.is-active {
background-color: #2563eb; background-color: var(--color-primary, #2563eb);
border-color: #2563eb; border-color: var(--color-primary, #2563eb);
color: #ffffff; color: #ffffff;
} }
.style-editor-panel[hidden] { .style-editor-panel[hidden] {
@@ -1494,3 +1991,90 @@ button.bg-white {
font-weight: 600; 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;
}
}
+25 -37
View File
@@ -2,20 +2,28 @@
// LED Matrix v3 JavaScript // LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration // Additional helpers for HTMX and Alpine.js integration
// Global notification system // Global notification system — implemented by widgets/notification.js.
window.showNotification = function(message, type = 'info') { // This fallback only exists if nothing defined showNotification earlier; it
// Use Alpine.js notification if available // hands off to the widget when registered, otherwise queues the message for
if (window.Alpine) { // the widget to show once it loads.
// This would trigger the Alpine.js notification system if (typeof window.showNotification !== 'function') {
const event = new CustomEvent('show-notification', { 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 } detail: { message, type }
}); }));
document.dispatchEvent(event); // User-facing last resort, so never gated
} else { console.info(`${(type && type.type) || type}: ${message}`);
// Fallback notification — user-facing last resort, so never gated };
console.info(`${type}: ${message}`); }
}
};
// HTMX response handlers // HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) { document.body.addEventListener('htmx:beforeRequest', function(event) {
@@ -180,30 +188,8 @@ document.addEventListener('DOMContentLoaded', function() {
} catch { /* no-op */ } } catch { /* no-op */ }
}); });
// SSE reconnection helper — closes and reopens both SSE streams, // SSE streams (and window.reconnectSSE) are owned by window.LEDStreams in
// reattaching the open/error handlers defined in base.html. // js/app-shell.js — do not open EventSources for stats/display here.
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);
}
};
// Utility functions // Utility functions
window.hexToRgb = function(hex) { window.hexToRgb = function(hex) {
@@ -462,6 +448,8 @@ window.updateFloatingPreviewVisibility = function(tab) {
img.src = 'data:image/png;base64,' + window._lastPreviewFrame; 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() { document.addEventListener('DOMContentLoaded', function() {
+197 -31
View File
@@ -1,24 +1,16 @@
/* global debugLog */ /* global debugLog */
// SSE wiring + full Alpine app() implementation and tab logic // SSE wiring + full Alpine app() implementation and tab logic
// Extracted from templates/v3/base.html so browsers cache it as a static asset. // 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. function _setConnectionStatus(connected, reconnecting, paused) {
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) {
const el = document.getElementById('connection-status'); const el = document.getElementById('connection-status');
if (!el) return; 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 = ` el.innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div> <div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span> <span class="text-gray-600">Connected</span>
@@ -38,8 +30,8 @@
var _statsErrorCount = 0; var _statsErrorCount = 0;
// Named on window so reconnectSSE() in app.js can reattach them after // Kept on window for backward compatibility; LEDStreams attaches them
// replacing the EventSource instances. // to every EventSource it opens.
window._statsOpenHandler = function() { window._statsOpenHandler = function() {
_statsErrorCount = 0; _statsErrorCount = 0;
_setConnectionStatus(true, false); _setConnectionStatus(true, false);
@@ -56,9 +48,173 @@
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')'); console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
}; };
window.statsSource.addEventListener('open', window._statsOpenHandler); // ===== Tab/page visibility — shared by partials =====
window.statsSource.addEventListener('error', window._statsErrorHandler); // LEDVisibility.onActive(tab, start, stop[, key]) runs start() when
window.displaySource.addEventListener('error', window._displayErrorHandler); // `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 // Reset any time the currently-active warning clears, so a future
// (new) occurrence shows the banner again even if this one was dismissed. // (new) occurrence shows the banner again even if this one was dismissed.
@@ -323,6 +479,10 @@
if (typeof window.updateFloatingPreviewVisibility === 'function') { if (typeof window.updateFloatingPreviewVisibility === 'function') {
window.updateFloatingPreviewVisibility(newTab); 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 // Trigger content load when tab changes
this.$nextTick(() => { this.$nextTick(() => {
this.loadTabContent(newTab); this.loadTabContent(newTab);
@@ -335,6 +495,9 @@
if (typeof window.updateNavAriaCurrent === 'function') { if (typeof window.updateNavAriaCurrent === 'function') {
window.updateNavAriaCurrent(this.activeTab); window.updateNavAriaCurrent(this.activeTab);
} }
document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', {
detail: { tab: this.activeTab, previous: null }
}));
}); });
// Listen for plugin updates from pluginManager // Listen for plugin updates from pluginManager
@@ -1878,18 +2041,21 @@
}); });
}; };
// showNotification is provided by notification.js widget // showNotification is implemented by the notification.js widget.
// This fallback is only used if the widget hasn't loaded yet // 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') { if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') { window.showNotification = function(message, type = 'info') {
debugLog(`[${type.toUpperCase()}]`, message); const registry = window.LEDMatrixWidgets;
const notification = document.createElement('div'); const widget = registry && typeof registry.get === 'function' ? registry.get('notification') : null;
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${ if (widget && typeof widget.show === 'function') {
type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500' return widget.show(message, typeof type === 'string' ? { type: type } : (type || {}));
} text-white`; }
notification.textContent = message; if (!Array.isArray(window.__pendingNotifications)) {
document.body.appendChild(notification); window.__pendingNotifications = [];
setTimeout(() => { notification.style.opacity = '0'; setTimeout(() => notification.remove(), 500); }, 3000); }
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.className = 'fixed inset-0 z-50 overflow-y-auto';
modalContainer.innerHTML = ` 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="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="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" data-diff-backdrop aria-hidden="true"></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 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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <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> <i class="fas fa-code-branch text-blue-600"></i>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1"> <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> <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"> <div class="mt-4 max-h-96 overflow-y-auto" tabindex="0" role="region" aria-label="Changes to save">
${this.formatDiff(differences)} ${this.formatDiff(differences)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <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"> 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>
<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"> 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 Cancel
</button> </button>
@@ -240,27 +240,41 @@ const ConfigDiffViewer = {
</div> </div>
</div> </div>
`; `;
document.body.appendChild(modalContainer); document.body.appendChild(modalContainer);
// Store resolve function globally (hack for onclick handlers) let release = null;
window.__configDiffResolve = resolve; 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 // Attach event listeners
const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn'); const confirmBtn = modalContainer.querySelector('#config-diff-confirm-btn');
const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn'); const cancelBtn = modalContainer.querySelector('#config-diff-cancel-btn');
const backdrop = modalContainer.querySelector('[data-diff-backdrop]');
confirmBtn.addEventListener('click', () => {
modalContainer.remove(); confirmBtn.addEventListener('click', () => finish(true));
delete window.__configDiffResolve; cancelBtn.addEventListener('click', () => finish(false));
resolve(true); if (backdrop) backdrop.addEventListener('click', () => finish(false));
});
if (window.LEDDialog) {
cancelBtn.addEventListener('click', () => { release = window.LEDDialog.trap(modalContainer.querySelector('#config-diff-modal-panel'), {
modalContainer.remove(); labelledBy: 'config-diff-modal-title',
delete window.__configDiffResolve; initialFocus: confirmBtn,
resolve(false); onEscape: () => finish(false)
}); });
}
}); });
}, },
@@ -134,9 +134,7 @@
function openResults() { function openResults() {
resultsBox.classList.remove('hidden'); resultsBox.classList.remove('hidden');
// .hidden has no effect without a matching CSS rule (this app's stylesheet // Clear any inline display left by closeResults(); the class does the rest.
// is a hand-picked utility subset, not full Tailwind) - force it directly,
// same as the revealNode/collapseNode fallback below.
resultsBox.style.display = ''; resultsBox.style.display = '';
if (input) input.setAttribute('aria-expanded', 'true'); if (input) input.setAttribute('aria-expanded', 'true');
} }
+139
View File
@@ -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 };
})();
+117 -78
View File
@@ -7,95 +7,97 @@
/** /**
* Comprehensive error code to user-friendly message mapping. * Comprehensive error code to user-friendly message mapping.
* Used only when the server response carries no message of its own.
*/ */
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
// Configuration errors // Configuration errors
'CONFIG_SAVE_FAILED': 'Failed to save configuration', 'CONFIG_SAVE_FAILED': "Couldn't save your settings. Check the values and try again",
'CONFIG_LOAD_FAILED': 'Failed to load configuration', 'CONFIG_LOAD_FAILED': "Couldn't load your settings. Reload the page to try again",
'CONFIG_VALIDATION_FAILED': 'Configuration validation failed', 'CONFIG_VALIDATION_FAILED': 'Some settings have invalid values. Fix the highlighted fields and save again',
'CONFIG_ROLLBACK_FAILED': 'Failed to rollback configuration', 'CONFIG_ROLLBACK_FAILED': "Couldn't restore the previous settings. Try again, or restore from Backup & Restore",
// Plugin errors // Plugin errors
'PLUGIN_NOT_FOUND': 'Plugin not found', 'PLUGIN_NOT_FOUND': "That plugin isn't installed. Check the Plugin Manager",
'PLUGIN_INSTALL_FAILED': 'Failed to install plugin', 'PLUGIN_INSTALL_FAILED': "Couldn't install the plugin. Check the Pi's internet connection and try again",
'PLUGIN_UPDATE_FAILED': 'Failed to update plugin', 'PLUGIN_UPDATE_FAILED': "Couldn't update the plugin. Try again in a moment",
'PLUGIN_UNINSTALL_FAILED': 'Failed to uninstall plugin', 'PLUGIN_UNINSTALL_FAILED': "Couldn't uninstall the plugin. Try again in a moment",
'PLUGIN_LOAD_FAILED': 'Failed to load plugin', 'PLUGIN_LOAD_FAILED': "The plugin couldn't start. Check the Logs tab for details",
'PLUGIN_OPERATION_CONFLICT': 'Plugin operation conflict - another operation is in progress', 'PLUGIN_OPERATION_CONFLICT': 'Another plugin operation is still running. Wait for it to finish, then try again',
// Validation errors // Validation errors
'VALIDATION_ERROR': 'Validation error', 'VALIDATION_ERROR': 'Some values are invalid. Fix them and try again',
'SCHEMA_VALIDATION_FAILED': 'Configuration schema validation failed', 'SCHEMA_VALIDATION_FAILED': "Some settings don't match what the plugin expects. Fix them and save again",
'INVALID_INPUT': 'Invalid input provided', 'INVALID_INPUT': "That value isn't valid. Check it and try again",
// Network errors // Network errors
'NETWORK_ERROR': 'Network error occurred', 'NETWORK_ERROR': "Couldn't reach the LEDMatrix device. Check that it's on and connected, then try again",
'API_ERROR': 'API request failed', 'API_ERROR': 'The request failed. Try again in a moment',
'TIMEOUT': 'Operation timed out', 'TIMEOUT': 'This took too long to respond. Try again in a moment',
// Permission errors // Permission errors
'PERMISSION_DENIED': 'Permission denied', 'PERMISSION_DENIED': "LEDMatrix doesn't have permission to do that. See the troubleshooting guide",
'FILE_PERMISSION_ERROR': 'File permission error', 'FILE_PERMISSION_ERROR': "LEDMatrix can't write a file it needs. See the troubleshooting guide",
// System errors // System errors
'SYSTEM_ERROR': 'System error occurred', 'SYSTEM_ERROR': 'Something went wrong on the device. Check the Logs tab for details',
'SERVICE_UNAVAILABLE': 'Service unavailable', 'SERVICE_UNAVAILABLE': 'The LEDMatrix service is not running. Restart it from the Overview tab',
// Unknown errors // 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. * Error code to troubleshooting documentation links.
*/ */
const TROUBLESHOOTING_URL = 'https://github.com/ChuckBuilds/LEDMatrix/blob/main/docs/TROUBLESHOOTING.md';
const ERROR_DOCS = { const ERROR_DOCS = {
'CONFIG_SAVE_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#configuration-errors', 'CONFIG_SAVE_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'CONFIG_VALIDATION_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#validation-errors', 'CONFIG_VALIDATION_FAILED': TROUBLESHOOTING_URL + '#4-check-configuration',
'PLUGIN_INSTALL_FAILED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-installation', 'PLUGIN_INSTALL_FAILED': TROUBLESHOOTING_URL + '#plugin-issues',
'PLUGIN_OPERATION_CONFLICT': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#plugin-operations', 'PLUGIN_OPERATION_CONFLICT': TROUBLESHOOTING_URL + '#plugin-issues',
'PERMISSION_DENIED': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions', 'PERMISSION_DENIED': TROUBLESHOOTING_URL + '#permission-issues',
'FILE_PERMISSION_ERROR': 'https://github.com/your-repo/LEDMatrix/wiki/Troubleshooting#permissions' 'FILE_PERMISSION_ERROR': TROUBLESHOOTING_URL + '#permission-issues'
}; };
/** /**
* Format error message for display to user. * Format error message for display to user.
* *
* @param {Object} error - Error object from API response * @param {Object} error - Error object from API response
* @returns {string} Formatted error message * @returns {string} Formatted error message
*/ */
function formatError(error) { function formatError(error) {
if (!error) { if (!error) {
return 'An unknown error occurred'; return ERROR_MESSAGES.UNKNOWN_ERROR;
} }
// If error is a string, return it // If error is a string, return it
if (typeof error === 'string') { if (typeof error === 'string') {
return error; return error;
} }
// If error has a message, use it // If error has a message, use it
if (error.message) { if (error.message) {
return error.message; return error.message;
} }
// If error has error_code, format it // If error has error_code, format it
if (error.error_code) { 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 // Add details if available
if (error.details) { if (error.details) {
return `${message}: ${error.details}`; return `${message}: ${error.details}`;
} }
return message; return message;
} }
return 'An error occurred'; return ERROR_MESSAGES.UNKNOWN_ERROR;
} }
/** /**
* Get suggested fixes for an error. * Get suggested fixes for an error.
* *
* @param {Object} error - Error object from API response * @param {Object} error - Error object from API response
* @returns {Array<string>} Array of suggested fixes * @returns {Array<string>} Array of suggested fixes
*/ */
@@ -103,13 +105,13 @@ function getSuggestedFixes(error) {
if (!error || !error.suggested_fixes) { if (!error || !error.suggested_fixes) {
return []; return [];
} }
return error.suggested_fixes; return error.suggested_fixes;
} }
/** /**
* Display error with suggestions in a rich UI. * Display error with suggestions in a rich UI.
* *
* @param {Object} error - Error object from API response * @param {Object} error - Error object from API response
* @param {string} context - Optional context about what was being done * @param {string} context - Optional context about what was being done
* @param {Object} options - Display options * @param {Object} options - Display options
@@ -121,19 +123,19 @@ function displayError(error, context = null, options = {}) {
const suggestions = getSuggestedFixes(error); const suggestions = getSuggestedFixes(error);
const errorCode = error?.error_code; const errorCode = error?.error_code;
const docLink = errorCode ? ERROR_DOCS[errorCode] : null; const docLink = errorCode ? ERROR_DOCS[errorCode] : null;
// Build full message // Build full message
let fullMessage = message; let fullMessage = message;
if (context) { if (context) {
fullMessage = `${context}: ${message}`; fullMessage = `${context}: ${message}`;
} }
if (suggestions.length > 0) { if (suggestions.length > 0) {
fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n'); fullMessage += '\n\nSuggested fixes:\n' + suggestions.map(s => `• ${s}`).join('\n');
} }
// If showDetails is true, show a rich error modal // 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); showErrorModal(error, context, message, suggestions, docLink);
} else { } else {
// Simple notification // 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. * Show a rich error modal with details, suggestions, and copy button.
* *
* @param {Object} error - Error object * @param {Object} error - Error object
* @param {string} context - Context * @param {string} context - Context
* @param {string} message - Formatted message * @param {string} message - Formatted message
@@ -156,6 +161,9 @@ function displayError(error, context = null, options = {}) {
* @param {string} docLink - Documentation link * @param {string} docLink - Documentation link
*/ */
function showErrorModal(error, context, message, suggestions, docLink) { function showErrorModal(error, context, message, suggestions, docLink) {
error = error || {};
suggestions = suggestions || [];
// Create modal container if it doesn't exist // Create modal container if it doesn't exist
let modalContainer = document.getElementById('error-modal-container'); let modalContainer = document.getElementById('error-modal-container');
if (!modalContainer) { if (!modalContainer) {
@@ -165,7 +173,13 @@ function showErrorModal(error, context, message, suggestions, docLink) {
modalContainer.style.display = 'none'; modalContainer.style.display = 'none';
document.body.appendChild(modalContainer); document.body.appendChild(modalContainer);
} }
// Re-opening while open: release the previous trap before replacing it.
if (errorModalRelease) {
errorModalRelease();
errorModalRelease = null;
}
// Build modal content // Build modal content
const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : ''; const contextText = context ? `<div class="text-sm text-gray-600 mb-2">${escapeHtml(context)}</div>` : '';
const suggestionsHtml = suggestions.length > 0 ? ` const suggestionsHtml = suggestions.length > 0 ? `
@@ -176,15 +190,15 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</ul> </ul>
</div> </div>
` : ''; ` : '';
const docLinkHtml = docLink ? ` const docLinkHtml = docLink ? `
<div class="mt-4"> <div class="mt-4">
<a href="${docLink}" target="_blank" class="text-blue-600 hover:text-blue-800 text-sm underline"> <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"></i>View troubleshooting guide <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> </a>
</div> </div>
` : ''; ` : '';
const detailsHtml = error.details ? ` const detailsHtml = error.details ? `
<div class="mt-4"> <div class="mt-4">
<details class="cursor-pointer"> <details class="cursor-pointer">
@@ -193,26 +207,26 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</details> </details>
</div> </div>
` : ''; ` : '';
const errorCodeHtml = error.error_code ? ` const errorCodeHtml = error.error_code ? `
<div class="mt-2 text-xs text-gray-500"> <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> Error code: <code class="bg-gray-100 px-1 py-0.5 rounded">${escapeHtml(error.error_code)}</code>
</div> </div>
` : ''; ` : '';
modalContainer.innerHTML = ` 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="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="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" 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 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="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start"> <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> <i class="fas fa-exclamation-triangle text-red-600"></i>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1"> <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> <h3 id="error-modal-title" class="text-lg leading-6 font-medium text-gray-900">Something went wrong</h3>
<div class="mt-2"> <div class="mt-2" id="error-modal-description">
${contextText} ${contextText}
<p class="text-sm text-gray-500">${escapeHtml(message)}</p> <p class="text-sm text-gray-500">${escapeHtml(message)}</p>
${errorCodeHtml} ${errorCodeHtml}
@@ -224,11 +238,11 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</div> </div>
</div> </div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> <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"> 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>
<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"> 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 Close
</button> </button>
@@ -236,7 +250,7 @@ function showErrorModal(error, context, message, suggestions, docLink) {
</div> </div>
</div> </div>
`; `;
// Attach event listener to copy button // Attach event listener to copy button
const copyBtn = modalContainer.querySelector('#error-modal-copy-btn'); const copyBtn = modalContainer.querySelector('#error-modal-copy-btn');
if (copyBtn) { if (copyBtn) {
@@ -244,8 +258,20 @@ function showErrorModal(error, context, message, suggestions, docLink) {
copyErrorDetails(error); copyErrorDetails(error);
}); });
} }
modalContainer.style.display = 'block'; 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) { if (modalContainer) {
modalContainer.style.display = 'none'; modalContainer.style.display = 'none';
} }
if (errorModalRelease) {
const release = errorModalRelease;
errorModalRelease = null;
release();
}
} }
/** /**
@@ -277,27 +308,35 @@ function escapeHtml(text) {
/** /**
* Copy error details to clipboard. * Copy error details to clipboard.
* *
* @param {Object} error - Error object from API response * @param {Object} error - Error object from API response
*/ */
function copyErrorDetails(error) { function copyErrorDetails(error) {
const errorText = JSON.stringify(error, null, 2); 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) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(errorText).then(() => { navigator.clipboard.writeText(errorText).then(() => {
if (typeof showNotification === 'function') { if (typeof showNotification === 'function') {
showNotification('Error details copied to clipboard', 'success'); showNotification('Error details copied to clipboard', 'success');
} }
}).catch(err => { }).catch(copyFailed);
console.error('Failed to copy error details:', err);
});
} else { } else {
// Fallback for older browsers // Fallback for older browsers
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = errorText; textArea.value = errorText;
textArea.style.position = 'fixed'; textArea.style.position = 'fixed';
textArea.style.opacity = '0'; 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(); textArea.select();
try { try {
document.execCommand('copy'); document.execCommand('copy');
@@ -305,9 +344,10 @@ function copyErrorDetails(error) {
showNotification('Error details copied to clipboard', 'success'); showNotification('Error details copied to clipboard', 'success');
} }
} catch (err) { } 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 ERROR_DOCS
}; };
} }
@@ -415,7 +415,8 @@
removeBtn.type = 'button'; removeBtn.type = 'button';
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1'; removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
removeBtn.onclick = function() { window.removeArrayTableRow(this); }; 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); actionsCell.appendChild(removeBtn);
if (hasAdvanced) { if (hasAdvanced) {
@@ -424,7 +425,8 @@
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1'; editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
editBtn.title = 'Edit advanced properties (layout, style…)'; editBtn.title = 'Edit advanced properties (layout, style…)';
editBtn.onclick = function() { window.openArrayTableRowEditor(this); }; 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); actionsCell.appendChild(editBtn);
} }
@@ -446,9 +448,8 @@
if (!advancedCell) return; if (!advancedCell) return;
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}'); const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
// Close any existing modal // Close any existing modal (also releases its focus trap)
const existing = document.getElementById('array-row-editor-modal'); window.closeArrayTableRowEditor();
if (existing) existing.remove();
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'array-row-editor-modal'; overlay.id = 'array-row-editor-modal';
@@ -463,9 +464,10 @@
// Header // Header
safeSetHTML(dialog, ` safeSetHTML(dialog, `
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200"> <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()" <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>`); </div>`);
const body = document.createElement('div'); const body = document.createElement('div');
@@ -558,11 +560,27 @@
dialog.appendChild(footer); dialog.appendChild(footer);
overlay.appendChild(dialog); overlay.appendChild(dialog);
document.body.appendChild(overlay); 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() { window.closeArrayTableRowEditor = function() {
const modal = document.getElementById('array-row-editor-modal'); const modal = document.getElementById('array-row-editor-modal');
if (modal) modal.remove(); if (modal) modal.remove();
if (rowEditorRelease) {
const release = rowEditorRelease;
rowEditorRelease = null;
release();
}
}; };
/** /**
@@ -107,6 +107,8 @@
html += ` html += `
<input type="color" <input type="color"
id="${fieldId}_color" id="${fieldId}_color"
title="Choose color"
aria-label="Choose color"
value="${currentValue}" value="${currentValue}"
${disabled ? 'disabled' : ''} ${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')" onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onColorChange('${fieldId}')"
@@ -122,6 +124,7 @@
id="${fieldId}_hex" id="${fieldId}_hex"
value="${currentValue.substring(1)}" value="${currentValue.substring(1)}"
maxlength="6" maxlength="6"
aria-label="Hex color code"
${disabled ? 'disabled' : ''} ${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')" onchange="window.LEDMatrixWidgets.getHandlers('color-picker').onHexChange('${fieldId}')"
oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')" oninput="window.LEDMatrixWidgets.getHandlers('color-picker').onHexInput('${fieldId}')"
@@ -161,7 +164,8 @@
onclick="window.LEDMatrixWidgets.getHandlers('color-picker').onPresetClick('${fieldId}', this.dataset.color)" 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'}" 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)};" style="background-color: ${escapeHtml(normalized)};"
title="${escapeHtml(normalized)}"> title="${escapeHtml(normalized)}"
aria-label="Use color ${escapeHtml(normalized)}">
</button> </button>
`; `;
} }
@@ -85,7 +85,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('date-picker').onChange('${fieldId}')" 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"> 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"> <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>
</div> </div>
`; `;
@@ -96,8 +96,8 @@
id="${fieldId}_clear" id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('date-picker').onClear('${fieldId}')" 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'}" class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear"> title="Clear" aria-label="Clear date">
<i class="fas fa-times"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
`; `;
} }
@@ -102,8 +102,8 @@
</div>`; </div>`;
html += `<button type="button" html += `<button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')" 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"> 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"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button>`; </button>`;
html += '</div>'; html += '</div>';
@@ -840,7 +840,7 @@
<!-- Schedule Mode --> <!-- Schedule Mode -->
<div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};"> <div id="schedule_options_${sanitizedId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div> <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}" <select id="schedule_mode_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}" data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}" data-image-id="${sanitizedId}"
@@ -855,8 +855,8 @@
<!-- Time Range Mode --> <!-- Time Range Mode -->
<div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};"> <div id="time_range_${sanitizedId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label> <label for="schedule_start_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
<input type="time" <input type="time"
id="schedule_start_${sanitizedId}" id="schedule_start_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}" data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}" data-image-id="${sanitizedId}"
@@ -865,8 +865,8 @@
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md"> class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label> <label for="schedule_end_${sanitizedId}" class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
<input type="time" <input type="time"
id="schedule_end_${sanitizedId}" id="schedule_end_${sanitizedId}"
data-field-id="${escapeHtml(fieldId)}" data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}" 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'};"> <div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${sanitizedId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time" <input type="time"
id="day_${day}_start_${sanitizedId}" id="day_${day}_start_${sanitizedId}"
aria-label="${day} start time"
data-field-id="${escapeHtml(fieldId)}" data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}" data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}" data-image-idx="${imageIdx}"
@@ -909,6 +910,7 @@
${!dayConfig.enabled ? 'disabled' : ''}> ${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time" <input type="time"
id="day_${day}_end_${sanitizedId}" id="day_${day}_end_${sanitizedId}"
aria-label="${day} end time"
data-field-id="${escapeHtml(fieldId)}" data-field-id="${escapeHtml(fieldId)}"
data-image-id="${sanitizedId}" data-image-id="${sanitizedId}"
data-image-idx="${imageIdx}" data-image-idx="${imageIdx}"
@@ -57,6 +57,8 @@
this._editFile = null; this._editFile = null;
this._deleteFile = null; this._deleteFile = null;
this._keyHandler = this._onKey.bind(this); this._keyHandler = this._onKey.bind(this);
// Focus-trap release functions for open modals (utils/dialog.js)
this._releases = new Map();
this._inject(); this._inject();
this._bind(); this._bind();
@@ -67,9 +69,30 @@
_destroy() { _destroy() {
document.removeEventListener('keydown', this._keyHandler); document.removeEventListener('keydown', this._keyHandler);
for (const key of Array.from(this._releases.keys())) this._release(key);
this.el._jfmInstance = null; 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 ──────────────────────────────────────────────────── // ── DOM Injection ────────────────────────────────────────────────────
_inject() { _inject() {
@@ -108,21 +131,22 @@
</div>` : ''} </div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── --> <!-- ── 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-box jfm-modal-wide">
<div class="jfm-modal-head"> <div class="jfm-modal-head">
<span id="${u}-edit-title" class="jfm-modal-title">Edit file</span> <span id="${u}-edit-title" class="jfm-modal-title">Edit file</span>
<div class="jfm-modal-tools"> <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="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-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">&times;</button> <button type="button" class="jfm-close-btn" data-jfm="close-edit" aria-label="Close editor">&times;</button>
</div> </div>
</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" <textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off" spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="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"> <div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span> <span id="${u}-charcount" class="jfm-stat"></span>
<button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button> <button type="button" class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
@@ -133,10 +157,10 @@
<!-- ── Delete modal ───────────────────────────────────── --> <!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? ` ${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-box">
<div class="jfm-modal-head"> <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">&times;</button> <button type="button" class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div> </div>
<div class="jfm-modal-body"> <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> <p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div> </div>
<div class="jfm-modal-foot"> <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> <button type="button" class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div> </div>
</div> </div>
@@ -152,10 +176,10 @@
<!-- ── Create modal ───────────────────────────────────── --> <!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? ` ${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-box">
<div class="jfm-modal-head"> <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">&times;</button> <button type="button" class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div> </div>
<div class="jfm-modal-body"> <div class="jfm-modal-body">
@@ -189,93 +213,106 @@
} }
_css(u) { _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> 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;} #${u} *{box-sizing:border-box;}
/* Header */ /* Header */
#${u} .jfm-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:.875rem;gap:.5rem;} #${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-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-title{font-size:.9375rem;font-weight:600;color:var(--color-text-primary,#111827);}
#${u} .jfm-dir{font-size:.75rem;color:#6b7280;background:#f3f4f6;padding:.125rem .375rem;border-radius:.25rem;font-family:monospace;} #${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;} #${u} .jfm-header-right{display:flex;gap:.375rem;align-items:center;flex-shrink:0;}
/* Buttons */ /* 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{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:#f9fafb;border-color:#9ca3af;} #${u} .jfm-btn:hover:not(:disabled){background:var(--color-background,#f9fafb);border-color:var(--jfm-border-hover);}
#${u} .jfm-btn:focus-visible{outline:2px solid #3b82f6;outline-offset:1px;} #${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:disabled{opacity:.5;cursor:not-allowed;}
#${u} .jfm-btn-sm{padding:.3125rem .625rem;font-size:.8125rem;} #${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{background:var(--color-primary,#3b82f6);border-color:var(--color-primary,#3b82f6);color:#fff;}
#${u} .jfm-btn-primary:hover:not(:disabled){background:#2563eb;border-color:#2563eb;} #${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:#ef4444;border-color:#ef4444;color:#fff;} #${u} .jfm-btn-danger{background:#dc2626;border-color:#dc2626;color:#fff;}
#${u} .jfm-btn-danger:hover:not(:disabled){background:#dc2626;border-color:#dc2626;} #${u} .jfm-btn-danger:hover:not(:disabled){background:#b91c1c;border-color:#b91c1c;}
#${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:#6b7280;} #${u} .jfm-btn-ghost{background:transparent;border-color:transparent;color:var(--jfm-text-muted);}
#${u} .jfm-btn-ghost:hover:not(:disabled){background:#f3f4f6;color:#374151;} #${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:#9ca3af;font-size:1.25rem;cursor:pointer;border-radius:.25rem;padding:0;line-height:1;} #${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:#f3f4f6;color:#374151;} #${u} .jfm-close-btn:hover{background:var(--jfm-subtle);color:var(--color-text-secondary,#374151);}
/* File list */ /* 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-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-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:#9ca3af;} #${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-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;} #${u} .jfm-empty-sub{font-size:.875rem;margin:0;}
/* File cards */ /* 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{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:#93c5fd;box-shadow:0 2px 8px rgba(59,130,246,.1);} #${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.jfm-off{opacity:.6;}
#${u} .jfm-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;} #${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-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:#6b7280;display:flex;flex-direction:column;gap:.125rem;line-height:1.5;} #${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 #f3f4f6;margin-top:.125rem;} #${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-btn{flex:1;justify-content:center;}
#${u} .jfm-card-actions .jfm-del{flex:0 0 auto;} #${u} .jfm-card-actions .jfm-del{flex:0 0 auto;}
/* Toggle */ /* 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;} #${u} .jfm-toggle input[type=checkbox]{width:.9375rem;height:.9375rem;cursor:pointer;accent-color:#22c55e;margin:0;}
/* Upload zone */ /* Upload zone */
#${u} .jfm-upload-wrap{margin-top:.25rem;} #${u} .jfm-upload-wrap{margin-top:.25rem;}
#${u} input[type=file]#${u}-fileinput{position:absolute;left:-9999px;width:1px;height:1px;opacity:0;} #${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{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:#3b82f6;background:#eff6ff;border-style:solid;outline: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-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-primary{font-size:.875rem;color:var(--color-text-secondary,#374151);margin:0 0 .25rem;}
#${u} .jfm-drop-hint{font-size:.75rem;color:#9ca3af;margin:0;} #${u} .jfm-drop-hint{font-size:.75rem;color:var(--jfm-text-muted);margin:0;}
/* Modals */ /* 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{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[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-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-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:#111827;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} #${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-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-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-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:#9ca3af;font-variant-numeric:tabular-nums;} #${u} .jfm-stat{margin-right:auto;font-size:.75rem;color:var(--jfm-text-muted);font-variant-numeric:tabular-nums;}
/* JSON editor */ /* 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-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-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: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;} #${u} .jfm-err-bar[hidden]{display:none;}
/* Create form */ /* Create form */
#${u} .jfm-field{margin-bottom:.875rem;} #${u} .jfm-field{margin-bottom:.875rem;}
#${u} .jfm-field:last-child{margin-bottom:0;} #${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 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 #d1d5db;border-radius:.375rem;font-size:.875rem;color:#111827;background:#fff;} #${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:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12);} #${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:#9ca3af;margin-top:.25rem;} #${u} .jfm-hint{display:block;font-size:.75rem;color:var(--jfm-text-muted);margin-top:.25rem;}
#${u} .jfm-muted{font-size:.875rem;color:#6b7280;margin-top:.375rem;} #${u} .jfm-muted{font-size:.875rem;color:var(--jfm-text-muted);margin-top:.375rem;}
/* Spinner */ /* 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);}} @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>`; </style>`;
} }
@@ -319,7 +356,9 @@
if (this._editorEl) { if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat()); this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => { 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(); e.preventDefault();
const s = this._editorEl.selectionStart; const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd; const end = this._editorEl.selectionEnd;
@@ -336,15 +375,8 @@
} }
_onKey(e) { _onKey(e) {
// Escape is handled by the dialog focus trap (see _trap).
const editOpen = this._editModal && !this._editModal.hidden; 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) { if ((e.ctrlKey || e.metaKey) && e.key === 's' && editOpen) {
e.preventDefault(); e.preventDefault();
this._doSave(); this._doSave();
@@ -488,6 +520,7 @@
this._editorEl.value = 'Loading…'; this._editorEl.value = 'Loading…';
this._updateStat(); this._updateStat();
this._editModal.hidden = false; this._editModal.hidden = false;
this._trap('edit', this._editModal, `${this._uid}-edit-title`, this._editorEl, () => this._closeEdit());
try { try {
const data = await this._api('get', { filename }); const data = await this._api('get', { filename });
@@ -498,7 +531,7 @@
this._editorEl.setSelectionRange(0, 0); this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0; this._editorEl.scrollTop = 0;
} catch (err) { } 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 = ''; this._editorEl.value = '';
} }
} }
@@ -507,6 +540,7 @@
if (this._editModal) this._editModal.hidden = true; if (this._editModal) this._editModal.hidden = true;
this._editFile = null; this._editFile = null;
this._clearErr(); this._clearErr();
this._release('edit');
} }
_formatJson() { _formatJson() {
@@ -562,12 +596,18 @@
this._deleteFile = filename; this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`); const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename; 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() { _closeDel() {
if (this._delModal) this._delModal.hidden = true; if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null; this._deleteFile = null;
this._release('del');
} }
async _doDelete() { async _doDelete() {
@@ -597,11 +637,13 @@
}); });
this._createModal.hidden = false; this._createModal.hidden = false;
const first = this.createFields[0]; 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() { _closeCreate() {
if (this._createModal) this._createModal.hidden = true; if (this._createModal) this._createModal.hidden = true;
this._release('create');
} }
async _doCreate() { async _doCreate() {
@@ -2,7 +2,9 @@
* LEDMatrix Notification Widget * LEDMatrix Notification Widget
* *
* Global notification/toast system for displaying messages to users. * 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: * Usage:
* window.showNotification('Message here', 'success'); * window.showNotification('Message here', 'success');
@@ -11,6 +13,16 @@
* *
* Types: success, error, warning, info (default) * 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 * @module NotificationWidget
*/ */
@@ -26,35 +38,51 @@
// Configuration // Configuration
const CONFIG = { const CONFIG = {
containerId: 'notifications', containerId: 'notifications',
politeRegionId: 'notifications-live-polite',
assertiveRegionId: 'notifications-live-assertive',
defaultDuration: 4000, defaultDuration: 4000,
warningDuration: 6000,
errorMinDuration: 10000,
fadeOutDuration: 300, fadeOutDuration: 300,
maxNotifications: 5, maxNotifications: 5,
position: 'top-right' // top-right, top-left, bottom-right, bottom-left 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 = { const TYPE_STYLES = {
success: { success: {
bg: 'bg-green-500', bg: 'bg-green-500',
color: '#047857',
icon: 'fa-check-circle', icon: 'fa-check-circle',
label: 'Success' label: 'Success'
}, },
error: { error: {
bg: 'bg-red-500', bg: 'bg-red-500',
color: '#b91c1c',
icon: 'fa-exclamation-circle', icon: 'fa-exclamation-circle',
label: 'Error' label: 'Error'
}, },
warning: { warning: {
bg: 'bg-yellow-500', bg: 'bg-yellow-500',
color: '#b45309',
icon: 'fa-exclamation-triangle', icon: 'fa-exclamation-triangle',
label: 'Warning' label: 'Warning'
}, },
info: { info: {
bg: 'bg-blue-500', bg: 'bg-blue-500',
color: '#1d4ed8',
icon: 'fa-info-circle', icon: 'fa-info-circle',
label: 'Info' 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 // Track active notifications
let activeNotifications = []; let activeNotifications = [];
@@ -62,8 +90,14 @@
// keyed by notification id (cleaned up on dismiss). A Map rather than a // keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties. // plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map(); const actionCallbacks = new Map();
// Auto-dismiss timers, keyed by notification id: { timer, remaining, startedAt }
const timers = new Map();
let notificationCounter = 0; let notificationCounter = 0;
function prefersReducedMotion() {
return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
}
/** /**
* Get or create the notifications container * Get or create the notifications container
* @returns {HTMLElement} Container element * @returns {HTMLElement} Container element
@@ -75,14 +109,55 @@
container = document.createElement('div'); container = document.createElement('div');
container.id = CONFIG.containerId; container.id = CONFIG.containerId;
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none'; 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); 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; 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 * Escape HTML to prevent XSS
* @param {string} text - Text to escape * @param {string} text - Text to escape
@@ -94,16 +169,55 @@
return div.innerHTML; 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 * Remove a notification by ID
* @param {string} notificationId - Notification ID * @param {string} notificationId - Notification ID
* @param {boolean} immediate - Skip fade animation * @param {boolean} immediate - Skip fade animation
*/ */
function removeNotification(notificationId, immediate = false) { function removeNotification(notificationId, immediate = false) {
clearTimer(notificationId);
const notification = document.getElementById(notificationId); const notification = document.getElementById(notificationId);
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
if (!notification) return; 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(); notification.remove();
} else { } else {
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`; notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
@@ -115,9 +229,16 @@
}, CONFIG.fadeOutDuration); }, CONFIG.fadeOutDuration);
} }
// Remove from tracking array if (hadFocus) {
activeNotifications = activeNotifications.filter(id => id !== notificationId); const next = activeNotifications.length
actionCallbacks.delete(notificationId); ? 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') { if (typeof options === 'string') {
options = { type: options }; options = { type: options };
} }
options = options || {};
const type = options.type || 'info'; const type = STYLE_BY_TYPE.has(options.type) ? options.type : 'info';
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration; 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 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 container = getContainer();
const notificationId = `notification_${++notificationCounter}`; const notificationId = `notification_${++notificationCounter}`;
const reduceMotion = prefersReducedMotion();
// Enforce max notifications limit // Enforce max notifications limit
while (activeNotifications.length >= CONFIG.maxNotifications) { while (activeNotifications.length >= CONFIG.maxNotifications) {
@@ -150,18 +282,25 @@
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.id = notificationId; 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.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.backgroundColor = style.color;
notification.style.transform = 'translateX(100%)'; notification.style.color = '#ffffff';
notification.setAttribute('role', 'alert'); 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 // Build content
let html = ''; let html = '';
if (showIcon) { 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). // Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which // The callback is stored by id and invoked via triggerAction, which
@@ -171,7 +310,8 @@
html += ` html += `
<button type="button" <button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')" 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)} ${escapeHtml(options.actionLabel)}
</button> </button>
`; `;
@@ -182,8 +322,9 @@
<button type="button" <button type="button"
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')" 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" 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"> 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;"
<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"> 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> <path d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
</button> </button>
@@ -194,17 +335,29 @@
container.appendChild(notification); container.appendChild(notification);
activeNotifications.push(notificationId); activeNotifications.push(notificationId);
// Trigger animation (need to wait for DOM update) announce(`${style.label}: ${String(message)}`, isError);
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
// 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) { if (duration > 0) {
setTimeout(() => { startTimer(notificationId, duration);
removeNotification(notificationId); notification.addEventListener('mouseenter', () => pauseTimer(notificationId));
}, duration); 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 // Log for debugging
@@ -224,16 +377,17 @@
// Register the widget // Register the widget
window.LEDMatrixWidgets.register('notification', { window.LEDMatrixWidgets.register('notification', {
name: 'Notification Widget', name: 'Notification Widget',
version: '1.0.0', version: '1.1.0',
/** /**
* Show a notification * Show a notification
* @param {string} message - Message to display * @param {string} message - Message to display
* @param {Object} options - Configuration options * @param {Object} options - Configuration options
* @param {string} options.type - Notification type: success, error, warning, info * @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.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 * @returns {string} Notification ID
*/ */
show: showNotification, show: showNotification,
@@ -295,15 +449,28 @@
// Global shorthand function (backwards compatible with existing code). // Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object // Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }). // ({ type, duration, actionLabel, onAction, ... }).
// This always replaces the early fallbacks, whatever order they loaded in.
window.showNotification = function(message, type = 'info') { window.showNotification = function(message, type = 'info') {
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {})); return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
}; };
window.showNotification.__ledNotificationWidget = true;
// Initialize container on load function flushPending() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', getContainer);
} else {
getContainer(); 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'); console.log('[NotificationWidget] Notification widget registered');
@@ -101,8 +101,9 @@
html += ` html += `
<button type="button" <button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onDecrement('${fieldId}')" 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'}"> 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> aria-label="Decrease" aria-controls="${fieldId}_input">
<i class="fas fa-minus text-xs" aria-hidden="true"></i>
</button> </button>
`; `;
} }
@@ -128,8 +129,9 @@
html += ` html += `
<button type="button" <button type="button"
onclick="window.LEDMatrixWidgets.getHandlers('number-input').onIncrement('${fieldId}')" 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'}"> 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> aria-label="Increase" aria-controls="${fieldId}_input">
<i class="fas fa-plus text-xs" aria-hidden="true"></i>
</button> </button>
`; `;
} }
@@ -142,8 +142,9 @@
id="${fieldId}_toggle" id="${fieldId}_toggle"
onclick="window.LEDMatrixWidgets.getHandlers('password-input').onToggle('${fieldId}')" 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" class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
title="Show/hide password"> title="Show/hide password"
<i id="${fieldId}_icon" class="fas fa-eye"></i> aria-label="Show password" aria-controls="${fieldId}_input">
<i id="${fieldId}_icon" class="fas fa-eye" aria-hidden="true"></i>
</button> </button>
`; `;
} }
@@ -297,16 +298,19 @@
const safeId = sanitizeId(fieldId); const safeId = sanitizeId(fieldId);
const input = document.getElementById(`${safeId}_input`); const input = document.getElementById(`${safeId}_input`);
const icon = document.getElementById(`${safeId}_icon`); const icon = document.getElementById(`${safeId}_icon`);
const toggleBtn = document.getElementById(`${safeId}_toggle`);
if (input && icon) { if (input && icon) {
if (input.type === 'password') { if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
icon.classList.remove('fa-eye'); icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash'); icon.classList.add('fa-eye-slash');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Hide password');
} else { } else {
input.type = 'password'; input.type = 'password';
icon.classList.remove('fa-eye-slash'); icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye'); icon.classList.add('fa-eye');
if (toggleBtn) toggleBtn.setAttribute('aria-label', 'Show password');
} }
} }
} }
@@ -56,108 +56,141 @@
if (!document.getElementById('pfm-styles')) { if (!document.getElementById('pfm-styles')) {
const style = document.createElement('style'); const style = document.createElement('style');
style.id = 'pfm-styles'; 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 = ` 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; .pfm-header { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.75rem; } margin-bottom:.75rem; }
.pfm-title { font-size:1rem; font-weight:600; color:#111827; } .pfm-title { font-size:1rem; font-weight:600; color:var(--color-text-primary,#111827); }
.pfm-dir { font-size:.75rem; color:#6b7280; margin-top:.125rem; } .pfm-dir { font-size:.75rem; color:var(--pfm-text-muted); margin-top:.125rem; }
.pfm-upload { border:2px dashed #d1d5db; border-radius:.5rem; padding:1.25rem; .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; } text-align:center; cursor:pointer; transition:border-color .15s,background .15s; }
.pfm-upload:hover,.pfm-upload.dragover { border-color:#3b82f6; background:#eff6ff; } .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:#4b5563; margin:.25rem 0 0; } .pfm-upload p { font-size:.875rem; color:var(--color-text-tertiary,#4b5563); margin:.25rem 0 0; }
.pfm-upload small { font-size:.75rem; color:#9ca3af; } .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)); .pfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
gap:.75rem; margin-top:.75rem; } gap:.75rem; margin-top:.75rem; }
.pfm-card { border:1px solid #e5e7eb; border-radius:.5rem; padding:.875rem; .pfm-card { border:1px solid var(--color-border,#e5e7eb); border-radius:.5rem; padding:.875rem;
background:#fff; transition:box-shadow .15s; } background:var(--color-surface,#fff); transition:box-shadow .15s; }
.pfm-card:hover { box-shadow:0 1px 4px rgba(0,0,0,.1); } .pfm-card:hover { box-shadow:var(--shadow-md,0 1px 4px rgba(0,0,0,.1)); }
.pfm-card.disabled { opacity:.55; } .pfm-card.disabled { opacity:.55; }
.pfm-card-top { display:flex; align-items:center; justify-content:space-between; .pfm-card-top { display:flex; align-items:center; justify-content:space-between;
margin-bottom:.5rem; } 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; display:flex; align-items:center; justify-content:center;
color:#6b7280; font-size:1rem; } color:var(--pfm-text-muted); font-size:1rem; }
.pfm-card-name { font-weight:600; color:#111827; font-size:.875rem; margin:.375rem 0 .125rem; } .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:#6b7280; line-height:1.5; } .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-card-actions { display:flex; gap:.375rem; margin-top:.625rem; }
.pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem; .pfm-btn { display:inline-flex; align-items:center; gap:.25rem; padding:.375rem .75rem;
border-radius:.375rem; font-size:.8125rem; font-weight:500; border-radius:.375rem; font-size:.8125rem; font-weight:500;
border:none; cursor:pointer; transition:background .15s; } border:none; cursor:pointer; transition:background .15s; }
.pfm-btn-primary { background:#2563eb; color:#fff; flex:1; justify-content:center; } .pfm-btn:focus-visible { outline:2px solid var(--color-primary,#2563eb); outline-offset:2px; }
.pfm-btn-primary:hover { background:#1d4ed8; } .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 { background:#dc2626; color:#fff; }
.pfm-btn-danger:hover { background:#b91c1c; } .pfm-btn-danger:hover { background:#b91c1c; }
.pfm-btn-secondary { background:#f3f4f6; color:#374151; border:1px solid #d1d5db; } .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:#e5e7eb; } .pfm-btn-secondary:hover { background:var(--pfm-subtle-hover); }
.pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; } .pfm-btn-sm { padding:.25rem .5rem; font-size:.75rem; }
.pfm-btn-create { background:#059669; color:#fff; } .pfm-btn-create { background:#059669; color:#fff; }
.pfm-btn-create:hover { background:#047857; } .pfm-btn-create:hover { background:#047857; }
.pfm-toggle-wrap { display:flex; align-items:center; gap:.375rem; } .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 { position:relative; display:inline-block; width:2rem; height:1.125rem; }
.pfm-toggle-cb input { opacity:0; width:0; height:0; } .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; } cursor:pointer; transition:background .2s; }
.pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem; .pfm-toggle-slider:before { content:''; position:absolute; height:.75rem; width:.75rem;
left:.1875rem; bottom:.1875rem; background:#fff; left:.1875rem; bottom:.1875rem; background:#fff;
border-radius:50%; transition:transform .2s; } border-radius:50%; transition:transform .2s; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; } .pfm-toggle-cb input:checked + .pfm-toggle-slider { background:#10b981; }
.pfm-toggle-cb input:checked + .pfm-toggle-slider:before { transform:translateX(.875rem); } .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-empty i { font-size:2rem; margin-bottom:.5rem; display:block; }
.pfm-error-text { color:var(--color-error,#dc2626); }
/* Modal */ /* Modal */
.pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5); .pfm-overlay { position:fixed; inset:0; background:rgba(0,0,0,.5);
display:flex; align-items:flex-start; justify-content:center; display:flex; align-items:flex-start; justify-content:center;
z-index:9999; padding:2rem 1rem; overflow-y:auto; } 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; } 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; .pfm-modal-header { display:flex; align-items:center; justify-content:space-between;
padding:1rem 1.25rem; border-bottom:1px solid #e5e7eb; } padding:1rem 1.25rem; border-bottom:1px solid var(--color-border,#e5e7eb); }
.pfm-modal-title { font-size:1rem; font-weight:600; color:#111827; } .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-body { padding:1.25rem; overflow-y:auto; max-height:70vh; }
.pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem; .pfm-modal-footer { display:flex; justify-content:flex-end; gap:.5rem;
padding:.875rem 1.25rem; border-top:1px solid #e5e7eb; padding:.875rem 1.25rem; border-top:1px solid var(--color-border,#e5e7eb);
background:#f9fafb; border-radius:0 0 .75rem .75rem; } background:var(--color-background,#f9fafb); border-radius:0 0 .75rem .75rem; }
/* Entry table */ /* Entry table */
.pfm-table-wrap { overflow-x:auto; } .pfm-table-wrap { overflow-x:auto; }
.pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; } .pfm-table { width:100%; border-collapse:collapse; font-size:.8125rem; }
.pfm-table th { background:#f9fafb; text-align:left; padding:.5rem .625rem; .pfm-table th { background:var(--color-background,#f9fafb); text-align:left; padding:.5rem .625rem;
font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb; font-weight:600; color:var(--color-text-secondary,#374151); border-bottom:1px solid var(--color-border,#e5e7eb);
white-space:nowrap; position:sticky; top:0; } 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; } 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 { .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; 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 { .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; .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; .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 { display:flex; align-items:center; gap:.375rem; font-size:.8125rem; }
.pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid #d1d5db; .pfm-page-jump input { width:3.5rem; padding:.25rem .375rem; border:1px solid var(--pfm-border-strong);
border-radius:.25rem; text-align:center; } 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 */ /* Form in create modal */
.pfm-field { margin-bottom:.875rem; } .pfm-field { margin-bottom:.875rem; }
.pfm-field label { display:block; font-size:.875rem; font-weight:500; .pfm-field label { display:block; font-size:.875rem; font-weight:500;
color:#374151; margin-bottom:.25rem; } color:var(--color-text-secondary,#374151); margin-bottom:.25rem; }
.pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid #d1d5db; .pfm-field input { width:100%; padding:.4rem .625rem; border:1px solid var(--pfm-border-strong);
border-radius:.375rem; font-size:.875rem; } border-radius:.375rem; font-size:.875rem;
.pfm-field input:focus { outline:none; border-color:#3b82f6; } background:var(--color-surface,#fff); color:var(--color-text-primary,#111827); }
.pfm-field-hint { font-size:.75rem; color:#9ca3af; margin-top:.2rem; } .pfm-field input:focus { outline:none; border-color:var(--color-primary,#3b82f6); box-shadow:0 0 0 2px var(--color-primary,#3b82f6); }
.pfm-field-error { font-size:.75rem; color:#dc2626; margin-top:.2rem; } .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 */ /* 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; 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); document.head.appendChild(style);
} }
@@ -356,7 +389,8 @@
delBtn.dataset.pfmAction = 'delete'; delBtn.dataset.pfmAction = 'delete';
delBtn.dataset.pfmField = fieldId; delBtn.dataset.pfmField = fieldId;
delBtn.dataset.pfmFile = f.filename; 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); actions.appendChild(delBtn);
} }
card.appendChild(actions); card.appendChild(actions);
@@ -375,18 +409,18 @@
modal.className = 'pfm-modal'; modal.className = 'pfm-modal';
safeSetHTML(modal, ` safeSetHTML(modal, `
<div class="pfm-modal-header"> <div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span> <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 class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close"> <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"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body"> <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>
<div class="pfm-modal-footer"> <div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button> <button type="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"> <button type="button" class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
<i class="fas fa-save mr-1"></i>Save <i class="fas fa-save mr-1" aria-hidden="true"></i>Save
</button> </button>
</div>`); </div>`);
overlay.appendChild(modal); 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_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).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)); 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 data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
const body = document.getElementById(`${fieldId}_edit_body`); const body = document.getElementById(`${fieldId}_edit_body`);
if (!data || data.status !== 'success' || !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; return;
} }
@@ -413,10 +448,10 @@
// Textarea path: _editData stays null; save() reads from the <textarea> // Textarea path: _editData stays null; save() reads from the <textarea>
st._editData = null; st._editData = null;
safeSetHTML(body, ` safeSetHTML(body, `
<textarea id="${escHtml(fieldId)}_json_ta" rows="20" <textarea id="${escHtml(fieldId)}_json_ta" rows="20" class="pfm-json-ta"
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;" aria-label="File contents (JSON)" aria-describedby="${escHtml(fieldId)}_json_err"
>${escHtml(JSON.stringify(content, null, 2))}</textarea> >${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); const totalPages = Math.ceil(total / perPage);
safeSetHTML(container, ` safeSetHTML(container, `
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;"> <div class="pfm-table-info">
${total} entries total ${total} entries total
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem" <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);})()"> 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'; modal.style.maxWidth = '28rem';
safeSetHTML(modal, ` safeSetHTML(modal, `
<div class="pfm-modal-header"> <div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span> <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 class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close"> <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"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="pfm-modal-body"> <div class="pfm-modal-body">
@@ -591,15 +626,18 @@
</div> </div>
</div> </div>
<div class="pfm-modal-footer"> <div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button> <button type="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"> <button type="button" class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
<i class="fas fa-trash mr-1"></i>Delete <i class="fas fa-trash mr-1" aria-hidden="true"></i>Delete
</button> </button>
</div>`); </div>`);
overlay.appendChild(modal); 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_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)); 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) { window._pfmConfirmDelete = async function (fieldId, filename) {
@@ -611,7 +649,7 @@
window._pfmCloseModal(fieldId); window._pfmCloseModal(fieldId);
await loadFiles(fieldId); await loadFiles(fieldId);
} else { } 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'; modal.style.maxWidth = '32rem';
safeSetHTML(modal, ` safeSetHTML(modal, `
<div class="pfm-modal-header"> <div class="pfm-modal-header">
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span> <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 class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close"> <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"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
</div> </div>
<div class="pfm-modal-body"> <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 => ` ${fields.map(f => `
<div class="pfm-field"> <div class="pfm-field">
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label> <label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
@@ -643,16 +681,16 @@
</div>`).join('')} </div>`).join('')}
</div> </div>
<div class="pfm-modal-footer"> <div class="pfm-modal-footer">
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button> <button type="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"> <button type="button" class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
<i class="fas fa-plus mr-1"></i>Create <i class="fas fa-plus mr-1" aria-hidden="true"></i>Create
</button> </button>
</div>
</div>`); </div>`);
overlay.appendChild(modal); overlay.appendChild(modal);
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId)); 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)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(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) { window._pfmConfirmCreate = async function (fieldId) {
@@ -739,11 +777,28 @@
return overlay; 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) { window._pfmCloseModal = function (fieldId) {
const st = getState(fieldId); const st = getState(fieldId);
if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; } if (st.currentModal) { st.currentModal.remove(); st.currentModal = null; }
st._editData = null; st._editData = null;
st._editFilename = null; st._editFilename = null;
if (st._releaseDialog) {
const release = st._releaseDialog;
st._releaseDialog = null;
release();
}
}; };
// ─── Widget registration ────────────────────────────────────────────────── // ─── Widget registration ──────────────────────────────────────────────────
@@ -793,7 +848,7 @@
<input type="file" id="${fieldId}_file_input" accept=".json" <input type="file" id="${fieldId}_file_input" accept=".json"
style="display:none" style="display:none"
onchange="if(this.files[0])window._pfmUpload('${fieldId}',this.files[0]);this.value=''"> 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> <p>Drag and drop or click to upload</p>
<small>${escHtml(st.uploadHint)}</small> <small>${escHtml(st.uploadHint)}</small>
</div>` : ''} </div>` : ''}
@@ -49,6 +49,7 @@
saturday: 'Saturday', saturday: 'Saturday',
sunday: 'Sunday' sunday: 'Sunday'
}; };
const DAY_LABEL_MAP = new Map(Object.entries(DAY_LABELS));
// Use BaseWidget utilities if available // Use BaseWidget utilities if available
const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null; const base = window.BaseWidget ? new window.BaseWidget('SchedulePicker', '1.0.0') : null;
@@ -283,17 +284,19 @@
// Render each day row // Render each day row
DAYS.forEach(day => { DAYS.forEach(day => {
const dayConfig = schedule.days[day]; const dayConfig = schedule.days[day];
const dayLabel = escapeHtml(DAY_LABEL_MAP.get(day) || day);
const disabled = !dayConfig.enabled; const disabled = !dayConfig.enabled;
const disabledClass = disabled ? 'bg-gray-100' : ''; const disabledClass = disabled ? 'bg-gray-100' : '';
html += ` html += `
<tr class="hover:bg-gray-50" id="${fieldId}_row_${day}"> <tr class="hover:bg-gray-50" id="${fieldId}_row_${day}">
<td class="px-3 py-2 whitespace-nowrap"> <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>
<td class="px-3 py-2 whitespace-nowrap"> <td class="px-3 py-2 whitespace-nowrap">
<input type="checkbox" <input type="checkbox"
id="${fieldId}_${day}_enabled" id="${fieldId}_${day}_enabled"
aria-label="${dayLabel} enabled"
${dayConfig.enabled ? 'checked' : ''} ${dayConfig.enabled ? 'checked' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayEnabledChange('${fieldId}', '${day}', this.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"> 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"> <td class="px-3 py-2 whitespace-nowrap">
<input type="time" <input type="time"
id="${fieldId}_${day}_start" id="${fieldId}_${day}_start"
aria-label="${dayLabel} start time"
value="${escapeHtml(dayConfig.start_time)}" value="${escapeHtml(dayConfig.start_time)}"
${disabled ? 'disabled' : ''} ${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')" onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -309,6 +313,7 @@
<td class="px-3 py-2 whitespace-nowrap"> <td class="px-3 py-2 whitespace-nowrap">
<input type="time" <input type="time"
id="${fieldId}_${day}_end" id="${fieldId}_${day}_end"
aria-label="${dayLabel} end time"
value="${escapeHtml(dayConfig.end_time)}" value="${escapeHtml(dayConfig.end_time)}"
${disabled ? 'disabled' : ''} ${disabled ? 'disabled' : ''}
onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')" onchange="window.LEDMatrixWidgets.getHandlers('schedule-picker').onDayTimeChange('${fieldId}', '${day}')"
@@ -121,8 +121,8 @@
id="${fieldId}_clear" id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')" onclick="window.LEDMatrixWidgets.getHandlers('text-input').onClear('${fieldId}')"
class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}" class="inline-flex items-center px-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear"> title="Clear" aria-label="Clear">
<i class="fas fa-times"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
`; `;
} }
@@ -90,7 +90,7 @@
onchange="window.LEDMatrixWidgets.getHandlers('time-picker').onChange('${fieldId}')" 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"> 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"> <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>
</div> </div>
`; `;
@@ -101,8 +101,8 @@
id="${fieldId}_clear" id="${fieldId}_clear"
onclick="window.LEDMatrixWidgets.getHandlers('time-picker').onClear('${fieldId}')" 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'}" class="ml-2 inline-flex items-center px-2 py-2 text-gray-400 hover:text-gray-600 ${currentValue ? '' : 'hidden'}"
title="Clear"> title="Clear" aria-label="Clear time">
<i class="fas fa-times"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
`; `;
} }
+147 -39
View File
@@ -1044,20 +1044,11 @@ window.initPluginsPage = function() {
setupInstalledFilterListeners(); setupInstalledFilterListeners();
setupStoreFilterListeners(); setupStoreFilterListeners();
if (closeOnDemandModalBtn) { // The on-demand modal lives in base.html and survives tab swaps, so bind it
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true)); // through the one guarded initializer instead of cloning its controls here
document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal); // (cloning copied data-initialized and left two code paths owning listeners).
} if (closeOnDemandModalBtn || cancelOnDemandBtn || onDemandForm || onDemandModal) {
if (cancelOnDemandBtn) { initializeOnDemandModal();
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;
} }
// Load on-demand status silently (false = don't show notification) // Load on-demand status silently (false = don't show notification)
@@ -1385,7 +1376,7 @@ function getInstalledFilter() {
el: 'installed-search', el: 'installed-search',
clearEl: 'installed-search-clear', clearEl: 'installed-search-clear',
fields: ['name', 'id', 'description', 'author', 'category', 'tags'], fields: ['name', 'id', 'description', 'author', 'category', 'tags'],
debounceMs: 200, debounceMs: 150,
}, },
sort: { sort: {
el: 'installed-sort', el: 'installed-sort',
@@ -1551,7 +1542,7 @@ function renderInstalledCards(plugins, total) {
return JSON.stringify(text || ''); return JSON.stringify(text || '');
}; };
container.innerHTML = plugins.map(plugin => { setGridHtmlIfChanged(container, plugins.map(plugin => {
// Convert enabled to boolean for consistent rendering // Convert enabled to boolean for consistent rendering
const enabledBool = Boolean(plugin.enabled); const enabledBool = Boolean(plugin.enabled);
@@ -1582,15 +1573,19 @@ function renderInstalledCards(plugins, total) {
<!-- Toggle Switch in Top Right --> <!-- Toggle Switch in Top Right -->
<div class="flex-shrink-0 ml-4"> <div class="flex-shrink-0 ml-4">
<label class="relative inline-flex items-center cursor-pointer group"> <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" <input type="checkbox"
class="sr-only peer" class="sr-only peer"
role="switch"
aria-label="Enable ${escapeAttr(plugin.name || plugin.id)}"
id="toggle-${escapedPluginId}" id="toggle-${escapedPluginId}"
${enabledBool ? 'checked' : ''} ${enabledBool ? 'checked' : ''}
data-plugin-id="${escapedPluginId}" data-plugin-id="${escapedPluginId}"
data-action="toggle"> 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 --> <!-- 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"> <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>'} ${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
</div> </div>
@@ -1638,9 +1633,30 @@ function renderInstalledCards(plugins, total) {
</div> </div>
</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) // 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 // 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. // 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() { function startOnDemandStatusPolling() {
if (onDemandStatusInterval) { if (onDemandStatusInterval) {
clearInterval(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; window.loadOnDemandStatus = loadOnDemandStatus;
@@ -2105,6 +2183,17 @@ window.__openOnDemandModalImpl = function(pluginId) {
modalContent.style.overflowY = 'auto'; 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 // Scroll to top of page to ensure modal is visible
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -2132,12 +2221,21 @@ window.__openOnDemandModalImpl = function(pluginId) {
// Replace the stub with the real implementation // Replace the stub with the real implementation
window.openOnDemandModal = window.__openOnDemandModalImpl; 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() { function closeOnDemandModal() {
const modal = document.getElementById('on-demand-modal'); const modal = document.getElementById('on-demand-modal');
if (modal) { if (modal) {
modal.style.display = 'none'; modal.style.display = 'none';
} }
currentOnDemandPluginId = null; currentOnDemandPluginId = null;
if (onDemandDialogRelease) {
const release = onDemandDialogRelease;
onDemandDialogRelease = null;
release();
}
} }
function submitOnDemandRequest(event) { function submitOnDemandRequest(event) {
@@ -2366,7 +2464,7 @@ function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, inde
if (logoValue.path) { if (logoValue.path) {
html += ` html += `
<div class="mt-2 flex items-center space-x-2 uploaded-image-container"> <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" <button type="button"
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')" onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
class="text-red-600 hover:text-red-800"> 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 // Get current item index from data-index attribute for remove button
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex; const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
imageContainer.innerHTML = ` 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" <button type="button"
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')" onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
class="text-red-600 hover:text-red-800"> class="text-red-600 hover:text-red-800">
@@ -3666,7 +3764,7 @@ function getStoreFilter() {
search: { search: {
el: 'plugin-search', el: 'plugin-search',
fields: ['name', 'description', 'author', 'id', 'category', 'tags'], fields: ['name', 'description', 'author', 'id', 'category', 'tags'],
debounceMs: 300, debounceMs: 150,
}, },
sort: { sort: {
el: 'store-sort', el: 'store-sort',
@@ -3781,7 +3879,7 @@ function renderPluginStore(plugins) {
return JSON.stringify(text || ''); return JSON.stringify(text || '');
}; };
container.innerHTML = plugins.map(plugin => { setGridHtmlIfChanged(container, plugins.map(plugin => {
const installed = isStorePluginInstalled(plugin); const installed = isStorePluginInstalled(plugin);
return ` return `
<div class="plugin-card"> <div class="plugin-card">
@@ -3830,7 +3928,7 @@ function renderPluginStore(plugins) {
</div> </div>
</div> </div>
</div>`; </div>`;
}).join(''); }).join(''));
} }
// Expose functions to window for onclick handlers // Expose functions to window for onclick handlers
@@ -4004,8 +4102,8 @@ function renderSavedRepositories(repositories) {
</div> </div>
<p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p> <p class="text-xs text-gray-500 truncate" title="${escapeAttribute(repoUrl)}">${escapeHtml(repoUrl)}</p>
</div> </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"> <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"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
`; `;
@@ -4603,14 +4701,17 @@ window.toggleGithubTokenVisibility = function() {
const icon = document.getElementById('github-token-icon'); const icon = document.getElementById('github-token-icon');
if (input && icon) { if (input && icon) {
const btn = icon.closest('button');
if (input.type === 'password') { if (input.type === 'password') {
input.type = 'text'; input.type = 'text';
icon.classList.remove('fa-eye'); icon.classList.remove('fa-eye');
icon.classList.add('fa-eye-slash'); icon.classList.add('fa-eye-slash');
if (btn) btn.setAttribute('aria-label', 'Hide token');
} else { } else {
input.type = 'password'; input.type = 'password';
icon.classList.remove('fa-eye-slash'); icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye'); 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"> <div class="flex items-center space-x-3 flex-1">
<img src="/${img.path || ''}" <img src="/${img.path || ''}"
alt="${img.filename || ''}" alt="${img.filename || ''}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded" class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"> 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"> <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" <button type="button"
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})" onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
class="text-blue-600 hover:text-blue-800 p-2" class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"> title="Schedule this image"
<i class="fas fa-calendar-alt"></i> aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button> </button>
<button type="button" <button type="button"
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')" onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')"
class="text-red-600 hover:text-red-800 p-2" class="text-red-600 hover:text-red-800 p-2"
title="Delete image"> title="Delete image"
<i class="fas fa-trash"></i> aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -5137,7 +5241,7 @@ window.openImageSchedule = function(fieldId, imageId, imageIdx) {
<!-- Schedule Mode --> <!-- Schedule Mode -->
<div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};"> <div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
<div> <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}" <select id="schedule_mode_${imageId}"
onchange="window.updateImageScheduleMode('${fieldId}', '${imageId}', ${imageIdx})" 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"> 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 --> <!-- Time Range Mode -->
<div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};"> <div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
<div> <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" <input type="time"
id="schedule_start_${imageId}" id="schedule_start_${imageId}"
value="${schedule.start_time || '08:00'}" 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"> class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
</div> </div>
<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" <input type="time"
id="schedule_end_${imageId}" id="schedule_end_${imageId}"
value="${schedule.end_time || '18:00'}" 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'};"> <div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${imageId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
<input type="time" <input type="time"
id="day_${day}_start_${imageId}" id="day_${day}_start_${imageId}"
aria-label="${day} start time"
value="${dayConfig.start_time || '08:00'}" value="${dayConfig.start_time || '08:00'}"
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')" onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
class="text-xs px-2 py-1 border border-gray-300 rounded" class="text-xs px-2 py-1 border border-gray-300 rounded"
${!dayConfig.enabled ? 'disabled' : ''}> ${!dayConfig.enabled ? 'disabled' : ''}>
<input type="time" <input type="time"
id="day_${day}_end_${imageId}" id="day_${day}_end_${imageId}"
aria-label="${day} end time"
value="${dayConfig.end_time || '18:00'}" value="${dayConfig.end_time || '18:00'}"
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')" onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
class="text-xs px-2 py-1 border border-gray-300 rounded" class="text-xs px-2 py-1 border border-gray-300 rounded"
@@ -5375,12 +5481,12 @@ if (typeof window !== 'undefined') {
if (propSchema.type === 'boolean') { if (propSchema.type === 'boolean') {
const checked = propValue ? 'checked' : ''; const checked = propValue ? 'checked' : '';
// No name attribute - rely solely on _data field to prevent key leakage // 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 { } else {
// Escape HTML to prevent XSS // Escape HTML to prevent XSS
// No name attribute - rely solely on _data field to prevent key leakage // No name attribute - rely solely on _data field to prevent key leakage
const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') : (propValue || ''); const escapedValue = typeof propValue === 'string' ? propValue.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;') : (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>`; itemHtml += `</div>`;
}); });
@@ -5823,7 +5929,7 @@ document.addEventListener('htmx:afterSettle', function() {
search: { search: {
el: 'starlark-search', el: 'starlark-search',
fields: ['name', 'summary', 'desc', 'author', 'id', 'category'], fields: ['name', 'summary', 'desc', 'author', 'id', 'category'],
debounceMs: 300, debounceMs: 150,
}, },
sort: { sort: {
el: 'starlark-sort', el: 'starlark-sort',
@@ -5902,7 +6008,9 @@ document.addEventListener('htmx:afterSettle', function() {
return; 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); const installed = isStarlarkInstalled(app.id);
return ` return `
<div class="plugin-card" data-app-id="${escapeHtml(app.id)}"> <div class="plugin-card" data-app-id="${escapeHtml(app.id)}">
@@ -5932,7 +6040,7 @@ document.addEventListener('htmx:afterSettle', function() {
</button> </button>
</div> </div>
</div>`; </div>`;
}).join(''); }).join(''));
// Add delegated event listener only once (prevent duplicate handlers) // Add delegated event listener only once (prevent duplicate handlers)
if (!grid.dataset.starlarkHandlerAttached) { if (!grid.dataset.starlarkHandlerAttached) {
+84 -99
View File
@@ -204,7 +204,7 @@
clearTimeout(timeout); clearTimeout(timeout);
console.error('Failed to load plugins:', err); console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded'); content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>'; content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Plugin Manager. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadPluginsDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
}); });
} }
} }
@@ -229,6 +229,11 @@
after DOM parse but before DOMContentLoaded, which is exactly what after DOM parse but before DOMContentLoaded, which is exactly what
Alpine wants — so no deferLoadingAlpine gymnastics are needed. Alpine wants — so no deferLoadingAlpine gymnastics are needed.
The inline rescue below only fires if the local file is missing. --> The inline rescue below only fires if the local file is missing. -->
<!-- App shell (full app(), Alpine stores, SSE streams). Deferred so it
doesn't block rendering, and placed before Alpine: deferred scripts run
in document order, and its alpine:init listener must be registered
before Alpine starts. -->
<script defer src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script> <script defer src="{{ url_for('static', filename='v3/js/alpinejs.min.js') }}"></script>
<script> <script>
// Rescue: if the local Alpine didn't load for any reason, pull the CDN // Rescue: if the local Alpine didn't load for any reason, pull the CDN
@@ -238,7 +243,7 @@
if (typeof window.Alpine === 'undefined') { if (typeof window.Alpine === 'undefined') {
console.warn('[Alpine] Local file failed to load, falling back to CDN'); console.warn('[Alpine] Local file failed to load, falling back to CDN');
const s = document.createElement('script'); const s = document.createElement('script');
s.src = 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js'; s.src = 'https://unpkg.com/alpinejs@3.13.5/dist/cdn.min.js';
document.head.appendChild(s); document.head.appendChild(s);
} }
}); });
@@ -291,11 +296,12 @@
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
</head> </head>
<body x-data="app()" class="bg-gray-50 min-h-screen"> <body x-data="app()" class="bg-gray-50 min-h-screen">
<a href="#tab-content" class="skip-link">Skip to main content</a>
<!-- Header --> <!-- Header -->
<header class="bg-white shadow-md border-b border-gray-200"> <header class="bg-white shadow-md border-b border-gray-200">
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;"> <div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
<div class="flex justify-between items-center h-16"> <div class="flex justify-between items-center h-16">
<div class="flex items-center"> <div class="flex items-center min-w-0">
<!-- Mobile nav toggle (hamburger) — hidden at md and up --> <!-- Mobile nav toggle (hamburger) — hidden at md and up -->
<button type="button" <button type="button"
class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden" class="theme-toggle-btn p-2 rounded-md mr-2 md:hidden"
@@ -307,14 +313,13 @@
title="Menu"> title="Menu">
<i class="fas fa-bars" aria-hidden="true"></i> <i class="fas fa-bars" aria-hidden="true"></i>
</button> </button>
<h1 class="text-xl font-bold text-gray-900"> <h1 class="text-lg sm:text-xl font-bold text-gray-900 truncate">
<i class="fas fa-tv text-blue-600 mr-2"></i> <i class="fas fa-tv text-blue-600 mr-2" aria-hidden="true"></i>LED Matrix Control
LED Matrix Control
</h1> </h1>
</div> </div>
<!-- Connection status and theme toggle --> <!-- Connection status and theme toggle -->
<div id="header-widgets" class="flex items-center space-x-4"> <div id="header-widgets" class="flex items-center flex-shrink-0 ml-2 space-x-4">
<!-- Global settings search --> <!-- Global settings search -->
<div class="relative hidden sm:block" id="settings-search-wrap"> <div class="relative hidden sm:block" id="settings-search-wrap">
<input id="settings-search" <input id="settings-search"
@@ -353,20 +358,20 @@
<!-- System stats (populated via SSE) --> <!-- System stats (populated via SSE) -->
<div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8"> <div id="system-stats" class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
<span id="cpu-stat" class="flex items-center space-x-1"> <span id="cpu-stat" class="flex items-center space-x-1" title="CPU usage">
<i class="fas fa-microchip"></i> <i class="fas fa-microchip" aria-hidden="true"></i>
<span>--%</span> <span>--%</span>
</span> </span>
<span id="memory-stat" class="flex items-center space-x-1"> <span id="memory-stat" class="flex items-center space-x-1" title="Memory usage">
<i class="fas fa-memory"></i> <i class="fas fa-memory" aria-hidden="true"></i>
<span>--%</span> <span>--%</span>
</span> </span>
<span id="temp-stat" class="flex items-center space-x-1"> <span id="temp-stat" class="flex items-center space-x-1" title="CPU temperature">
<i class="fas fa-thermometer-half"></i> <i class="fas fa-thermometer-half" aria-hidden="true"></i>
<span>--°C</span> <span>--°C</span>
</span> </span>
<span id="power-stat" class="hidden items-center space-x-1" title=""> <span id="power-stat" class="hidden items-center space-x-1" title="">
<i class="fas fa-bolt"></i> <i class="fas fa-bolt" aria-hidden="true"></i>
<span>Power</span> <span>Power</span>
</span> </span>
</div> </div>
@@ -381,7 +386,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%"> <div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<i class="fas fa-arrow-circle-up text-lg"></i> <i class="fas fa-arrow-circle-up text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="update-banner-text" <span class="text-sm font-medium" id="update-banner-text"
aria-live="polite" aria-atomic="true"> aria-live="polite" aria-atomic="true">
A new LEDMatrix update is available A new LEDMatrix update is available
@@ -391,12 +396,12 @@
<button onclick="applyUpdate()" id="update-banner-btn" <button onclick="applyUpdate()" id="update-banner-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150"> update-banner-action transition-colors duration-150">
<i class="fas fa-download mr-1"></i> Update Now <i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now
</button> </button>
<button type="button" onclick="dismissUpdateBanner()" <button type="button" onclick="dismissUpdateBanner()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150" class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss update"> title="Dismiss" aria-label="Dismiss update">
<i class="fas fa-times text-sm"></i> <i class="fas fa-times text-sm" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -412,7 +417,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%"> <div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<i class="fas fa-rotate text-lg"></i> <i class="fas fa-rotate text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" aria-live="polite" <span class="text-sm font-medium" aria-live="polite"
id="restart-pending-text"> id="restart-pending-text">
Configuration saved &mdash; restart the display to apply the changes Configuration saved &mdash; restart the display to apply the changes
@@ -422,12 +427,12 @@
<button onclick="window.restartPendingNow()" id="restart-pending-btn" <button onclick="window.restartPendingNow()" id="restart-pending-btn"
class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md class="inline-flex items-center px-3 py-1 text-xs font-semibold rounded-md
update-banner-action transition-colors duration-150"> update-banner-action transition-colors duration-150">
<i class="fas fa-power-off mr-1"></i> Restart Now <i class="fas fa-power-off mr-1" aria-hidden="true"></i> Restart Now
</button> </button>
<button type="button" onclick="window.dismissRestartPending()" <button type="button" onclick="window.dismissRestartPending()"
class="update-banner-dismiss rounded p-1 transition-colors duration-150" class="update-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss restart reminder"> title="Dismiss" aria-label="Dismiss restart reminder">
<i class="fas fa-times text-sm"></i> <i class="fas fa-times text-sm" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -440,7 +445,7 @@
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%"> <div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-2" style="max-width:100%">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<i class="fas fa-exclamation-triangle text-lg"></i> <i class="fas fa-exclamation-triangle text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium" id="power-warning-banner-text" <span class="text-sm font-medium" id="power-warning-banner-text"
aria-live="polite" aria-atomic="true"> aria-live="polite" aria-atomic="true">
A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling. A power/thermal issue detected right now — the display may flicker or degrade. Check your power supply and cooling.
@@ -449,7 +454,7 @@
<button type="button" onclick="dismissPowerWarningBanner()" <button type="button" onclick="dismissPowerWarningBanner()"
class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150" class="power-warning-banner-dismiss rounded p-1 transition-colors duration-150"
title="Dismiss" aria-label="Dismiss power warning"> title="Dismiss" aria-label="Dismiss power warning">
<i class="fas fa-times text-sm"></i> <i class="fas fa-times text-sm" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -471,90 +476,90 @@
<div id="drawer-widgets"></div> <div id="drawer-widgets"></div>
<!-- Navigation tabs --> <!-- Navigation tabs -->
<nav class="mb-8"> <div class="mb-8">
<!-- First row - System tabs --> <!-- First row - System tabs -->
<div class="border-b border-gray-200 mb-4"> <div class="border-b border-gray-200 mb-4">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4"> <nav aria-label="System settings" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'overview'" <button @click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'nav-tab-active' : ''" :class="activeTab === 'overview' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-tachometer-alt"></i>Overview <i class="fas fa-tachometer-alt" aria-hidden="true"></i>Overview
</button> </button>
<button @click="activeTab = 'general'" <button @click="activeTab = 'general'"
:class="activeTab === 'general' ? 'nav-tab-active' : ''" :class="activeTab === 'general' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-sliders-h"></i>General <i class="fas fa-sliders-h" aria-hidden="true"></i>General
</button> </button>
<button @click="activeTab = 'wifi'" <button @click="activeTab = 'wifi'"
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''" :class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-wifi"></i>WiFi <i class="fas fa-wifi" aria-hidden="true"></i>WiFi
</button> </button>
<button @click="activeTab = 'schedule'" <button @click="activeTab = 'schedule'"
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''" :class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-clock"></i>Schedule <i class="fas fa-clock" aria-hidden="true"></i>Schedule
</button> </button>
<button @click="activeTab = 'display'" <button @click="activeTab = 'display'"
:class="activeTab === 'display' ? 'nav-tab-active' : ''" :class="activeTab === 'display' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-desktop"></i>Display <i class="fas fa-desktop" aria-hidden="true"></i>Display
</button> </button>
<button @click="activeTab = 'durations'" <button @click="activeTab = 'durations'"
:class="activeTab === 'durations' ? 'nav-tab-active' : ''" :class="activeTab === 'durations' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-rotate"></i>Rotation <i class="fas fa-rotate" aria-hidden="true"></i>Rotation
</button> </button>
<button @click="activeTab = 'config-editor'" <button @click="activeTab = 'config-editor'"
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''" :class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-file-code"></i>Config Editor <i class="fas fa-file-code" aria-hidden="true"></i>Config Editor
</button> </button>
<button @click="activeTab = 'backup-restore'" <button @click="activeTab = 'backup-restore'"
:class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''" :class="activeTab === 'backup-restore' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-save"></i>Backup &amp; Restore <i class="fas fa-save" aria-hidden="true"></i>Backup &amp; Restore
</button> </button>
<button @click="activeTab = 'fonts'" <button @click="activeTab = 'fonts'"
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''" :class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-font"></i>Fonts <i class="fas fa-font" aria-hidden="true"></i>Fonts
</button> </button>
<button @click="activeTab = 'logs'" <button @click="activeTab = 'logs'"
:class="activeTab === 'logs' ? 'nav-tab-active' : ''" :class="activeTab === 'logs' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-file-alt"></i>Logs <i class="fas fa-file-alt" aria-hidden="true"></i>Logs
</button> </button>
<button @click="activeTab = 'cache'" <button @click="activeTab = 'cache'"
:class="activeTab === 'cache' ? 'nav-tab-active' : ''" :class="activeTab === 'cache' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-database"></i>Cache <i class="fas fa-database" aria-hidden="true"></i>Cache
</button> </button>
<button @click="activeTab = 'operation-history'" <button @click="activeTab = 'operation-history'"
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''" :class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-history"></i>Operation History <i class="fas fa-history" aria-hidden="true"></i>Operation History
</button> </button>
<button @click="activeTab = 'tools'" <button @click="activeTab = 'tools'"
:class="activeTab === 'tools' ? 'nav-tab-active' : ''" :class="activeTab === 'tools' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-tools"></i>Tools <i class="fas fa-tools" aria-hidden="true"></i>Tools
</button> </button>
</nav> </nav>
</div> </div>
<!-- Second row - Plugin tabs (populated dynamically) --> <!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200"> <div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4"> <nav aria-label="Plugins" class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }" <button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''" :class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab"> class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager <i class="fas fa-plug" aria-hidden="true"></i>Plugin Manager
</button> </button>
<!-- Installed plugin tabs will be added here dynamically --> <!-- Installed plugin tabs will be added here dynamically -->
</nav> </nav>
</div> </div>
</nav> </div>
</div> <!-- /#site-nav --> </div> <!-- /#site-nav -->
<!-- Floating mini display preview: available on every tab except <!-- Floating mini display preview: available on every tab except
@@ -563,16 +568,16 @@
persists in localStorage. --> persists in localStorage. -->
<div id="floating-preview" class="floating-preview" style="display:none"> <div id="floating-preview" class="floating-preview" style="display:none">
<div class="flex items-center justify-between px-2 py-1"> <div class="flex items-center justify-between px-2 py-1">
<span class="text-xs text-gray-300"><i class="fas fa-tv mr-1"></i>Live Preview</span> <span class="text-xs text-gray-300"><i class="fas fa-tv mr-1" aria-hidden="true"></i>Live Preview</span>
<span class="flex items-center"> <span class="flex items-center">
<button type="button" onclick="window.cycleFloatingPreviewSize()" <button type="button" onclick="window.cycleFloatingPreviewSize()"
class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size" class="text-gray-400 hover:text-white ml-2" aria-label="Cycle preview size"
title="Cycle preview size (drag the corner to resize on desktop)"> title="Cycle preview size (drag the corner to resize on desktop)">
<i class="fas fa-up-right-and-down-left-from-center text-xs"></i> <i class="fas fa-up-right-and-down-left-from-center text-xs" aria-hidden="true"></i>
</button> </button>
<button type="button" onclick="window.toggleFloatingPreview(false)" <button type="button" onclick="window.toggleFloatingPreview(false)"
class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview"> class="text-gray-400 hover:text-white ml-2" aria-label="Close live preview">
<i class="fas fa-times text-xs"></i> <i class="fas fa-times text-xs" aria-hidden="true"></i>
</button> </button>
</span> </span>
</div> </div>
@@ -582,12 +587,12 @@
<button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)" <button id="floating-preview-toggle" type="button" onclick="window.toggleFloatingPreview(true)"
class="floating-preview-toggle" style="display:none" class="floating-preview-toggle" style="display:none"
title="Show live display preview" aria-label="Show live display preview"> title="Show live display preview" aria-label="Show live display preview">
<i class="fas fa-tv"></i> <i class="fas fa-tv" aria-hidden="true"></i>
</button> </button>
<!-- Tab content --> <!-- Tab content -->
<div id="tab-content" class="space-y-6"> <div id="tab-content" class="space-y-6" tabindex="-1">
<!-- Overview tab --> <!-- Overview tab -->
<div x-show="activeTab === 'overview'" x-transition> <div x-show="activeTab === 'overview'" x-transition>
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()"> <div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="loadtab" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
@@ -606,7 +611,10 @@
const overviewContent = document.getElementById('overview-content'); const overviewContent = document.getElementById('overview-content');
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) { if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/overview') fetch('/v3/partials/overview')
.then(response => response.text()) .then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => { .then(html => {
overviewContent.innerHTML = html; overviewContent.innerHTML = html;
overviewContent.setAttribute('data-loaded', 'true'); overviewContent.setAttribute('data-loaded', 'true');
@@ -620,7 +628,7 @@
}) })
.catch(err => { .catch(err => {
console.error('Failed to load overview content:', err); console.error('Failed to load overview content:', err);
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>'; overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load the Overview. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadOverviewDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
}); });
} }
} }
@@ -698,7 +706,10 @@
const wifiContent = document.getElementById('wifi-content'); const wifiContent = document.getElementById('wifi-content');
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) { if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
fetch('/v3/partials/wifi') fetch('/v3/partials/wifi')
.then(response => response.text()) .then(response => {
if (!response.ok) throw new Error(response.status + ' ' + response.statusText);
return response.text();
})
.then(html => { .then(html => {
wifiContent.innerHTML = html; wifiContent.innerHTML = html;
wifiContent.setAttribute('data-loaded', 'true'); wifiContent.setAttribute('data-loaded', 'true');
@@ -709,7 +720,7 @@
}) })
.catch(err => { .catch(err => {
console.error('Failed to load WiFi content:', err); console.error('Failed to load WiFi content:', err);
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>'; wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4" role="alert"><p class="text-red-800">Couldn’t load WiFi setup. Check that the device is still reachable, then try again.</p><button type="button" onclick="loadWifiDirect()" class="mt-3 px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white rounded-md">Try again</button></div>';
}); });
} }
} }
@@ -952,9 +963,6 @@
<!-- Notifications --> <!-- Notifications -->
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div> <div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
<!-- SSE connection for real-time updates -->
<script src="{{ url_for('static', filename='v3/js/app-shell.js') }}"></script>
<!-- Custom v3 JavaScript --> <!-- Custom v3 JavaScript -->
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
@@ -964,6 +972,7 @@
<!-- Modular Plugin Management JavaScript --> <!-- Modular Plugin Management JavaScript -->
<!-- Load utilities first --> <!-- Load utilities first -->
<script src="{{ url_for('static', filename='v3/js/utils/dialog.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
<!-- Load core API client first (used by other modules) --> <!-- Load core API client first (used by other modules) -->
@@ -979,43 +988,10 @@
<!-- Load config utilities --> <!-- Load config utilities -->
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script> <script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
<!-- Widget Registry System --> <!-- Widget Registry System: all widget scripts in one request (see
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script> web_interface/widget_bundle.py). The individual files stay served at
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script> their own URLs for plugin-loader.js and debugging. -->
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script> <script src="{{ widgets_bundle_url() }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-order-list.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-oauth.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload-single.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-file-manager.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
<!-- Basic input widgets -->
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/style-editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
<!-- Reusable JSON file manager widget (used by of-the-day and others via x-widget: json-file-manager) -->
<script src="{{ url_for('static', filename='v3/js/widgets/json-file-manager.js') }}" defer></script>
<!-- plugins_manager.js: loaded LAST on purpose — it defines the live <!-- plugins_manager.js: loaded LAST on purpose — it defines the live
window.* implementations for several plugin actions (installPlugin, window.* implementations for several plugin actions (installPlugin,
@@ -1030,17 +1006,17 @@
<!-- On-Demand Modal (moved here from plugins.html so it's always available) --> <!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;"> <div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg"> <div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg" role="dialog" aria-modal="true" aria-labelledby="on-demand-modal-title">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3> <h2 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h2>
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600"> <button type="button" id="close-on-demand-modal" class="text-gray-500 hover:text-gray-700 p-2 -m-2 rounded-md" aria-label="Close">
<i class="fas fa-times"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
</div> </div>
<!-- Service Status Alert --> <!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md"> <div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start"> <div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i> <i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2" aria-hidden="true"></i>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p> <p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1"> <p class="text-xs text-yellow-700 mt-1">
@@ -1148,8 +1124,8 @@
window.applyUpdate = function() { window.applyUpdate = function() {
var btn = document.getElementById('update-banner-btn'); var btn = document.getElementById('update-banner-btn');
var originalHTML = '<i class="fas fa-download mr-1"></i> Update Now'; var originalHTML = '<i class="fas fa-download mr-1" aria-hidden="true"></i> Update Now';
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Updating...'; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Updating...';
btn.disabled = true; btn.disabled = true;
fetch('/api/v3/system/action', { fetch('/api/v3/system/action', {
method: 'POST', method: 'POST',
@@ -1191,9 +1167,18 @@
}); });
}; };
// Initial check shortly after page load, then periodic // Initial check shortly after page load, then every CHECK_INTERVAL while
setTimeout(checkForUpdate, 2000); // the page is visible. A hidden tab makes no requests and catches up as
setInterval(checkForUpdate, CHECK_INTERVAL); // soon as it is shown again.
var lastCheck = 0;
function checkIfDue() {
if (document.hidden || Date.now() - lastCheck < CHECK_INTERVAL) return;
lastCheck = Date.now();
checkForUpdate();
}
setTimeout(checkIfDue, 2000);
setInterval(checkIfDue, 60 * 1000);
document.addEventListener('visibilitychange', checkIfDue);
})(); })();
</script> </script>
</body> </body>
+155 -75
View File
@@ -3,39 +3,48 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>LEDMatrix WiFi Setup</title> <title>LEDMatrix WiFi Setup</title>
<style> <style>
:root{--bg:#f3f4f6;--surface:#fff;--text:#1f2937;--label:#374151;--muted:#4b5563;--faint:#6b7280;--line:#e5e7eb;--field:#d1d5db;--accent:#2563eb;--accent-hover:#1d4ed8;--scan:#e5e7eb;--scan-hover:#d1d5db;--ok-bg:#d1fae5;--ok:#065f46;--err-bg:#fee2e2;--err:#991b1b;--info-bg:#dbeafe;--info:#1e40af}
@media (prefers-color-scheme:dark){:root{--bg:#111827;--surface:#1f2937;--text:#f9fafb;--label:#d1d5db;--muted:#d1d5db;--faint:#9ca3af;--line:#374151;--field:#4b5563;--accent:#3b82f6;--accent-hover:#60a5fa;--scan:#374151;--scan-hover:#4b5563;--ok-bg:#064e3b;--ok:#6ee7b7;--err-bg:#7f1d1d;--err:#fecaca;--info-bg:#1e3a5f;--info:#bfdbfe}}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f3f4f6;color:#1f2937;padding:16px;max-width:480px;margin:0 auto} body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);padding:16px;max-width:480px;margin:0 auto;line-height:1.45}
h1{font-size:20px;margin-bottom:4px} h1{font-size:20px;margin-bottom:4px}
.subtitle{color:#6b7280;font-size:13px;margin-bottom:20px} .subtitle{color:var(--muted);font-size:14px;margin-bottom:20px}
.card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px} .card{background:var(--surface);border-radius:12px;padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.1);margin-bottom:16px}
label{display:block;font-size:13px;font-weight:600;color:#374151;margin-bottom:6px} label{display:block;font-size:14px;font-weight:600;color:var(--label);margin-bottom:6px}
select,input[type=text],input[type=password]{width:100%;padding:10px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:15px;background:#fff;-webkit-appearance:none;appearance:none} .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center} /* 16px inputs: iOS Safari zooms any focused field smaller than that */
select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.15)} select,input[type=text],input[type=password]{width:100%;min-width:0;min-height:44px;padding:10px 12px;border:1px solid var(--field);border-radius:8px;font-size:16px;background-color:var(--surface);color:var(--text);-webkit-appearance:none;appearance:none}
.btn{display:block;width:100%;padding:12px;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;text-align:center;transition:background .15s} select{padding-right:32px;text-overflow:ellipsis;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8} select:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:1px;border-color:var(--accent)}
.btn-scan{background:#e5e7eb;color:#374151}.btn-scan:hover{background:#d1d5db} .btn{display:block;width:100%;min-height:44px;padding:12px;border:none;border-radius:8px;font-size:16px;font-weight:600;cursor:pointer;text-align:center;transition:background-color .15s}
.btn:disabled{background:#d1d5db;color:#9ca3af;cursor:not-allowed} .btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-scan{background:var(--scan);color:var(--label);width:auto;padding:10px 16px;white-space:nowrap}.btn-scan:hover{background:var(--scan-hover)}
.btn:disabled{background:var(--scan);color:var(--muted);cursor:not-allowed;opacity:.75}
.row{display:flex;gap:8px;margin-bottom:16px} .row{display:flex;gap:8px;margin-bottom:16px}
.row>*:first-child{flex:1} .row>*:first-child{flex:1;min-width:0}
.msg{padding:12px;border-radius:8px;font-size:13px;margin-bottom:12px;display:none} .msg{padding:12px;border-radius:8px;font-size:14px;margin-bottom:12px;display:none;overflow-wrap:anywhere}
.msg-ok{background:#d1fae5;color:#065f46;display:block} .msg-ok{background:var(--ok-bg);color:var(--ok);display:block}
.msg-err{background:#fee2e2;color:#991b1b;display:block} .msg-err{background:var(--err-bg);color:var(--err);display:block}
.msg-info{background:#dbeafe;color:#1e40af;display:block} .msg-info{background:var(--info-bg);color:var(--info);display:block}
.step{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px} .step{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:4px}
.sep{margin:16px 0;border:none;border-top:1px solid #e5e7eb} .spinner{display:inline-block;width:14px;height:14px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .6s linear infinite;vertical-align:-2px;margin-right:6px}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes spin{to{transform:rotate(360deg)}} @keyframes spin{to{transform:rotate(360deg)}}
.footer{text-align:center;margin-top:20px;font-size:12px;color:#9ca3af} @media (prefers-reduced-motion:reduce){.spinner{animation-duration:1.8s}.btn{transition:none}}
.footer a{color:#3b82f6;text-decoration:none} .footer{text-align:center;margin-top:20px;font-size:14px}
.footer a{color:var(--accent);text-underline-offset:.15em;display:inline-block;padding:10px}
.success-box{text-align:center;padding:24px} .success-box{text-align:center;padding:24px}
.success-box .icon{font-size:48px;margin-bottom:12px} .success-box .icon{width:48px;height:48px;margin:0 auto 12px;color:#059669}
.success-box .ip{font-size:18px;font-weight:700;color:#2563eb;word-break:break-all} .success-box .title{font-size:16px;font-weight:600;margin-bottom:8px}
.success-box .hint{font-size:14px;color:var(--muted);margin-bottom:12px}
.success-box .note{font-size:13px;color:var(--faint);margin-top:12px}
.success-box .ip{font-size:18px;font-weight:700;color:var(--accent);word-break:break-all}
.hidden{display:none} .hidden{display:none}
.or-divider{text-align:center;color:#9ca3af;font-size:12px;margin:12px 0;position:relative} .or-divider{text-align:center;color:var(--faint);font-size:13px;margin:12px 0;position:relative}
.or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:40%;height:1px;background:#e5e7eb} .or-divider::before,.or-divider::after{content:'';position:absolute;top:50%;width:30%;height:1px;background:var(--line)}
.or-divider::before{left:0} .or-divider::before{left:0}
.or-divider::after{right:0} .or-divider::after{right:0}
</style> </style>
@@ -45,44 +54,48 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
<h1>LEDMatrix WiFi Setup</h1> <h1>LEDMatrix WiFi Setup</h1>
<p class="subtitle">Connect your device to a WiFi network</p> <p class="subtitle">Connect your device to a WiFi network</p>
<div id="msg" class="msg"></div> <div id="msg" class="msg" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="setup-form"> <div id="setup-form">
<div class="card"> <div class="card">
<div class="step">Step 1 &mdash; Choose Network</div> <div class="step" aria-hidden="true">Step 1 &mdash; Choose Network</div>
<label for="net-select">Available Networks</label> <label for="net-select">Available Networks</label>
<div class="row"> <div class="row">
<select id="net-select" onchange="onSelectNetwork()"> <select id="net-select" onchange="onSelectNetwork()">
<option value="">-- Scan to find networks --</option> <option value="">-- Scan to find networks --</option>
</select> </select>
<button class="btn btn-scan" id="btn-scan" onclick="doScan()" style="width:auto;padding:10px 16px"> <button type="button" class="btn btn-scan" id="btn-scan" onclick="doScan()">
Scan Scan
</button> </button>
</div> </div>
<div class="or-divider">or enter manually</div> <div class="or-divider" aria-hidden="true">or enter manually</div>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" oninput="onManualInput()"> <label for="manual-ssid" class="sr-only">Network name (SSID), if it isn't listed</label>
<input type="text" id="manual-ssid" placeholder="Network name (SSID)" maxlength="32"
autocomplete="off" autocapitalize="off" spellcheck="false" oninput="onManualInput()">
</div> </div>
<div class="card"> <div class="card">
<div class="step">Step 2 &mdash; Password</div> <div class="step" aria-hidden="true">Step 2 &mdash; Password</div>
<label for="password">WiFi Password</label> <label for="password">WiFi Password</label>
<input type="password" id="password" placeholder="Leave empty for open networks"> <input type="password" id="password" placeholder="Leave empty for open networks" maxlength="63"
autocomplete="current-password" aria-describedby="password-hint">
<p id="password-hint" class="sr-only">Leave empty for open networks.</p>
</div> </div>
<div class="card"> <div class="card">
<button class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled> <button type="button" class="btn btn-primary" id="btn-connect" onclick="doConnect()" disabled>
Connect Connect
</button> </button>
</div> </div>
</div> </div>
<div id="success-view" class="card hidden"> <div id="success-view" class="card hidden" tabindex="-1" aria-labelledby="success-title">
<div class="success-box"> <div class="success-box">
<div class="icon">&#10003;</div> <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M7.5 12.5l3 3 6-6"/></svg>
<p style="font-size:16px;font-weight:600;margin-bottom:8px">Connected!</p> <p class="title" id="success-title">Connected!</p>
<p style="font-size:13px;color:#6b7280;margin-bottom:12px">Your device is now on the network. Access the full interface at:</p> <p class="hint">Your device is now on the network. Access the full interface at:</p>
<p class="ip" id="new-ip"></p> <p class="ip" id="new-ip"></p>
<p style="font-size:12px;color:#9ca3af;margin-top:12px">You may need to reconnect your phone to the same WiFi network.</p> <p class="note">You may need to reconnect your phone to the same WiFi network.</p>
</div> </div>
</div> </div>
@@ -94,17 +107,53 @@ select:focus,input:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px
var selectedSSID = ''; var selectedSSID = '';
var scanning = false; var scanning = false;
var connecting = false; var connecting = false;
var msgTimer = null;
function $(id) { return document.getElementById(id); } function $(id) { return document.getElementById(id); }
function showMsg(text, type) { // fetch() with a deadline: on a flaky AP link a request can otherwise hang
var el = $('msg'); // forever and leave the buttons stuck in their busy state.
el.textContent = text; function fetchWithTimeout(url, options, ms) {
el.className = 'msg msg-' + (type || 'info'); options = options || {};
if (type === 'ok') setTimeout(function() { el.style.display = 'none'; }, 8000); if (typeof AbortController === 'undefined') return fetch(url, options);
var controller = new AbortController();
var timer = setTimeout(function() { controller.abort(); }, ms);
options.signal = controller.signal;
return fetch(url, options).finally(function() { clearTimeout(timer); });
} }
function clearMsg() { $('msg').className = 'msg'; } function readJson(r) {
return r.json().catch(function() {
throw new Error(r.ok ? 'bad-response' : 'http-' + r.status);
});
}
function setBusy(btn, label) {
btn.textContent = '';
var spin = document.createElement('span');
spin.className = 'spinner';
spin.setAttribute('aria-hidden', 'true');
btn.appendChild(spin);
btn.appendChild(document.createTextNode(label));
}
function showMsg(text, type) {
var el = $('msg');
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
el.style.display = '';
// Errors interrupt; progress and success wait their turn.
el.setAttribute('role', type === 'err' ? 'alert' : 'status');
el.setAttribute('aria-live', type === 'err' ? 'assertive' : 'polite');
el.textContent = text;
el.className = 'msg msg-' + (type || 'info');
if (type === 'ok') msgTimer = setTimeout(function() { el.style.display = 'none'; }, 8000);
}
function clearMsg() {
if (msgTimer) { clearTimeout(msgTimer); msgTimer = null; }
$('msg').className = 'msg';
$('msg').textContent = '';
}
function updateConnectBtn() { function updateConnectBtn() {
var ssid = $('net-select').value || $('manual-ssid').value.trim(); var ssid = $('net-select').value || $('manual-ssid').value.trim();
@@ -128,43 +177,58 @@ function doScan() {
scanning = true; scanning = true;
var btn = $('btn-scan'); var btn = $('btn-scan');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Scanning'; btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Scanning');
clearMsg(); clearMsg();
fetch('/api/v3/wifi/scan') fetchWithTimeout('/api/v3/wifi/scan', {}, 30000)
.then(function(r) { return r.json(); }) .then(readJson)
.then(function(data) { .then(function(data) {
var sel = $('net-select'); var sel = $('net-select');
var previous = sel.value;
sel.innerHTML = '<option value="">-- Select a network --</option>'; sel.innerHTML = '<option value="">-- Select a network --</option>';
if (data.status === 'success' && Array.isArray(data.data)) { if (data.status === 'success' && Array.isArray(data.data)) {
var nets = data.data; var seen = {};
var nets = data.data.filter(function(n) {
if (!n || !n.ssid || seen[n.ssid]) return false;
seen[n.ssid] = true;
return true;
});
for (var i = 0; i < nets.length; i++) { for (var i = 0; i < nets.length; i++) {
var n = nets[i]; var n = nets[i];
var opt = document.createElement('option'); var opt = document.createElement('option');
opt.value = n.ssid; opt.value = n.ssid;
opt.textContent = n.ssid + ' (' + n.signal + '% - ' + n.security + ')'; var details = [];
if (n.signal !== undefined && n.signal !== null) details.push(n.signal + '%');
if (n.security) details.push(n.security);
opt.textContent = n.ssid + (details.length ? ' (' + details.join(' - ') + ')' : '');
sel.appendChild(opt); sel.appendChild(opt);
} }
if (previous && seen[previous]) sel.value = previous;
if (nets.length > 0) { if (nets.length > 0) {
var msg = 'Found ' + nets.length + ' network' + (nets.length > 1 ? 's' : ''); var msg = 'Found ' + nets.length + (nets.length === 1 ? ' network' : ' networks');
if (data.cached) { if (data.cached) {
msg += ' \u2014 Showing cached networks. Connect to see the latest.'; msg += ' — showing cached networks. Connect to see the latest.';
} }
showMsg(msg, data.cached ? 'info' : 'ok'); showMsg(msg, data.cached ? 'info' : 'ok');
} else { } else {
showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again.'), 'info'); showMsg('No networks found. ' + (data.cached ? 'Enter your network name manually.' : 'Try scanning again, or enter your network name manually.'), 'info');
} }
} else { } else {
showMsg(data.message || 'Scan failed', 'err'); showMsg(data.message || 'Scan failed. Try again, or enter your network name manually.', 'err');
} }
}) })
.catch(function(e) { .catch(function(e) {
showMsg('Scan failed: ' + e.message, 'err'); var why = e && e.name === 'AbortError'
? 'The scan took too long.'
: 'Couldn’t reach the device to scan.';
showMsg(why + ' Stay close to the device and try again, or enter your network name manually.', 'err');
}) })
.finally(function() { .finally(function() {
scanning = false; scanning = false;
btn.disabled = false; btn.disabled = false;
btn.innerHTML = 'Scan'; btn.removeAttribute('aria-busy');
btn.textContent = 'Scan';
updateConnectBtn(); updateConnectBtn();
}); });
} }
@@ -175,16 +239,25 @@ function doConnect() {
connecting = true; connecting = true;
var btn = $('btn-connect'); var btn = $('btn-connect');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Connecting...'; btn.setAttribute('aria-busy', 'true');
setBusy(btn, 'Connecting…');
clearMsg(); clearMsg();
showMsg('Connecting to ' + ssid + '... This may take 15-30 seconds.', 'info'); showMsg('Connecting to ' + ssid + '… This may take 15–30 seconds.', 'info');
fetch('/api/v3/wifi/connect', { function resetConnect() {
connecting = false;
btn.disabled = false;
btn.removeAttribute('aria-busy');
btn.textContent = 'Connect';
updateConnectBtn();
}
fetchWithTimeout('/api/v3/wifi/connect', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ssid: ssid, password: $('password').value || '' }) body: JSON.stringify({ ssid: ssid, password: $('password').value || '' })
}) }, 60000)
.then(function(r) { return r.json(); }) .then(readJson)
.then(function(data) { .then(function(data) {
if (data.status === 'success') { if (data.status === 'success') {
clearMsg(); clearMsg();
@@ -192,15 +265,19 @@ function doConnect() {
setTimeout(function() { checkNewIP(ssid); }, 3000); setTimeout(function() { checkNewIP(ssid); }, 3000);
} else { } else {
var msg = data.error_type === 'wrong_password' var msg = data.error_type === 'wrong_password'
? 'Incorrect password — please try again' ? 'Incorrect password — please try again.'
: (data.message || 'Connection failed'); : (data.message || 'Couldn’t connect to ' + ssid + '. Check the network name and password, then try again.');
showMsg(msg, 'err'); showMsg(msg, 'err');
connecting = false; resetConnect();
btn.disabled = false; if (data.error_type === 'wrong_password') $('password').focus();
btn.innerHTML = 'Connect';
} }
}) })
.catch(function(e) { .catch(function(e) {
if (e && /^http-|^bad-response$/.test(e.message)) {
showMsg('The device couldn’t start the connection. Try again in a moment.', 'err');
resetConnect();
return;
}
// Connection may drop if AP mode was disabled — that's expected // Connection may drop if AP mode was disabled — that's expected
clearMsg(); clearMsg();
showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info'); showMsg('Connection attempt sent. If the page stops responding, the device is connecting to ' + ssid + '.', 'info');
@@ -216,8 +293,8 @@ function checkNewIP(ssid, retriesLeft) {
showSuccessFallback(ssid); showSuccessFallback(ssid);
return; return;
} }
fetch('/api/v3/wifi/status') fetchWithTimeout('/api/v3/wifi/status', {}, 8000)
.then(function(r) { return r.json(); }) .then(readJson)
.then(function(data) { .then(function(data) {
if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) { if (data.status === 'success' && data.data && data.data.connected && data.data.ip_address) {
showSuccess(data.data.ip_address); showSuccess(data.data.ip_address);
@@ -231,18 +308,21 @@ function checkNewIP(ssid, retriesLeft) {
}); });
} }
function showSuccess(ip) { function revealSuccess(text) {
$('setup-form').classList.add('hidden'); $('setup-form').classList.add('hidden');
$('success-view').classList.remove('hidden'); var view = $('success-view');
$('new-ip').textContent = 'http://' + ip + ':5000'; view.classList.remove('hidden');
$('msg').className = 'msg'; $('new-ip').textContent = text;
clearMsg();
view.focus();
}
function showSuccess(ip) {
revealSuccess('http://' + ip + ':5000');
} }
function showSuccessFallback(ssid) { function showSuccessFallback(ssid) {
$('setup-form').classList.add('hidden'); revealSuccess('Check your router for the device IP');
$('success-view').classList.remove('hidden');
$('new-ip').textContent = 'Check your router for the device IP';
$('msg').className = 'msg';
} }
// Auto-scan on load // Auto-scan on load
@@ -52,7 +52,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Backup file</label> <label for="restore-file-input" class="block text-sm font-medium text-gray-700 mb-2">Backup file</label>
<input type="file" id="restore-file-input" accept=".zip" <input type="file" id="restore-file-input" accept=".zip"
class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"> class="block w-full text-sm text-gray-700 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div> </div>
@@ -1039,10 +1039,25 @@ window.showDisplaySaveResult = function(xhr) {
content.appendChild(textSpan); content.appendChild(textSpan);
} }
// Initial UI state and polling — guard against duplicate intervals on re-run // Initial UI state, then the 5s sync-status poll — only while the Display
// tab is active and the page is visible. LEDVisibility keys the
// registration, so a re-run of this partial replaces the previous timer.
function stopSyncTimer() {
if (window.syncStatusInterval) {
clearInterval(window.syncStatusInterval);
window.syncStatusInterval = null;
}
}
function startSyncTimer() {
stopSyncTimer();
pollSyncStatus();
window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
}
function startSyncPolling() { function startSyncPolling() {
updateSyncUI(); updateSyncUI();
if (!window.syncStatusInterval) { if (window.LEDVisibility) {
window.LEDVisibility.onActive('display', startSyncTimer, stopSyncTimer, 'display-sync-status');
} else if (!window.syncStatusInterval) {
window.syncStatusInterval = setInterval(pollSyncStatus, 5000); window.syncStatusInterval = setInterval(pollSyncStatus, 5000);
} }
} }
+42 -30
View File
@@ -39,7 +39,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label> <label for="upload-font-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family Name</label>
<input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font"> <input type="text" id="upload-font-family" class="form-control" placeholder="e.g., my-custom-font">
<p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p> <p class="text-sm text-gray-600 mt-1">Custom name for this font (letters, numbers, underscores, hyphens)</p>
</div> </div>
@@ -75,24 +75,24 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6">
<div> <div>
<div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center"> <div id="font-preview-container" class="border border-gray-300 bg-black rounded p-4 min-h-[100px] flex items-center justify-center">
<img id="font-preview-image" src="" alt="Font preview" class="max-w-full" style="display: none;"> <img id="font-preview-image" alt="Font preview" class="max-w-full" style="display: none;">
<span id="font-preview-loading" class="text-gray-400">Select a font to preview</span> <span id="font-preview-loading" class="text-gray-400">Select a font to preview</span>
</div> </div>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label> <label for="preview-text" class="block text-sm font-medium text-gray-700 mb-1">Preview Text</label>
<input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm"> <input type="text" id="preview-text" value="Sample Text 123" class="form-control text-sm">
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label> <label for="preview-family" class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
<select id="preview-family" class="form-control text-sm"> <select id="preview-family" class="form-control text-sm">
<!-- Dynamically populated from font catalog --> <!-- Dynamically populated from font catalog -->
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label> <label for="preview-size" class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
<select id="preview-size" class="form-control text-sm"> <select id="preview-size" class="form-control text-sm">
<option value="xs">XS (6px)</option> <option value="xs">XS (6px)</option>
<option value="sm">SM (8px)</option> <option value="sm">SM (8px)</option>
@@ -163,11 +163,18 @@ function initializeFontsTab() {
// Reset retry counter on successful init // Reset retry counter on successful init
initRetryCount = 0; initRetryCount = 0;
// showNotification is provided by the notification widget (notification.js) // showNotification is implemented by the notification widget
// Fallback only if widget hasn't loaded yet // (widgets/notification.js). If it hasn't loaded, queue the message; the
// widget shows queued messages as soon as it registers.
if (typeof window.showNotification !== 'function') { if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') { window.showNotification = function(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message); 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 || {}));
}
(window.__pendingNotifications = window.__pendingNotifications || []).push([message, type]);
console.log(`[${String((type && type.type) || type).toUpperCase()}]`, message);
}; };
} }
@@ -725,9 +732,8 @@ function updateUploadProgress(percent) {
<style> <style>
.drag-over { .drag-over {
border-color: #3b82f6 !important; border-color: var(--color-primary, #3b82f6) !important;
background-color: #eff6ff !important; background-color: var(--color-info-bg, #eff6ff) !important;
transform: scale(1.02);
} }
.font-upload-area { .font-upload-area {
@@ -735,45 +741,51 @@ function updateUploadProgress(percent) {
} }
.upload-dropzone { .upload-dropzone {
border: 3px dashed #d1d5db; border: 2px dashed var(--color-field-border, #d1d5db);
border-radius: 10px; border-radius: 0.75rem;
padding: 40px; padding: 2rem 1.5rem;
text-align: center; text-align: center;
background: #fafafa; background: var(--color-background, #f9fafb);
transition: all 0.3s ease; transition: border-color 0.15s ease, background-color 0.15s ease;
cursor: pointer; cursor: pointer;
} }
@media (min-width: 640px) {
.upload-dropzone {
padding: 2.5rem;
}
}
.upload-dropzone:hover { .upload-dropzone:hover {
border-color: #3b82f6; border-color: var(--color-primary, #3b82f6);
background: #f0f8ff; background: var(--color-info-bg, #eff6ff);
} }
.upload-dropzone i { .upload-dropzone i {
font-size: 3rem; font-size: 3rem;
color: #3b82f6; color: var(--color-primary, #3b82f6);
margin-bottom: 15px; margin-bottom: 0.75rem;
display: block; display: block;
} }
.upload-dropzone p { .upload-dropzone p {
margin: 5px 0; margin: 0.25rem 0;
font-size: 1.1rem; font-size: 1rem;
color: #374151; color: var(--color-text-secondary, #374151);
} }
.upload-hint { .upload-hint {
font-size: 0.9rem !important; font-size: 0.875rem !important;
color: #6b7280 !important; color: var(--color-text-tertiary, #4b5563) !important;
font-style: italic; font-style: italic;
} }
.upload-form { .upload-form {
margin-top: 20px; margin-top: 1.25rem;
padding: 20px; padding: 1.25rem;
background: #f9f9f9; background: var(--color-background, #f9fafb);
border-radius: 8px; border-radius: 0.5rem;
border: 1px solid #d1d5db; border: 1px solid var(--color-border, #d1d5db);
} }
#font-preview-container { #font-preview-container {
+51 -17
View File
@@ -17,9 +17,9 @@
<!-- Controls --> <!-- Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4"> <div class="flex flex-wrap items-center gap-3 sm:gap-4">
<!-- Log Mode Toggle --> <!-- Log Mode Toggle -->
<div class="flex items-center space-x-2"> <div class="flex items-center gap-2">
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked> <input type="checkbox" id="log-realtime-toggle" class="form-control h-4 w-4" checked>
<span class="ml-2 text-sm font-medium">Real-time</span> <span class="ml-2 text-sm font-medium">Real-time</span>
@@ -31,7 +31,7 @@
</div> </div>
<!-- Log Level Filter --> <!-- Log Level Filter -->
<select id="log-level-filter" class="form-control text-sm"> <select id="log-level-filter" aria-label="Filter logs by level" class="form-control text-sm w-auto">
<option value="">All Levels</option> <option value="">All Levels</option>
<option value="ERROR">Errors Only</option> <option value="ERROR">Errors Only</option>
<option value="WARNING">Warnings & Errors</option> <option value="WARNING">Warnings & Errors</option>
@@ -39,18 +39,18 @@
</select> </select>
<!-- Plugin Filter --> <!-- Plugin Filter -->
<select id="log-plugin-filter" class="form-control text-sm"> <select id="log-plugin-filter" aria-label="Filter logs by plugin" class="form-control text-sm w-auto">
<option value="">All Plugins</option> <option value="">All Plugins</option>
</select> </select>
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48"> <input type="text" id="log-search" placeholder="Search logs..." aria-label="Search logs" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i> <i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs pointer-events-none" aria-hidden="true"></i>
</div> </div>
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex flex-wrap items-center gap-2">
<!-- Auto-scroll toggle --> <!-- Auto-scroll toggle -->
<label class="flex items-center"> <label class="flex items-center">
<input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked> <input type="checkbox" id="log-autoscroll" class="form-control h-4 w-4" checked>
@@ -72,7 +72,7 @@
<!-- Log Display --> <!-- Log Display -->
<div class="relative"> <div class="relative">
<div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;"> <div id="logs-container" class="bg-gray-900 text-gray-100 font-mono text-sm rounded-lg p-3 border border-gray-700 shadow-inner relative" style="height: 500px; min-height: 400px; max-height: 70vh;">
<div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900"> <div id="logs-loading" class="absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center"> <div class="text-center">
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i> <i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
<p>Loading logs...</p> <p>Loading logs...</p>
@@ -83,7 +83,7 @@
<!-- Logs will be inserted here --> <!-- Logs will be inserted here -->
</div> </div>
</div> </div>
<div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-400 bg-gray-900"> <div id="logs-empty" class="hidden absolute inset-0 flex items-center justify-center text-gray-300 bg-gray-900">
<div class="text-center"> <div class="text-center">
<i class="fas fa-file-alt text-4xl mb-2"></i> <i class="fas fa-file-alt text-4xl mb-2"></i>
<p>No logs available</p> <p>No logs available</p>
@@ -215,13 +215,32 @@ window._filteredLogs = [];
}); });
} }
refreshCurrentPluginStatus(); // Current-plugin poll and the realtime log stream run only while the Logs
if (window._currentPluginPollTimer) { // tab is active and the page is visible (both restart with an immediate
clearInterval(window._currentPluginPollTimer); // refresh). Re-registering on partial reload replaces the old one.
function startLogsActivity() {
refreshCurrentPluginStatus();
if (window._currentPluginPollTimer) clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
if (window._isRealtime && !window._logsEventSource) setupRealtimeLogs();
}
function stopLogsActivity() {
if (window._currentPluginPollTimer) {
clearInterval(window._currentPluginPollTimer);
window._currentPluginPollTimer = null;
}
stopRealtimeLogs();
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('logs', startLogsActivity, stopLogsActivity);
} else {
startLogsActivity();
} }
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
// Handle window resize for responsive height // Handle window resize for responsive height. Added once: this script
// re-runs on every partial load, and the handler reads window globals.
if (window._logsResizeListenerAdded) return;
window._logsResizeListenerAdded = true;
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
if (window._logContainer) { if (window._logContainer) {
// Force a reflow to update sizing // Force a reflow to update sizing
@@ -270,10 +289,24 @@ function loadLogs() {
}); });
} }
function setupRealtimeLogs() { // Closes the log stream and cancels any pending reconnect, so a paused
// (inactive tab / hidden page) stream stays closed.
function stopRealtimeLogs() {
if (window._logsReconnectTimer) {
clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = null;
}
if (window._logsEventSource) { if (window._logsEventSource) {
window._logsEventSource.close(); window._logsEventSource.close();
window._logsEventSource = null;
} }
}
function setupRealtimeLogs() {
stopRealtimeLogs();
// Don't open (or reconnect) while the Logs tab isn't on screen;
// LEDVisibility restarts the stream when it is.
if (window.LEDVisibility && !window.LEDVisibility.isActive('logs')) return;
window._logsEventSource = new EventSource('/api/v3/stream/logs'); window._logsEventSource = new EventSource('/api/v3/stream/logs');
@@ -302,8 +335,9 @@ function setupRealtimeLogs() {
<span>Disconnected from log stream</span> <span>Disconnected from log stream</span>
`; `;
// Attempt to reconnect after 5 seconds // Attempt to reconnect after 5 seconds (cancelled by stopRealtimeLogs)
setTimeout(setupRealtimeLogs, 5000); if (window._logsReconnectTimer) clearTimeout(window._logsReconnectTimer);
window._logsReconnectTimer = setTimeout(setupRealtimeLogs, 5000);
}; };
} }
@@ -8,12 +8,12 @@
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Filter by Plugin --> <!-- Filter by Plugin -->
<select id="history-plugin-filter" class="form-control text-sm"> <select id="history-plugin-filter" class="form-control text-sm" aria-label="Filter by plugin">
<option value="">All Plugins</option> <option value="">All Plugins</option>
</select> </select>
<!-- Filter by Operation Type --> <!-- Filter by Operation Type -->
<select id="history-type-filter" class="form-control text-sm"> <select id="history-type-filter" class="form-control text-sm" aria-label="Filter by operation type">
<option value="">All Operations</option> <option value="">All Operations</option>
<option value="install">Install</option> <option value="install">Install</option>
<option value="update">Update</option> <option value="update">Update</option>
@@ -24,7 +24,7 @@
</select> </select>
<!-- Filter by Status --> <!-- Filter by Status -->
<select id="history-status-filter" class="form-control text-sm"> <select id="history-status-filter" class="form-control text-sm" aria-label="Filter by status">
<option value="">All Statuses</option> <option value="">All Statuses</option>
<option value="success">Success</option> <option value="success">Success</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
@@ -32,8 +32,8 @@
<!-- Search --> <!-- Search -->
<div class="relative"> <div class="relative">
<input type="text" id="history-search" placeholder="Search operations..." class="form-control text-sm pl-8 pr-4 py-1 w-48"> <input type="text" id="history-search" placeholder="Search operations..." aria-label="Search operations" class="form-control text-sm pl-8 pr-4 py-1 w-48">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i> <i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs" aria-hidden="true"></i>
</div> </div>
</div> </div>
@@ -98,7 +98,7 @@
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p> <p class="text-sm font-semibold text-blue-900"><i class="fas fa-rocket mr-1"></i>Getting Started</p>
<p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p> <p class="text-xs text-blue-700 mt-0.5 mb-2">A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.</p>
<ul class="space-y-1 text-sm" id="getting-started-items"> <ul class="list-none pl-0 space-y-1 text-sm" id="getting-started-items">
<li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display"> <li><button type="button" class="gs-item text-left w-full" data-done="{{ '1' if _hw_done else '0' }}" data-tab="display">
<i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li> <i class="far fa-square mr-2"></i>Set your panel size (Display tab)</button></li>
<li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general"> <li><button type="button" class="gs-item text-left w-full" data-done="0" data-check="timezone" data-tz="{{ _tz }}" data-tab="general">
@@ -21,8 +21,8 @@
{# Schedule picker widget - renders enable/mode/times UI #} {# Schedule picker widget - renders enable/mode/times UI #}
{% set obj_value = value if value is not none else {} %} {% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label> <label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="schedule-picker-container mt-1"></div> <div id="{{ field_id }}_container" class="schedule-picker-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'> <input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div> </div>
<script> <script>
@@ -48,8 +48,8 @@
{# Time range widget - renders start/end time inputs #} {# Time range widget - renders start/end time inputs #}
{% set obj_value = value if value is not none else {} %} {% set obj_value = value if value is not none else {} %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label> <label id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
<div id="{{ field_id }}_container" class="time-range-container mt-1"></div> <div id="{{ field_id }}_container" class="time-range-container mt-1" role="group" aria-labelledby="{{ field_id }}-label"></div>
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'> <input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "&#39;") }}'>
</div> </div>
<script> <script>
@@ -88,6 +88,15 @@
</div> </div>
<script> <script>
(function() { (function() {
// ~10s at 50ms: enough for the widget bundle to arrive over a
// slow link to a Pi, bounded so a bundle that never registers
// style-editor doesn't poll for the life of the page.
var retries = 0;
var MAX_RETRIES = 200;
function giveUp() {
var container = document.getElementById('{{ field_id }}_container');
if (container) { container.remove(); }
}
function fallbackIsDirty(root) { function fallbackIsDirty(root) {
if (!root) { return false; } if (!root) { return false; }
var controls = root.querySelectorAll('input, select, textarea'); var controls = root.querySelectorAll('input, select, textarea');
@@ -106,9 +115,12 @@
return false; return false;
} }
function initWidget() { function initWidget() {
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; } var widget = window.LEDMatrixWidgets && window.LEDMatrixWidgets.get('style-editor');
var widget = window.LEDMatrixWidgets.get('style-editor'); if (!widget) {
if (!widget) { setTimeout(initWidget, 50); return; } // The plain fallback fields stay in place either way.
if (++retries < MAX_RETRIES) { setTimeout(initWidget, 50); } else { giveUp(); }
return;
}
var container = document.getElementById('{{ field_id }}_container'); var container = document.getElementById('{{ field_id }}_container');
var fallback = document.getElementById('{{ field_id }}_fallback'); var fallback = document.getElementById('{{ field_id }}_fallback');
if (!container) { return; } if (!container) { return; }
@@ -145,8 +157,27 @@
{{ render_nested_section(key, prop, value, prefix, plugin_id) }} {{ render_nested_section(key, prop, value, prefix, plugin_id) }}
{% endif %} {% endif %}
{% else %} {% else %}
{# Point the label at the control that actually takes focus. Widgets
render their own element (e.g. <id>_input), so a plain for="<id>"
would label nothing. Mirrors the branch order below; a miss just
falls back to the old for="<id>". #}
{% set _lw = prop.get('x-widget') or prop.get('x_widget') %}
{% set label_target = field_id %}
{% if field_type == 'boolean' %}
{% if _lw == 'toggle-switch' %}{% set label_target = field_id ~ '_button' %}{% endif %}
{% elif prop.enum %}
{% elif field_type in ['number', 'integer'] %}
{% if _lw in ['slider', 'number-input'] %}{% set label_target = field_id ~ '_input' %}{% endif %}
{% elif field_type == 'array' %}
{% if _lw == 'color-picker' %}{% set label_target = field_id ~ '_hex' %}
{% elif _lw == 'file-upload' %}{% set label_target = field_id ~ '_file_input' %}{% endif %}
{% elif _lw in ['text-input', 'textarea', 'select-dropdown', 'date-picker', 'time-picker', 'slider', 'email-input', 'url-input', 'password-input', 'font-selector'] %}
{% set label_target = field_id ~ '_input' %}
{% elif _lw == 'color-picker' %}
{% set label_target = field_id ~ '_color' %}
{% endif %}
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}"> <div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
<label for="{{ field_id }}" class="block text-sm font-medium text-gray-700 mb-1"> <label for="{{ label_target }}" id="{{ field_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}{{ ui.help_tip(description, label) }} {{ label }}{{ ui.help_tip(description, label) }}
</label> </label>
@@ -277,7 +308,11 @@
data-max-files="{{ max_files }}" data-max-files="{{ max_files }}"
data-max-size-mb="{{ max_size_mb }}" data-max-size-mb="{{ max_size_mb }}"
data-allowed-types="{{ allowed_types|join(',') }}" data-allowed-types="{{ allowed_types|join(',') }}"
onclick="document.getElementById('{{ field_id }}_file_input').click()"> role="button"
tabindex="0"
aria-label="Upload files: drop them here or press Enter to choose"
onclick="document.getElementById('{{ field_id }}_file_input').click()"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('{{ field_id }}_file_input').click();}">
<input type="file" <input type="file"
id="{{ field_id }}_file_input" id="{{ field_id }}_file_input"
multiple multiple
@@ -310,8 +345,9 @@
<div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200"> <div id="img_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center space-x-3 flex-1"> <div class="flex items-center space-x-3 flex-1">
<img src="/{{ img.get('path', '') }}" <img src="/{{ img.get('path', '') }}"
alt="{{ img.get('filename', '') }}" alt="{{ img.get('filename', '') }}"
loading="lazy" decoding="async"
class="w-16 h-16 object-cover rounded" class="w-16 h-16 object-cover rounded"
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';"> 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"> <div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
@@ -336,9 +372,10 @@
data-image-id="{{ img_id }}" data-image-id="{{ img_id }}"
data-image-idx="{{ loop.index0 }}" data-image-idx="{{ loop.index0 }}"
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))" onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
class="text-blue-600 hover:text-blue-800 p-2" class="text-blue-600 hover:text-blue-800 p-2"
title="Schedule this image"> title="Schedule this image"
<i class="fas fa-calendar-alt"></i> aria-label="Schedule image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
</button> </button>
<button type="button" <button type="button"
data-field-id="{{ field_id }}" data-field-id="{{ field_id }}"
@@ -346,8 +383,9 @@
data-plugin-id="{{ plugin_id_from_config }}" data-plugin-id="{{ plugin_id_from_config }}"
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)" onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
class="text-red-600 hover:text-red-800 p-2" class="text-red-600 hover:text-red-800 p-2"
title="Delete image"> title="Delete image"
<i class="fas fa-trash"></i> aria-label="Delete image {{ img.get('original_filename') or img.get('filename', '') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -387,7 +425,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="mt-1 space-y-2"> <div class="mt-1 space-y-2" role="group" aria-labelledby="{{ field_id }}-label">
{% for option in enum_items %} {% for option in enum_items %}
{% set is_checked = option in array_value %} {% set is_checked = option in array_value %}
{% set option_label = labels.get(option, option|replace('_', ' ')|title) %} {% set option_label = labels.get(option, option|replace('_', ' ')|title) %}
@@ -508,6 +546,7 @@
name="{{ full_key }}.{{ item_index }}.name" name="{{ full_key }}.{{ item_index }}.name"
value="{{ item.get('name', '') }}" value="{{ item.get('name', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm" class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed name, row {{ item_index + 1 }}"
placeholder="Feed Name" placeholder="Feed Name"
required> required>
</td> </td>
@@ -516,6 +555,7 @@
name="{{ full_key }}.{{ item_index }}.url" name="{{ full_key }}.{{ item_index }}.url"
value="{{ item.get('url', '') }}" value="{{ item.get('url', '') }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm" class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
aria-label="Feed URL, row {{ item_index + 1 }}"
placeholder="https://example.com/feed" placeholder="https://example.com/feed"
required> required>
</td> </td>
@@ -534,7 +574,7 @@
<i class="fas fa-upload mr-1"></i> Upload <i class="fas fa-upload mr-1"></i> Upload
</button> </button>
{% if logo_path %} {% if logo_path %}
<img src="/{{ logo_path }}" alt="Logo" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}"> <img src="/{{ logo_path }}" alt="Logo" loading="lazy" decoding="async" class="w-8 h-8 object-cover rounded border" id="{{ field_id }}_logo_preview_{{ item_index }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}"> <input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.path" value="{{ logo_path }}">
<input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}"> <input type="hidden" name="{{ full_key }}.{{ item_index }}.logo.id" value="{{ logo_value.get('id', '') }}">
{% else %} {% else %}
@@ -549,13 +589,15 @@
{% if item.get('enabled', true) %}checked{% endif %} {% if item.get('enabled', true) %}checked{% endif %}
value="true" value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'" onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
aria-label="Feed enabled, row {{ item_index + 1 }}"
class="h-4 w-4 text-blue-600"> class="h-4 w-4 text-blue-600">
</td> </td>
<td class="px-4 py-3 whitespace-nowrap text-center"> <td class="px-4 py-3 whitespace-nowrap text-center">
<button type="button" <button type="button"
onclick="removeCustomFeedRow(this)" onclick="removeCustomFeedRow(this)"
aria-label="Remove feed, row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1"> class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
</td> </td>
</tr> </tr>
@@ -624,6 +666,8 @@
<tr class="array-table-row" data-index="{{ item_index }}"> <tr class="array-table-row" data-index="{{ item_index }}">
{% for col_name in display_columns %} {% for col_name in display_columns %}
{% set col_def = item_properties.get(col_name, {}) %} {% set col_def = item_properties.get(col_name, {}) %}
{# Accessible name for the cell's control: column title + row. #}
{% set cell_label = col_def.get('title', col_name|replace('_', ' ')|title) ~ ', row ' ~ (item_index + 1) %}
{# Normalize nullable types e.g. ["null","integer"] → "integer" #} {# Normalize nullable types e.g. ["null","integer"] → "integer" #}
{% set _raw_type = col_def.get('type', 'string') %} {% set _raw_type = col_def.get('type', 'string') %}
{% if _raw_type is iterable and _raw_type is not string %} {% if _raw_type is iterable and _raw_type is not string %}
@@ -652,6 +696,7 @@
<input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}"> <input type="hidden" name="{{ full_key }}.{{ item_index }}.{{ col_name }}" value="{{ 'true' if col_value else 'false' }}">
<input type="checkbox" <input type="checkbox"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
{% if col_value %}checked{% endif %} {% if col_value %}checked{% endif %}
value="true" value="true"
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'" onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
@@ -659,6 +704,7 @@
{% elif col_type == 'integer' or col_type == 'number' %} {% elif col_type == 'integer' or col_type == 'number' %}
<input type="number" <input type="number"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}" value="{{ col_value if col_value is not none else '' }}"
{% if col_def.get('minimum') is not none %}min="{{ col_def.get('minimum') }}"{% endif %} {% if col_def.get('minimum') is not none %}min="{{ col_def.get('minimum') }}"{% endif %}
{% if col_def.get('maximum') is not none %}max="{{ col_def.get('maximum') }}"{% endif %} {% if col_def.get('maximum') is not none %}max="{{ col_def.get('maximum') }}"{% endif %}
@@ -671,6 +717,7 @@
which must not be title-cased behind the user's back. #} which must not be title-cased behind the user's back. #}
{% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %} {% set col_labels = (col_def.get('x-options') or col_def.get('x_options') or {}).get('labels') or {} %}
<select name="{{ full_key }}.{{ item_index }}.{{ col_name }}" <select name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white"> class="block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white">
{% for opt in col_enum %}{% if opt is not none %} {% for opt in col_enum %}{% if opt is not none %}
<option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option> <option value="{{ opt }}" {% if col_value == opt or (col_value is none and col_def.get('default') == opt) %}selected{% endif %}>{{ col_labels.get(opt, opt) }}</option>
@@ -679,28 +726,32 @@
{% elif col_xwidget == 'date-picker' %} {% elif col_xwidget == 'date-picker' %}
<input type="date" <input type="date"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}" value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"> class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'time-picker' %} {% elif col_xwidget == 'time-picker' %}
<input type="time" <input type="time"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '00:00' }}" value="{{ col_value if col_value is not none else '00:00' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"> class="block w-full px-2 py-1 border border-gray-300 rounded text-sm">
{% elif col_xwidget == 'file-upload-single' %} {% elif col_xwidget == 'file-upload-single' %}
{% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %} {% set cell_input_id = field_id ~ '_' ~ item_index ~ '_' ~ col_name %}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{% if col_value %}<img src="/{{ col_value }}" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %} {% if col_value %}<img src="/{{ col_value }}" alt="" loading="lazy" decoding="async" class="w-6 h-6 object-cover rounded flex-shrink-0" onerror="this.style.display='none'">{% endif %}
<input type="text" <input type="text"
id="{{ cell_input_id }}" id="{{ cell_input_id }}"
aria-label="{{ cell_label }}"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
value="{{ col_value if col_value is not none else '' }}" value="{{ col_value if col_value is not none else '' }}"
class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs" class="block w-20 px-1 py-1 border border-gray-300 rounded text-xs"
placeholder="path…"> placeholder="path…">
<label class="cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image"> <label class="file-trigger cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100" title="Upload image">
<i class="fas fa-upload"></i> <i class="fas fa-upload" aria-hidden="true"></i>
<input type="file" <input type="file"
class="sr-only"
aria-label="Upload image: {{ cell_label }}"
accept="image/png,image/jpeg,image/bmp,image/gif" accept="image/png,image/jpeg,image/bmp,image/gif"
style="display:none"
data-plugin-id="{{ plugin_id }}" data-plugin-id="{{ plugin_id }}"
data-target-input="{{ cell_input_id }}" data-target-input="{{ cell_input_id }}"
onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)"> onchange="(function(e){ const t=document.getElementById('{{ cell_input_id }}'); const p=t.previousElementSibling && t.previousElementSibling.tagName==='IMG' ? t.previousElementSibling : null; window.handleArrayTableImageUpload(e,t,p,'{{ plugin_id }}'); })(event)">
@@ -709,6 +760,7 @@
{% else %} {% else %}
<input type="text" <input type="text"
name="{{ full_key }}.{{ item_index }}.{{ col_name }}" name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
aria-label="{{ cell_label }}"
value="{{ col_value if col_value is not none else '' }}" value="{{ col_value if col_value is not none else '' }}"
class="block w-full px-2 py-1 border border-gray-300 rounded text-sm" class="block w-full px-2 py-1 border border-gray-300 rounded text-sm"
{% if col_def.get('description') %}placeholder="{{ col_def.get('description') }}"{% endif %} {% if col_def.get('description') %}placeholder="{{ col_def.get('description') }}"{% endif %}
@@ -725,15 +777,17 @@
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle"> <td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
<button type="button" <button type="button"
onclick="removeArrayTableRow(this)" onclick="removeArrayTableRow(this)"
aria-label="Remove row {{ item_index + 1 }}"
class="text-red-600 hover:text-red-800 px-2 py-1"> class="text-red-600 hover:text-red-800 px-2 py-1">
<i class="fas fa-trash"></i> <i class="fas fa-trash" aria-hidden="true"></i>
</button> </button>
{% if has_advanced.value %} {% if has_advanced.value %}
<button type="button" <button type="button"
onclick="openArrayTableRowEditor(this)" onclick="openArrayTableRowEditor(this)"
class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1" class="text-blue-500 hover:text-blue-700 px-2 py-1 ml-1"
aria-label="Edit advanced settings for row {{ item_index + 1 }}"
title="Edit layout, style and other advanced properties"> title="Edit layout, style and other advanced properties">
<i class="fas fa-sliders-h"></i> <i class="fas fa-sliders-h" aria-hidden="true"></i>
</button> </button>
{% endif %} {% endif %}
</td> </td>
@@ -804,9 +858,10 @@
value="{{ hex_val }}" value="{{ hex_val }}"
class="h-9 w-12 cursor-pointer rounded border border-gray-300" class="h-9 w-12 cursor-pointer rounded border border-gray-300"
title="Color picker" title="Color picker"
aria-label="{{ label }} color picker"
oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)"> oninput="(function(h){var r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);document.getElementById('{{ field_id }}_r').value=r;document.getElementById('{{ field_id }}_g').value=g;document.getElementById('{{ field_id }}_b').value=b;})(this.value)">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">R</label> <label for="{{ field_id }}_r" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">R</span><span class="sr-only">Red</span></label>
<input type="number" min="0" max="255" step="1" <input type="number" min="0" max="255" step="1"
id="{{ field_id }}_r" id="{{ field_id }}_r"
name="{{ full_key }}.0" name="{{ full_key }}.0"
@@ -815,7 +870,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()"> oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">G</label> <label for="{{ field_id }}_g" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">G</span><span class="sr-only">Green</span></label>
<input type="number" min="0" max="255" step="1" <input type="number" min="0" max="255" step="1"
id="{{ field_id }}_g" id="{{ field_id }}_g"
name="{{ full_key }}.1" name="{{ full_key }}.1"
@@ -824,7 +879,7 @@
oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()"> oninput="(function(){var r=+document.getElementById('{{ field_id }}_r').value||0,g=+document.getElementById('{{ field_id }}_g').value||0,b=+document.getElementById('{{ field_id }}_b').value||0;document.getElementById('{{ field_id }}_hex').value='#'+[r,g,b].map(function(n){return n.toString(16).padStart(2,'0')}).join('')})()">
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<label class="text-xs text-gray-500 font-medium">B</label> <label for="{{ field_id }}_b" class="text-xs text-gray-500 font-medium"><span aria-hidden="true">B</span><span class="sr-only">Blue</span></label>
<input type="number" min="0" max="255" step="1" <input type="number" min="0" max="255" step="1"
id="{{ field_id }}_b" id="{{ field_id }}_b"
name="{{ full_key }}.2" name="{{ full_key }}.2"
@@ -1235,20 +1290,21 @@
{% for key, value in config.items() %} {% for key, value in config.items() %}
{% if key not in ['enabled'] %} {% if key not in ['enabled'] %}
<div class="form-group mb-4"> <div class="form-group mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1"> {% set fallback_id = 'fallback-field-' ~ (key|string|replace(' ', '-')) %}
<label for="{{ fallback_id }}" class="block text-sm font-medium text-gray-700 mb-1">
{{ key|replace('_', ' ')|title }} {{ key|replace('_', ' ')|title }}
</label> </label>
{% if value is sameas true or value is sameas false %} {% if value is sameas true or value is sameas false %}
<label class="flex items-center cursor-pointer"> <label class="flex items-center cursor-pointer">
<input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %} <input type="checkbox" id="{{ fallback_id }}" name="{{ key }}" {% if value %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<span class="ml-2 text-sm text-gray-600">Enabled</span> <span class="ml-2 text-sm text-gray-600">Enabled</span>
</label> </label>
{% elif value is number %} {% elif value is number %}
<input type="number" name="{{ key }}" value="{{ value }}" <input type="number" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400"> class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% else %} {% else %}
<input type="text" name="{{ key }}" value="{{ value }}" <input type="text" id="{{ fallback_id }}" name="{{ key }}" value="{{ value }}"
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400"> class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
{% endif %} {% endif %}
</div> </div>
@@ -6,7 +6,7 @@
<!-- Plugin Controls --> <!-- Plugin Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4"> <div class="flex flex-wrap items-center gap-2 sm:gap-4">
<button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md"> <button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-sync-alt mr-2"></i>Refresh Plugins <i class="fas fa-sync-alt mr-2"></i>Refresh Plugins
</button> </button>
@@ -34,7 +34,7 @@
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200"> <div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search --> <!-- Search -->
<div class="relative flex-1 min-w-[12rem]"> <div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i> <i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none" aria-hidden="true"></i>
<input type="text" <input type="text"
id="installed-search" id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2" class="form-control text-sm pl-9 pr-8 py-2"
@@ -44,7 +44,7 @@
<button type="button" id="installed-search-clear" <button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search"> aria-label="Clear search">
<i class="fas fa-times-circle"></i> <i class="fas fa-times-circle" aria-hidden="true"></i>
</button> </button>
</div> </div>
@@ -149,7 +149,7 @@
<!-- GitHub Token Configuration (Combined Warning + Settings) --> <!-- GitHub Token Configuration (Combined Warning + Settings) -->
<div id="github-token-container" class="mb-5"> <div id="github-token-container" class="mb-5">
<!-- Warning Banner (shown when no token configured) --> <!-- Warning Banner (shown when no token configured) -->
<div id="github-auth-warning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg"> <div id="github-auth-warning" class="hidden bg-yellow-50 border border-yellow-300 p-4 rounded-lg">
<div class="flex"> <div class="flex">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i> <i class="fas fa-exclamation-triangle text-yellow-400"></i>
@@ -170,8 +170,8 @@
</p> </p>
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<button onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900"> <button type="button" onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900" aria-label="Dismiss GitHub token warning">
<i class="fas fa-times"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -207,9 +207,10 @@
<input type="password" id="github-token-input" <input type="password" id="github-token-input"
class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"> placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
<button type="button" onclick="toggleGithubTokenVisibility()" <button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"> class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"
<i id="github-token-icon" class="fas fa-eye"></i> aria-label="Show token" aria-controls="github-token-input">
<i id="github-token-icon" class="fas fa-eye" aria-hidden="true"></i>
</button> </button>
</div> </div>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
@@ -246,8 +247,8 @@
</div> </div>
<!-- Search Row --> <!-- Search Row -->
<div class="flex gap-3 mb-4"> <div class="flex gap-3 mb-4">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow"> <input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow"> <select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option> <option value="">All Categories</option>
<option value="sports">Sports</option> <option value="sports">Sports</option>
<option value="content">Content</option> <option value="content">Content</option>
@@ -262,7 +263,7 @@
<!-- Sort & Filter Bar --> <!-- Sort & Filter Bar -->
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200"> <div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort --> <!-- Sort -->
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white"> <select id="store-sort" aria-label="Sort the Plugin Store" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option> <option value="a-z">A → Z</option>
<option value="z-a">Z → A</option> <option value="z-a">Z → A</option>
<option value="category">Category</option> <option value="category">Category</option>
@@ -289,13 +290,13 @@
<!-- Results Bar (top pagination) --> <!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="store-results-info" class="text-sm text-gray-600"></span> <span id="store-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white"> <select id="store-per-page" aria-label="Plugins per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="12">12 per page</option> <option value="12">12 per page</option>
<option value="24">24 per page</option> <option value="24">24 per page</option>
<option value="48">48 per page</option> <option value="48">48 per page</option>
</select> </select>
<div id="store-pagination-top" class="flex items-center gap-1"></div> <div id="store-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div> </div>
</div> </div>
@@ -355,8 +356,8 @@
<!-- Search Row --> <!-- Search Row -->
<div class="flex gap-3 mb-4"> <div class="flex gap-3 mb-4">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow"> <input type="text" id="starlark-search" placeholder="Search by name, description, author..." aria-label="Search Starlark apps" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow"> <select id="starlark-category" aria-label="Filter Starlark apps by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option> <option value="">All Categories</option>
</select> </select>
</div> </div>
@@ -364,7 +365,7 @@
<!-- Sort & Filter Bar --> <!-- Sort & Filter Bar -->
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200"> <div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort --> <!-- Sort -->
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white"> <select id="starlark-sort" aria-label="Sort Starlark apps" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option> <option value="a-z">A → Z</option>
<option value="z-a">Z → A</option> <option value="z-a">Z → A</option>
<option value="category">Category</option> <option value="category">Category</option>
@@ -379,7 +380,7 @@
</button> </button>
<!-- Author filter --> <!-- Author filter -->
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white"> <select id="starlark-filter-author" aria-label="Filter Starlark apps by author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="">All Authors</option> <option value="">All Authors</option>
</select> </select>
@@ -395,13 +396,13 @@
<!-- Results Bar (top pagination) --> <!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span> <span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white"> <select id="starlark-per-page" aria-label="Starlark apps per page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="24">24 per page</option> <option value="24">24 per page</option>
<option value="48">48 per page</option> <option value="48">48 per page</option>
<option value="96">96 per page</option> <option value="96">96 per page</option>
</select> </select>
<div id="starlark-pagination-top" class="flex items-center gap-1"></div> <div id="starlark-pagination-top" class="flex flex-wrap items-center gap-1"></div>
</div> </div>
</div> </div>
@@ -463,7 +464,7 @@
<p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p> <p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex gap-2"> <div class="flex gap-2">
<input type="text" id="github-plugin-url" <input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
placeholder="https://github.com/user/ledmatrix-plugin-name" placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"> class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url" <button type="button" id="install-plugin-from-url"
@@ -491,7 +492,7 @@
</h4> </h4>
<p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p> <p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p>
<div class="flex gap-2 mb-2"> <div class="flex gap-2 mb-2">
<input type="text" id="github-registry-url" <input type="text" id="github-registry-url" aria-label="Plugin registry GitHub URL"
placeholder="https://github.com/user/ledmatrix-plugins" placeholder="https://github.com/user/ledmatrix-plugins"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"> class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap"> <button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap">
@@ -4,7 +4,7 @@
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">config.json Editor</h2> <h2 id="main-config-editor-title" class="text-lg font-semibold text-gray-900">config.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p> <p class="mt-1 text-sm text-gray-600">{{ main_config_path }}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -28,7 +28,7 @@
</div> </div>
<div class="relative"> <div class="relative">
<textarea id="main-config-editor" <textarea id="main-config-editor" aria-labelledby="main-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ main_config_json }}</textarea> spellcheck="false">{{ main_config_json }}</textarea>
<div id="main-config-validation" class="mt-2 text-sm"></div> <div id="main-config-validation" class="mt-2 text-sm"></div>
@@ -55,7 +55,7 @@
<div class="border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2> <h2 id="secrets-config-editor-title" class="text-lg font-semibold text-gray-900">config_secrets.json Editor</h2>
<p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p> <p class="mt-1 text-sm text-gray-600">{{ secrets_config_path }}</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -79,7 +79,7 @@
</div> </div>
<div class="relative"> <div class="relative">
<textarea id="secrets-config-editor" <textarea id="secrets-config-editor" aria-labelledby="secrets-config-editor-title"
class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500" class="w-full h-96 font-mono text-sm p-4 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
spellcheck="false">{{ secrets_config_json }}</textarea> spellcheck="false">{{ secrets_config_json }}</textarea>
<div id="secrets-config-validation" class="mt-2 text-sm"></div> <div id="secrets-config-validation" class="mt-2 text-sm"></div>
@@ -1,3 +1,6 @@
{# Prefix for label/input id pairs; app ids and field ids are sanitised so
the ids stay valid and unique if several app configs are on the page. #}
{% set sl_prefix = ('starlark-' ~ app_id)|replace(':', '-')|replace('.', '-')|replace(' ', '-')|replace('/', '-') %}
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between pb-4 border-b border-gray-200"> <div class="flex items-center justify-between pb-4 border-b border-gray-200">
@@ -59,16 +62,18 @@
<div id="starlark-config-form" class="space-y-4"> <div id="starlark-config-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label> <label for="{{ sl_prefix }}-render-interval" class="block text-sm font-medium text-gray-700 mb-1">Render Interval (seconds)</label>
<input type="number" min="10" max="86400" step="1" <input type="number" min="10" max="86400" step="1"
id="{{ sl_prefix }}-render-interval"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ render_interval }}" value="{{ render_interval }}"
data-starlark-config="render_interval"> data-starlark-config="render_interval">
<p class="text-xs text-gray-400 mt-1">How often the app re-renders (fetches new data)</p> <p class="text-xs text-gray-400 mt-1">How often the app re-renders (fetches new data)</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label> <label for="{{ sl_prefix }}-display-duration" class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
<input type="number" min="1" max="3600" step="1" <input type="number" min="1" max="3600" step="1"
id="{{ sl_prefix }}-display-duration"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ display_duration }}" value="{{ display_duration }}"
data-starlark-config="display_duration"> data-starlark-config="display_duration">
@@ -98,12 +103,14 @@
{% set field_desc = field.desc or '' %} {% set field_desc = field.desc or '' %}
{% set field_default = field.default if field.default is defined else '' %} {% set field_default = field.default if field.default is defined else '' %}
{% set current_val = config.get(field_id, field_default) if config else field_default %} {% set current_val = config.get(field_id, field_default) if config else field_default %}
{% set input_id = (sl_prefix ~ '-f-' ~ field_id)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
{# ── text ── #} {# ── text ── #}
{% if field_type == 'text' %} {% if field_type == 'text' %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text" <input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}" value="{{ current_val }}"
placeholder="{{ field_desc }}" placeholder="{{ field_desc }}"
@@ -116,8 +123,8 @@
{# ── dropdown ── #} {# ── dropdown ── #}
{% elif field_type == 'dropdown' %} {% elif field_type == 'dropdown' %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<select class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white" <select id="{{ input_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm bg-white"
data-starlark-config="{{ field_id }}"> data-starlark-config="{{ field_id }}">
{% for opt in (field.options or []) %} {% for opt in (field.options or []) %}
<option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}> <option value="{{ opt.value }}" {{ 'selected' if current_val|string == opt.value|string else '' }}>
@@ -149,14 +156,16 @@
{# ── color ── #} {# ── color ── #}
{% elif field_type == 'color' %} {% elif field_type == 'color' %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="color" <input type="color"
aria-label="{{ field_name }} color picker"
class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5" class="w-10 h-10 rounded border border-gray-300 cursor-pointer p-0.5"
value="{{ current_val or '#FFFFFF' }}" value="{{ current_val or '#FFFFFF' }}"
data-starlark-color-picker="{{ field_id }}" data-starlark-color-picker="{{ field_id }}"
oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value"> oninput="this.closest('.space-y-6').querySelector('[data-starlark-config={{ field_id }}]').value = this.value">
<input type="text" <input type="text"
id="{{ input_id }}"
class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono" class="form-control flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm font-mono"
value="{{ current_val or '#FFFFFF' }}" value="{{ current_val or '#FFFFFF' }}"
placeholder="#RRGGBB" placeholder="#RRGGBB"
@@ -171,8 +180,9 @@
{# ── datetime ── #} {# ── datetime ── #}
{% elif field_type == 'datetime' %} {% elif field_type == 'datetime' %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="datetime-local" <input type="datetime-local"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}" value="{{ current_val }}"
data-starlark-config="{{ field_id }}"> data-starlark-config="{{ field_id }}">
@@ -184,10 +194,11 @@
{# ── location (mini-form) ── #} {# ── location (mini-form) ── #}
{% elif field_type == 'location' %} {% elif field_type == 'location' %}
<div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}"> <div class="form-group" data-starlark-location-group="{{ field_id }}" data-starlark-location-value="{{ current_val }}">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label id="{{ input_id }}-label" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2"> <div class="grid grid-cols-1 md:grid-cols-3 gap-2" role="group" aria-labelledby="{{ input_id }}-label">
<div> <div>
<input type="number" step="any" min="-90" max="90" <input type="number" step="any" min="-90" max="90"
aria-label="{{ field_name }} latitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Latitude" placeholder="Latitude"
data-starlark-location-field="{{ field_id }}" data-starlark-location-field="{{ field_id }}"
@@ -195,6 +206,7 @@
</div> </div>
<div> <div>
<input type="number" step="any" min="-180" max="180" <input type="number" step="any" min="-180" max="180"
aria-label="{{ field_name }} longitude"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Longitude" placeholder="Longitude"
data-starlark-location-field="{{ field_id }}" data-starlark-location-field="{{ field_id }}"
@@ -204,6 +216,7 @@
<input type="text" <input type="text"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
placeholder="Timezone (e.g. America/New_York)" placeholder="Timezone (e.g. America/New_York)"
aria-label="{{ field_name }} timezone"
data-starlark-location-field="{{ field_id }}" data-starlark-location-field="{{ field_id }}"
data-starlark-location-key="timezone"> data-starlark-location-key="timezone">
</div> </div>
@@ -243,8 +256,9 @@
{# ── typeahead / location_based (text fallback with note) ── #} {# ── typeahead / location_based (text fallback with note) ── #}
{% elif field_type in ('typeahead', 'location_based') %} {% elif field_type in ('typeahead', 'location_based') %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text" <input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}" value="{{ current_val }}"
placeholder="{{ field_desc }}" placeholder="{{ field_desc }}"
@@ -261,8 +275,9 @@
{# ── unknown type (text fallback) ── #} {# ── unknown type (text fallback) ── #}
{% else %} {% else %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label> <label for="{{ input_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ field_name }}</label>
<input type="text" <input type="text"
id="{{ input_id }}"
class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ current_val }}" value="{{ current_val }}"
placeholder="{{ field_desc }}" placeholder="{{ field_desc }}"
@@ -287,8 +302,9 @@
{% for key, value in config.items() %} {% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') and key not in schema_ids %} {% if key not in ('render_interval', 'display_duration') and key not in schema_ids %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label> {% set custom_id = (sl_prefix ~ '-c-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" <label for="{{ custom_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }} <span class="text-xs text-gray-400">(custom)</span></label>
<input type="text" id="{{ custom_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}" value="{{ value }}"
data-starlark-config="{{ key }}"> data-starlark-config="{{ key }}">
</div> </div>
@@ -303,8 +319,9 @@
{% for key, value in config.items() %} {% for key, value in config.items() %}
{% if key not in ('render_interval', 'display_duration') %} {% if key not in ('render_interval', 'display_duration') %}
<div class="form-group"> <div class="form-group">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label> {% set raw_id = (sl_prefix ~ '-r-' ~ key)|replace(' ', '-')|replace('.', '-')|replace(':', '-')|replace('/', '-') %}
<input type="text" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm" <label for="{{ raw_id }}" class="block text-sm font-medium text-gray-700 mb-1">{{ key }}</label>
<input type="text" id="{{ raw_id }}" class="form-control w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
value="{{ value }}" value="{{ value }}"
data-starlark-config="{{ key }}"> data-starlark-config="{{ key }}">
</div> </div>
+37 -31
View File
@@ -32,7 +32,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Switch branch --> <!-- Switch branch -->
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-medium text-gray-900">Branch</p> <p class="text-sm font-medium text-gray-900">Branch</p>
<p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p> <p class="text-xs text-gray-500 mt-0.5">Choose which branch this pi follows. Switching attaches tracking, so Pull Latest works afterwards.</p>
@@ -50,7 +50,7 @@
<div id="result-checkout-branch" class="hidden"></div> <div id="result-checkout-branch" class="hidden"></div>
<!-- Pull latest --> <!-- Pull latest -->
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p> <p class="text-sm font-medium text-gray-900">Pull latest (rebase)</p>
<p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p> <p class="text-xs text-gray-500 mt-0.5">Stashes any local changes, then runs <code class="bg-gray-100 px-1 rounded">git pull --rebase</code>. The stash is preserved but not re-applied.</p>
@@ -63,7 +63,7 @@
<div id="result-git-pull" class="hidden"></div> <div id="result-git-pull" class="hidden"></div>
<!-- Force reset --> <!-- Force reset -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div> <div>
<p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p> <p class="text-sm font-medium text-gray-900">Force reset to <code class="bg-gray-100 px-1 rounded">origin/main</code></p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p> <p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">git fetch origin</code> then <code class="bg-gray-100 px-1 rounded">git reset --hard origin/main</code>. Discards all local changes.</p>
@@ -99,7 +99,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Base requirements --> <!-- Base requirements -->
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">Reinstall base requirements</p> <p class="text-sm font-medium text-gray-900">Reinstall base requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p> <p class="text-xs text-gray-500 mt-0.5">Installs from <code class="bg-gray-100 px-1 rounded">requirements.txt</code> in the project root.</p>
@@ -112,7 +112,7 @@
<div id="result-base-reqs" class="hidden"></div> <div id="result-base-reqs" class="hidden"></div>
<!-- Plugin requirements --> <!-- Plugin requirements -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div> <div>
<p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p> <p class="text-sm font-medium text-gray-900">Reinstall plugin requirements</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p> <p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">pip install</code> for every installed plugin that has a <code class="bg-gray-100 px-1 rounded">requirements.txt</code>.</p>
@@ -134,7 +134,7 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">Clear Python cache</p> <p class="text-sm font-medium text-gray-900">Clear Python cache</p>
<p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p> <p class="text-xs text-gray-500 mt-0.5">Deletes all <code class="bg-gray-100 px-1 rounded">__pycache__</code> directories in the project. Useful after switching branches or debugging import issues.</p>
@@ -166,14 +166,15 @@
<p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p> <p class="mt-1 text-sm text-gray-600">Turn the WiFi radio on or off. Full WiFi setup (scan, connect, hotspot) lives on the <span class="font-medium">WiFi</span> tab.</p>
</div> </div>
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">WiFi radio</p> <p id="wifi-radio-label" class="text-sm font-medium text-gray-900">WiFi radio</p>
<p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p> <p id="wifi-radio-note" class="text-xs text-gray-500 mt-0.5">Checking current state…</p>
</div> </div>
<div class="shrink-0 flex flex-col items-end gap-2"> <div class="shrink-0 flex flex-col items-end gap-2">
<!-- Toggle switch --> <!-- Toggle switch -->
<button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false" <button id="wifi-radio-toggle" type="button" role="switch" aria-checked="false"
aria-labelledby="wifi-radio-label" aria-describedby="wifi-radio-note"
onclick="onWifiRadioToggleClick()" disabled onclick="onWifiRadioToggleClick()" disabled
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60"> class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-gray-200 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 opacity-60">
<span id="wifi-radio-knob" aria-hidden="true" <span id="wifi-radio-knob" aria-hidden="true"
@@ -207,7 +208,7 @@
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">Restart display service</p> <p class="text-sm font-medium text-gray-900">Restart display service</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p> <p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix.service</code>.</p>
@@ -219,7 +220,7 @@
</div> </div>
<div id="result-restart-display" class="hidden"></div> <div id="result-restart-display" class="hidden"></div>
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div> <div>
<p class="text-sm font-medium text-gray-900">Restart web interface</p> <p class="text-sm font-medium text-gray-900">Restart web interface</p>
<p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p> <p class="text-xs text-gray-500 mt-0.5">Restarts <code class="bg-gray-100 px-1 rounded">ledmatrix-web.service</code>. The page will go offline briefly.</p>
@@ -282,7 +283,7 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Reboot --> <!-- Reboot -->
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-gray-900">Reboot</p> <p class="text-sm font-medium text-gray-900">Reboot</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p> <p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo reboot</code>. The Pi will restart and come back online in a minute or two.</p>
@@ -308,7 +309,7 @@
<div id="result-reboot" class="hidden"></div> <div id="result-reboot" class="hidden"></div>
<!-- Shutdown --> <!-- Shutdown -->
<div class="flex items-start justify-between gap-4 pt-4 border-t border-gray-100"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4 pt-4 border-t border-gray-100">
<div> <div>
<p class="text-sm font-medium text-gray-900">Shut down</p> <p class="text-sm font-medium text-gray-900">Shut down</p>
<p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p> <p class="text-xs text-gray-500 mt-0.5">Runs <code class="bg-gray-100 px-1 rounded">sudo poweroff</code>. The Pi will power off and must be unplugged/replugged (or power-cycled) to turn back on.</p>
@@ -979,26 +980,31 @@
loadGitInfo(); loadGitInfo();
loadBranches(); loadBranches();
// Plugin health: initial load + periodic refresh. Guard against duplicate // Plugin health (15s) and system diagnostics (10s): the partial stays in
// timers if this partial is re-swapped in by HTMX; the handler re-resolves // the DOM (hidden via x-show) when another tab is active, so both polls
// DOM nodes by id each tick. // run only while the Tools tab is active and the page is visible — each
refreshPluginHealth(false); // restarts with an immediate refresh. LEDVisibility keys the registration,
if (!window._pluginHealthTimer) { // so a partial re-swapped in by HTMX replaces (not stacks) the timers.
window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000); function stopToolsPolling() {
if (window._pluginHealthTimer) { clearInterval(window._pluginHealthTimer); window._pluginHealthTimer = null; }
if (window._diagPollInterval) { clearInterval(window._diagPollInterval); window._diagPollInterval = null; }
} }
function startToolsPolling() {
// System diagnostics: load now, then refresh every 10s. Clear any prior stopToolsPolling();
// interval so re-swapping the partial doesn't stack. The recurring poll is refreshPluginHealth(false);
// gated on visibility — the partial stays in the DOM (hidden via x-show) window._pluginHealthTimer = setInterval(function () { refreshPluginHealth(true); }, 15000);
// when another tab is active, so without this it would keep hitting
// /api/v3/system/status every 10s and churn the Pi while off-screen.
if (window._diagPollInterval) clearInterval(window._diagPollInterval);
window.loadSystemDiagnostics();
window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics(); window.loadSystemDiagnostics();
}, 10000); window._diagPollInterval = setInterval(function () {
const panel = document.getElementById('diag-panel');
if (!panel || document.hidden || panel.offsetParent === null) return;
window.loadSystemDiagnostics();
}, 10000);
}
if (window.LEDVisibility) {
window.LEDVisibility.onActive('tools', startToolsPolling, stopToolsPolling);
} else {
startToolsPolling();
}
// WiFi radio current state. // WiFi radio current state.
window.loadWifiRadio(); window.loadWifiRadio();
@@ -1223,7 +1229,7 @@
const active = status.running ? ` const active = status.running ? `
<div class="rounded-md border border-amber-300 bg-amber-50 p-3"> <div class="rounded-md border border-amber-300 bg-amber-50 p-3">
<div class="flex items-start justify-between gap-4"> <div class="flex flex-wrap sm:flex-nowrap items-start justify-between gap-4">
<div> <div>
<p class="text-sm font-medium text-amber-900"> <p class="text-sm font-medium text-amber-900">
<i class="fas fa-circle-notch fa-spin mr-1"></i> <i class="fas fa-circle-notch fa-spin mr-1"></i>
@@ -159,6 +159,9 @@
</div> </div>
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<button type="button" <button type="button"
role="switch"
aria-label="Auto-Enable AP Mode"
:aria-checked="status.auto_enable_ap_mode ? 'true' : 'false'"
@click="toggleAutoEnable(!status.auto_enable_ap_mode)" @click="toggleAutoEnable(!status.auto_enable_ap_mode)"
:class="status.auto_enable_ap_mode :class="status.auto_enable_ap_mode
? 'bg-blue-600 hover:bg-blue-700' ? 'bg-blue-600 hover:bg-blue-700'
+107
View File
@@ -0,0 +1,107 @@
"""Single-request bundle for the config-form widget scripts.
Every page used to carry one <script> tag per widget (34 of them, ~700 KB
uncompressed). On a Pi Zero 2 W serving a phone over WiFi, each tag is a
separate request the Python server has to answer. The files are plain scripts
that register themselves on window/LEDMatrixWidgets at load time, so
concatenating them in the same order is exactly equivalent to loading them
one by one — no module wrapper, no scope change.
The individual files stay on disk and keep working as direct URLs, which is
what plugin-loader.js and any third-party page expect.
BUNDLE_ORDER is the authority for which widgets ship; test_widget_scripts.py
checks it against the directory so a new widget can't be forgotten.
"""
import hashlib
from pathlib import Path
from threading import Lock
WIDGETS_DIR = Path(__file__).parent / "static" / "v3" / "js" / "widgets"
# Load order matters: the registry and base class must exist before the
# widgets that call them, and notification.js owns window.showNotification.
BUNDLE_ORDER = [
"registry.js",
"base-widget.js",
"notification.js",
"plugin-order-list.js",
"file-upload.js",
"checkbox-group.js",
"custom-feeds.js",
"array-table.js",
"google-calendar-picker.js",
"google-oauth.js",
"day-selector.js",
"time-range.js",
"time-picker.js",
"file-upload-single.js",
"plugin-file-manager.js",
"schedule-picker.js",
# Basic input widgets
"text-input.js",
"number-input.js",
"textarea.js",
"select-dropdown.js",
"font-selector.js",
"style-editor.js",
"toggle-switch.js",
"radio-group.js",
"date-picker.js",
"slider.js",
"color-picker.js",
"email-input.js",
"url-input.js",
"password-input.js",
"timezone-selector.js",
"plugin-loader.js",
# Reusable JSON file manager (used via x-widget: json-file-manager)
"json-file-manager.js",
]
# Widget files that must NOT be bundled, with the reason.
EXCLUDED = {
# Documentation example (docs/widget-guide.md); it registers the name
# 'color-picker' and would shadow the real color-picker.js.
"example-color-picker.js": "documentation example",
}
_lock = Lock()
_cache = {"version": None, "body": None}
def bundle_paths():
"""Widget files in load order (only those present on disk)."""
return [WIDGETS_DIR / name for name in BUNDLE_ORDER if (WIDGETS_DIR / name).is_file()]
def bundle_version():
"""Cache-busting token: a fingerprint of every bundled file.
Uses each file's name, nanosecond mtime and size, so an edit that lands
in the same second as another file's, or leaves mtime behind the newest
file, still changes the token (and the URL).
"""
digest = hashlib.sha256()
for path in bundle_paths():
stat = path.stat()
digest.update(f"{path.name}:{stat.st_mtime_ns}:{stat.st_size};".encode())
return digest.hexdigest()[:12]
def build_bundle():
"""Concatenated widget sources, rebuilt only when a file changes."""
version = bundle_version()
with _lock:
if _cache["version"] == version and _cache["body"] is not None:
return _cache["body"], version
parts = []
for path in bundle_paths():
source = path.read_text(encoding="utf-8")
# A file ending in a line comment would swallow the next file's
# first line, and a missing semicolon can join two statements.
parts.append("/* %s */\n%s\n;\n" % (path.name, source))
body = "".join(parts)
_cache["version"] = version
_cache["body"] = body
return body, version