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