3 Commits

Author SHA1 Message Date
Chuck
6212fd3cd2 feat(ui): add reusable json-file-manager widget for plugin file management
Introduces JsonFileManager — a zero-CDN, keyboard-accessible, configurable
widget for managing JSON data files from plugin configuration forms.

web_interface/static/v3/js/widgets/json-file-manager.js (new):
- Self-contained class with scoped CSS (no global leakage)
- File list with cards: enable/disable toggle, entry count, size, date
- Drag-and-drop + click-to-browse JSON upload
- Textarea-based JSON editor (no CDN); Format + Validate buttons
- Ctrl+S to save, Escape to close any open modal
- Create-new-file modal with configurable fields and validation
- Delete confirmation modal
- All actions (list/get/save/upload/delete/create/toggle) are configurable
  via x-widget-config in config_schema.json — no plugin-ID hardcoding

web_interface/static/v3/plugins_manager.js:
- New handler for x-widget: "json-file-manager" — renders mount div,
  instantiates JsonFileManager with x-widget-config and plugin ID

web_interface/templates/v3/base.html:
- Include json-file-manager.js (defer) before plugins_manager.js

Usage: set x-widget: "json-file-manager" + x-widget-config in any
plugin's config_schema.json (see ledmatrix-plugins of-the-day for a
complete example).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 06:58:35 -04:00
Chuck
0c7d03a476 fix(web-ui): support multiple browser tabs via SSE broadcaster (#349)
* fix(web-ui): support multiple browser tabs via SSE broadcaster pattern

Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(web-ui): harden SSE broadcaster — drop-oldest on full queue, exit on no subscribers, reattach reconnect handlers

- _broadcast: on queue.Full drop the oldest item and retry the put
  instead of removing the client from _clients — a slow tab now stays
  subscribed and receives the latest data rather than being silently
  ejected
- _broadcast: break instead of continue when _clients is empty so the
  background generator thread exits rather than spinning indefinitely;
  subscribe() already restarts it on the next connection
- base.html: expose _statsOpenHandler, _statsErrorHandler, and
  _displayErrorHandler as window properties so reconnectSSE() can
  reattach them after replacing the EventSource instances
- app.js: reconnectSSE() now reattaches those handlers after creating
  each new EventSource so the status badge and display-stream console
  logging survive a manual reconnect

Heartbeat path (~line 646) is a queue read (q.get), not a write; no
queue.Full can occur there so no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): declare updateDisplayPreview in ESLint global comment

Codacy flagged 'updateDisplayPreview is not defined' at app.js:73.
The function is defined in base.html and already guarded with
typeof check, matching the existing updateSystemStats pattern — it
just wasn't listed in the /* global */ declaration at the top of the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:37:03 -04:00
Chuck
321a87f734 fix(wifi): fix AP mode, captive portal, and WiFi connect flow (#348)
* fix(wifi): fix AP mode, captive portal, and WiFi connect flow

- Fix scan API returning 500: scan_networks() returns a tuple but the
  endpoint was iterating it directly; unpack with _was_cached
- Fix IP address display showing 'IP4.ADDRESS[1]:x.x.x.x': nmcli -t
  output includes the field label; split on ':' before '/'
- Add force parameter to enable_ap_mode() to bypass WiFi/Ethernet
  guards; expose via force JSON body field in the AP enable endpoint
- Fix daemon auto-disabling forced AP: add _FORCE_AP_FLAG_PATH flag
  file written on force-enable and checked in check_and_manage_ap_mode
  before auto-disabling; disable_ap_mode() clears it
- Fix wifi_connected false positive in AP mode: _get_status_nmcli()
  was reporting wlan0 as 'connected' when it was running as AP;
  override wifi_connected=False when _is_ap_mode_active() is True
- Fix AP verification failure on async NM activation: retry
  _get_ap_status_nmcli() up to 5 times with 2s delay instead of
  single immediate check
- Fix WiFi connect ignoring existing NM connections: nmcli does not
  support 802-11-wireless.ssid as a column in 'connection show';
  replace with NAME,TYPE list then per-connection SSID query via -g
  (fixes 'netplan generate failed' error on Trixie / netplan systems)
- Fix failsafe AP re-enable blocked by Ethernet: all recovery-path
  enable_ap_mode() calls in connect_to_network() now pass force=True

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): strict bool parsing for force; nosec annotation parity

- api_v3.py: replace bool(...) coercion for force with strict check —
  only actual boolean True or strings "true"/"1" (case-insensitive)
  pass; "false", integers, and other strings are treated as False so
  the Ethernet/WiFi guards and _FORCE_AP_FLAG_PATH cannot be bypassed
  by accident
- wifi_manager.py: add nosec B108 annotation to _IP_FORWARD_SAVE_PATH
  to match the identical annotation already on _FORCE_AP_FLAG_PATH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): suppress false-positive Bandit B603/B607 on new nmcli calls

Both subprocess.run calls in the SSID connection lookup use fixed
arguments (no user input) or values derived from nmcli's own output —
not from user-controlled data. Add nosec B603 B607 annotations to
silence the Codacy/Bandit warnings, consistent with existing nosec
usage in the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wifi): address four review findings in wifi_manager.py

IP parsing (line 476): use partition(':') so bare "ip/mask" lines
(no field-label prefix) are handled without IndexError; falls back to
the full string when no ':' is present before splitting on '/'.

AP-mode override comment (line 503): add one-line explanation above
the wifi_connected/ssid/ip_address clear so maintainers know why the
fields are reset while wlan0 reports as "connected".

Stale force-flag cleanup (__init__): remove a left-over
_FORCE_AP_FLAG_PATH from a prior crash on first instantiation per
process (guarded by class-level _startup_cleanup_done so the nmcli
AP-state check only runs once, not on every per-request instantiation).

Force-flag logging (enable_ap_mode): log at debug when force=True is
applied, log success at debug and failure with OSError details at
warning for both the hostapd and nmcli hotspot paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chuck <chuck@example.com>
2026-05-24 16:12:59 -04:00
7 changed files with 1046 additions and 77 deletions

View File

@@ -151,6 +151,18 @@ class WiFiManager:
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
# Once per process: remove a stale force-AP flag left by a prior crash.
# Guard with a class-level flag so the nmcli AP-state check only runs
# once even though WiFiManager is instantiated per-request.
if not WiFiManager._startup_cleanup_done:
WiFiManager._startup_cleanup_done = True
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
try:
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.debug("Removed stale force-AP flag on startup (AP not active)")
except OSError as exc:
logger.warning(f"Could not remove stale force-AP flag: {exc}")
def _show_led_message(self, message: str, duration: int = 5):
"""
Show a WiFi status message on the LED display.
@@ -474,7 +486,10 @@ class WiFiManager:
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if '/' in line:
ip_address = line.split('/')[0].strip()
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
# bare "x.x.x.x/prefix" is also accepted defensively.
_, sep, rest = line.partition(':')
ip_address = (rest if sep else line).split('/')[0].strip()
break
# Final fallback: Get signal strength by matching SSID in WiFi list
@@ -500,6 +515,13 @@ class WiFiManager:
# Check if AP mode is active
ap_active = self._is_ap_mode_active()
# wlan0 shows as "connected" in AP mode; clear client-station fields so
# callers don't mistake the AP for an outbound WiFi connection.
if ap_active and wifi_connected:
wifi_connected = False
ssid = None
ip_address = None
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
return WiFiStatus(
connected=wifi_connected,
@@ -690,6 +712,10 @@ class WiFiManager:
# ---------------------------------------------------------------------------
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
_startup_cleanup_done: bool = False
def _validate_ap_config(self) -> Tuple[str, int]:
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
@@ -1367,7 +1393,7 @@ class WiFiManager:
logger.error(f"Failed to restore original connection: {original_ssid}")
# Trigger AP mode as last resort
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode()
ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed and restoration failed. AP mode enabled."
@@ -1379,7 +1405,7 @@ class WiFiManager:
elif not success:
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
self._show_led_message("Enabling AP mode...", duration=5)
ap_success, ap_msg = self.enable_ap_mode()
ap_success, ap_msg = self.enable_ap_mode(force=True)
if ap_success:
logger.info("AP mode enabled as failsafe")
return False, "Connection failed. AP mode enabled."
@@ -1400,7 +1426,7 @@ class WiFiManager:
logger.error(f"Failed to restore after exception: {restore_error}")
# Last resort: enable AP mode
try:
self.enable_ap_mode()
self.enable_ap_mode(force=True)
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
return False, str(e)
@@ -1464,24 +1490,27 @@ class WiFiManager:
# Show LED message
self._show_led_message(f"Connecting to {ssid}...", duration=10)
# First, check if connection already exists and try to activate it
# NetworkManager connection names might not match SSID exactly, so search by SSID
check_result = subprocess.run(
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
capture_output=True,
text=True,
timeout=5
# Find existing NM connection for this SSID.
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
# so list all wifi connections then query each one's SSID individually.
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
capture_output=True, text=True, timeout=5
)
existing_conn_name = None
if check_result.returncode == 0:
for line in check_result.stdout.strip().split('\n'):
if ':' in line:
if list_result.returncode == 0:
for line in list_result.stdout.strip().split('\n'):
if ':' not in line:
continue
parts = line.split(':')
if len(parts) >= 2:
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
continue
conn_name = parts[0].strip()
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
if conn_ssid == ssid:
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5
)
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
existing_conn_name = conn_name
break
@@ -1855,7 +1884,7 @@ class WiFiManager:
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
return False
def enable_ap_mode(self) -> Tuple[bool, str]:
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
"""
Enable access point mode
@@ -1877,20 +1906,29 @@ class WiFiManager:
if not self._ensure_wifi_radio_enabled():
return False, "WiFi radio is disabled and could not be enabled"
# Check if WiFi is connected
# Check if WiFi is connected (skip when force=True)
status = self.get_wifi_status()
if status.connected:
if not force and status.connected:
return False, "Cannot enable AP mode while WiFi is connected"
# Check if Ethernet is connected
if self._is_ethernet_connected():
# Check if Ethernet is connected (skip when force=True)
if not force and self._is_ethernet_connected():
return False, "Cannot enable AP mode while Ethernet is connected"
if force:
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
# Try hostapd/dnsmasq first (captive portal mode)
if self.has_hostapd and self.has_dnsmasq:
result = self._enable_ap_mode_hostapd()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
# Fallback to nmcli hotspot (simpler, no captive portal)
@@ -1900,6 +1938,12 @@ class WiFiManager:
result = self._enable_ap_mode_nmcli_hotspot()
if result[0]:
self._ap_enabled_at = time.time()
if force:
try:
self._FORCE_AP_FLAG_PATH.touch()
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
except OSError as exc:
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
return result
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
@@ -2091,8 +2135,14 @@ class WiFiManager:
self._clear_led_message()
return False, "AP started but captive-portal redirect setup failed"
# Verify the AP is actually running
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
status = {}
for _attempt in range(5):
status = self._get_ap_status_nmcli()
if status.get('active'):
break
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
time.sleep(2)
if status.get('active'):
ip = status.get('ip', '192.168.4.1')
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
@@ -2290,6 +2340,7 @@ class WiFiManager:
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
self._ap_enabled_at = None
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
logger.info("AP mode disabled successfully")
return True, "AP mode disabled"
except Exception as e:
@@ -2478,22 +2529,29 @@ address=/detectportal.firefox.com/192.168.4.1
else:
logger.warning(f"Failed to enable AP mode: {message}")
elif not should_have_ap and ap_active:
# Should not have AP but do - disable AP mode
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
if status.connected or ethernet_connected:
# Should not have AP but do - check if it was manually force-enabled
force_active = self._FORCE_AP_FLAG_PATH.exists()
if status.connected:
# WiFi connected: always disable AP (user successfully configured WiFi)
success, message = self.disable_ap_mode()
if success:
if status.connected:
logger.info("Auto-disabled AP mode (WiFi connected)")
elif ethernet_connected:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0 # Reset counter
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and not force_active:
# Ethernet connected, AP not manually forced: auto-disable
success, message = self.disable_ap_mode()
if success:
logger.info("Auto-disabled AP mode (Ethernet connected)")
self._disconnected_checks = 0
return True
else:
logger.warning(f"Failed to auto-disable AP mode: {message}")
elif ethernet_connected and force_active:
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
elif not auto_enable:
# AP is active but auto_enable is disabled - this means it was manually enabled
# Don't disable it automatically, let it stay active
logger.debug("AP mode is active (manually enabled), keeping active")
# Idle-timeout check: disable AP if no client has connected within the window.

View File

@@ -2,8 +2,10 @@ from flask import Flask, request, redirect, url_for, jsonify, Response, send_fro
import json
import logging
import os
import queue
import sys
import subprocess
import threading
import time
from pathlib import Path
from datetime import datetime, timedelta
@@ -413,13 +415,53 @@ def add_security_headers(response):
return response
# SSE helper function
def sse_response(generator_func):
"""Helper to create SSE responses"""
def generate():
for data in generator_func():
yield f"data: {json.dumps(data)}\n\n"
return Response(generate(), mimetype='text/event-stream')
class _StreamBroadcaster:
"""Fan-out broadcaster: one background generator thread pushes to all SSE clients.
This means N browser tabs share one generator instead of each running their own,
keeping PIL encodes / subprocess forks constant regardless of how many tabs are open.
"""
def __init__(self, generator_factory):
self._generator_factory = generator_factory
self._clients: set = set()
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
def subscribe(self) -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=5)
with self._lock:
self._clients.add(q)
if not (self._thread and self._thread.is_alive()):
self._thread = threading.Thread(target=self._broadcast, daemon=True)
self._thread.start()
return q
def unsubscribe(self, q: queue.Queue) -> None:
with self._lock:
self._clients.discard(q)
def _broadcast(self):
for data in self._generator_factory():
with self._lock:
if not self._clients:
# No subscribers — exit so the thread doesn't spin indefinitely.
# subscribe() will restart it when a new client arrives.
break
for q in self._clients:
try:
q.put_nowait(data)
except queue.Full:
# Client is reading too slowly; drop the oldest item and
# deliver the latest so the queue never stalls the client.
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(data)
except queue.Full:
pass
# System status generator for SSE
def system_status_generator():
@@ -596,20 +638,50 @@ def logs_generator():
time.sleep(5) # Update every 5 seconds (reduced frequency for better performance)
# One broadcaster per stream — shared across all SSE clients
_stats_broadcaster = _StreamBroadcaster(system_status_generator)
_display_broadcaster = _StreamBroadcaster(display_preview_generator)
_logs_broadcaster = _StreamBroadcaster(logs_generator)
def _sse_stream(broadcaster: _StreamBroadcaster) -> Response:
"""Return a streaming SSE response backed by a shared broadcaster."""
q = broadcaster.subscribe()
def generate():
try:
while True:
try:
data = q.get(timeout=30)
yield f"data: {json.dumps(data)}\n\n"
except queue.Empty:
# Send an SSE comment heartbeat to keep the connection alive
# through proxies that close idle connections.
yield ": heartbeat\n\n"
except GeneratorExit:
pass
finally:
broadcaster.unsubscribe(q)
return Response(generate(), mimetype='text/event-stream')
# SSE endpoints
@app.route('/api/v3/stream/stats')
def stream_stats():
return sse_response(system_status_generator)
return _sse_stream(_stats_broadcaster)
@app.route('/api/v3/stream/display')
def stream_display():
return sse_response(display_preview_generator)
return _sse_stream(_display_broadcaster)
@app.route('/api/v3/stream/logs')
def stream_logs():
return sse_response(logs_generator)
return _sse_stream(_logs_broadcaster)
# Exempt SSE streams from CSRF and add rate limiting
# Exempt SSE streams from CSRF and apply a generous rate limit.
# SSE connections are long-lived HTTP requests, not repeated API calls, so the
# tight "20 per minute" default would be exhausted quickly on reconnects.
if csrf:
csrf.exempt(stream_stats)
csrf.exempt(stream_display)
@@ -617,9 +689,9 @@ if csrf:
# Note: api_v3 blueprint is exempted above after registration
if limiter:
limiter.limit("20 per minute")(stream_stats)
limiter.limit("20 per minute")(stream_display)
limiter.limit("20 per minute")(stream_logs)
limiter.limit("200 per minute")(stream_stats)
limiter.limit("200 per minute")(stream_display)
limiter.limit("200 per minute")(stream_logs)
# Main route - redirect to v3 interface as default
@app.route('/')

View File

@@ -6542,7 +6542,7 @@ def scan_wifi_networks():
ap_was_active = wifi_manager._is_ap_mode_active()
# Perform the scan (this will handle AP mode disabling/enabling internally)
networks = wifi_manager.scan_networks()
networks, _was_cached = wifi_manager.scan_networks()
# Convert to dict format
networks_data = [
@@ -6680,7 +6680,9 @@ def enable_ap_mode():
from src.wifi_manager import WiFiManager
wifi_manager = WiFiManager()
success, message = wifi_manager.enable_ap_mode()
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
success, message = wifi_manager.enable_ap_mode(force=force)
if success:
return jsonify({

View File

@@ -1,4 +1,4 @@
/* global showNotification, updateSystemStats, htmx */
/* global showNotification, updateSystemStats, updateDisplayPreview, htmx */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
@@ -51,7 +51,8 @@ document.body.addEventListener('htmx:afterRequest', function(event) {
}
});
// SSE reconnection helper
// 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();
@@ -60,14 +61,18 @@ window.reconnectSSE = function() {
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() {
// Handle display updates
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);
}
};

View File

@@ -0,0 +1,782 @@
/**
* JsonFileManager — reusable JSON file management widget for LEDMatrix plugins.
*
* Usage via config_schema.json:
* "file_manager": {
* "type": "null",
* "title": "Data Files",
* "x-widget": "json-file-manager",
* "x-widget-config": {
* "actions": {
* "list": "list-files", // required
* "get": "get-file", // required for editing
* "save": "save-file", // required for editing
* "upload": "upload-file", // optional
* "delete": "delete-file", // optional
* "create": "create-file", // optional
* "toggle": "toggle-category" // optional
* },
* "upload_hint": "Hint text under the drop zone",
* "directory_label": "of_the_day/",
* "create_fields": [
* { "key": "category_name", "label": "Category Name",
* "placeholder": "my_words", "pattern": "^[a-z0-9_]+$",
* "hint": "Used as filename" },
* { "key": "display_name", "label": "Display Name",
* "placeholder": "My Words" }
* ],
* "toggle_key": "category_name"
* }
* }
*
* No CDN dependencies. Works on all modern browsers.
*/
(function () {
'use strict';
class JsonFileManager {
constructor(container, config, pluginId) {
// Prevent duplicate instances on the same container
if (container._jfmInstance) {
container._jfmInstance._destroy();
}
container._jfmInstance = this;
this.el = container;
this.pluginId = pluginId;
this.actions = config.actions || {};
this.uploadHint = config.upload_hint || '';
this.dirLabel = config.directory_label || '';
this.createFields = config.create_fields || [];
this.toggleKey = config.toggle_key || null;
// Unique prefix for all DOM IDs in this instance
this._uid = 'jfm_' + Math.random().toString(36).slice(2, 9);
// Mutable state
this._editFile = null;
this._deleteFile = null;
this._keyHandler = this._onKey.bind(this);
this._inject();
this._bind();
this._loadList();
}
// ── Lifecycle ────────────────────────────────────────────────────────
_destroy() {
document.removeEventListener('keydown', this._keyHandler);
this.el._jfmInstance = null;
}
// ── DOM Injection ────────────────────────────────────────────────────
_inject() {
const u = this._uid;
const hasUpload = !!this.actions.upload;
const hasCreate = !!this.actions.create;
const hasDelete = !!this.actions.delete;
this.el.innerHTML = this._css(u) + `
<div id="${u}" class="jfm">
<div class="jfm-header">
<div class="jfm-header-left">
<span class="jfm-title">Data Files</span>
${this.dirLabel ? `<code class="jfm-dir">${this._esc(this.dirLabel)}</code>` : ''}
</div>
<div class="jfm-header-right">
${hasCreate ? `<button class="jfm-btn jfm-btn-primary jfm-btn-sm" data-jfm="open-create">+ New File</button>` : ''}
<button class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="refresh" title="Refresh file list">&#8635;</button>
</div>
</div>
<div id="${u}-list" class="jfm-list">
<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>
</div>
${hasUpload ? `
<div class="jfm-upload-wrap">
<input type="file" accept=".json" id="${u}-fileinput" tabindex="-1">
<div class="jfm-dropzone" id="${u}-dropzone" data-jfm="open-picker" role="button" tabindex="0"
aria-label="Upload JSON file">
<span class="jfm-drop-icon">&#128193;</span>
<p class="jfm-drop-primary">Drop a JSON file here, or click to browse</p>
${this.uploadHint ? `<p class="jfm-drop-hint">${this._esc(this.uploadHint)}</p>` : ''}
</div>
</div>` : ''}
<!-- ── Edit modal ─────────────────────────────────────── -->
<div class="jfm-modal" id="${u}-edit-modal" role="dialog" aria-modal="true" 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 class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="fmt">Format</button>
<button class="jfm-btn jfm-btn-ghost jfm-btn-sm" data-jfm="validate">Validate</button>
<button class="jfm-close-btn" data-jfm="close-edit" aria-label="Close">&times;</button>
</div>
</div>
<div id="${u}-edit-err" class="jfm-err-bar" hidden></div>
<textarea id="${u}-editor" class="jfm-editor"
spellcheck="false" autocomplete="off"
autocorrect="off" autocapitalize="off"
aria-label="JSON editor"></textarea>
<div class="jfm-modal-foot">
<span id="${u}-charcount" class="jfm-stat"></span>
<button class="jfm-btn jfm-btn-ghost" data-jfm="close-edit">Cancel</button>
<button class="jfm-btn jfm-btn-primary" data-jfm="save" id="${u}-save-btn">Save</button>
</div>
</div>
</div>
<!-- ── Delete modal ───────────────────────────────────── -->
${hasDelete ? `
<div class="jfm-modal" id="${u}-del-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Delete file</span>
<button class="jfm-close-btn" data-jfm="close-del" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
<p>Delete <strong id="${u}-del-name"></strong>?</p>
<p class="jfm-muted">This permanently removes the file and its entry from the plugin configuration.</p>
</div>
<div class="jfm-modal-foot">
<button class="jfm-btn jfm-btn-ghost" data-jfm="close-del">Cancel</button>
<button class="jfm-btn jfm-btn-danger" data-jfm="confirm-del" id="${u}-del-btn">Delete</button>
</div>
</div>
</div>` : ''}
<!-- ── Create modal ───────────────────────────────────── -->
${hasCreate ? `
<div class="jfm-modal" id="${u}-create-modal" role="dialog" aria-modal="true" hidden>
<div class="jfm-modal-box">
<div class="jfm-modal-head">
<span class="jfm-modal-title">Create new file</span>
<button class="jfm-close-btn" data-jfm="close-create" aria-label="Close">&times;</button>
</div>
<div class="jfm-modal-body">
${this.createFields.map(f => `
<div class="jfm-field">
<label for="${u}-cf-${this._esc(f.key)}">${this._esc(f.label)}</label>
<input type="text" id="${u}-cf-${this._esc(f.key)}"
placeholder="${this._esc(f.placeholder || '')}"
${f.pattern ? `pattern="${this._esc(f.pattern)}"` : ''}>
${f.hint ? `<span class="jfm-hint">${this._esc(f.hint)}</span>` : ''}
</div>`).join('')}
</div>
<div class="jfm-modal-foot">
<button class="jfm-btn jfm-btn-ghost" data-jfm="close-create">Cancel</button>
<button class="jfm-btn jfm-btn-primary" data-jfm="do-create" id="${u}-create-btn">Create</button>
</div>
</div>
</div>` : ''}
</div>`; // end #${u}
// Cache frequently-used elements
this._root = document.getElementById(u);
this._listEl = document.getElementById(`${u}-list`);
this._editorEl = document.getElementById(`${u}-editor`);
this._editModal = document.getElementById(`${u}-edit-modal`);
this._delModal = document.getElementById(`${u}-del-modal`);
this._createModal = document.getElementById(`${u}-create-modal`);
this._dropzone = document.getElementById(`${u}-dropzone`);
this._fileInput = document.getElementById(`${u}-fileinput`);
}
_css(u) {
return `<style>
#${u}{font-family:inherit;color:#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-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: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;}
/* 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-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-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.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-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 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-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;}
/* 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-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-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;}
/* 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-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;}
/* 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;}
@keyframes jfm-spin-${u}{to{transform:rotate(360deg);}}
</style>`;
}
// ── Event Binding ────────────────────────────────────────────────────
_bind() {
// Delegated clicks on the widget root
this._root.addEventListener('click', this._onClick.bind(this));
this._root.addEventListener('change', this._onChange.bind(this));
// Drag-and-drop on the dropzone
if (this._dropzone) {
this._dropzone.addEventListener('dragover', e => {
e.preventDefault();
this._dropzone.classList.add('jfm-over');
});
this._dropzone.addEventListener('dragleave', () => {
this._dropzone.classList.remove('jfm-over');
});
this._dropzone.addEventListener('drop', e => {
e.preventDefault();
this._dropzone.classList.remove('jfm-over');
const file = e.dataTransfer?.files[0];
if (file) this._uploadFile(file);
});
// Keyboard activation of drop zone
this._dropzone.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this._fileInput?.click();
}
});
}
// Modal backdrop clicks
[this._editModal, this._delModal, this._createModal].forEach(m => {
if (m) m.addEventListener('click', e => { if (e.target === m) this._closeAll(); });
});
// Editor: char count + Tab indent
if (this._editorEl) {
this._editorEl.addEventListener('input', () => this._updateStat());
this._editorEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const s = this._editorEl.selectionStart;
const end = this._editorEl.selectionEnd;
const v = this._editorEl.value;
this._editorEl.value = v.slice(0, s) + ' ' + v.slice(end);
this._editorEl.selectionStart = this._editorEl.selectionEnd = s + 2;
this._updateStat();
}
});
}
// Global keyboard shortcuts
document.addEventListener('keydown', this._keyHandler);
}
_onKey(e) {
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();
}
}
_onClick(e) {
const btn = e.target.closest('[data-jfm]');
if (!btn) return;
const action = btn.dataset.jfm;
switch (action) {
case 'refresh': this._loadList(); break;
case 'open-picker': this._fileInput?.click(); break;
case 'open-create': this._openCreate(); break;
case 'close-edit': this._closeEdit(); break;
case 'close-del': this._closeDel(); break;
case 'close-create': this._closeCreate(); break;
case 'fmt': this._formatJson(); break;
case 'validate': this._validateJson(); break;
case 'save': this._doSave(); break;
case 'confirm-del': this._doDelete(); break;
case 'do-create': this._doCreate(); break;
case 'edit-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openEdit(card.dataset.jfmFile);
break;
}
case 'del-file': {
const card = btn.closest('[data-jfm-file]');
if (card) this._openDel(card.dataset.jfmFile);
break;
}
}
}
_onChange(e) {
// Toggle checkbox
if (e.target.classList.contains('jfm-toggle-cb')) {
const catName = e.target.dataset.cat;
const enabled = e.target.checked;
this._doToggle(catName, enabled, e.target);
}
// File input
if (e.target === this._fileInput) {
const file = e.target.files?.[0];
if (file) this._uploadFile(file);
e.target.value = '';
}
}
// ── API helper ───────────────────────────────────────────────────────
async _api(actionKey, params) {
const actionId = this.actions[actionKey];
if (!actionId) throw new Error(`Action "${actionKey}" not configured`);
const body = { plugin_id: this.pluginId, action_id: actionId };
if (params !== undefined) body.params = params;
const r = await fetch('/api/v3/plugins/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!r.ok) throw new Error('Server error ' + r.status);
const ct = r.headers.get('content-type') || '';
if (!ct.includes('application/json')) {
const txt = await r.text();
throw new Error('Unexpected response: ' + txt.slice(0, 120));
}
return r.json();
}
// ── File List ────────────────────────────────────────────────────────
async _loadList() {
this._listEl.innerHTML = `<div class="jfm-loading"><span class="jfm-spin"></span> Loading…</div>`;
try {
const data = await this._api('list');
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._renderList(data.files || []);
} catch (err) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#9888;</div>
<p class="jfm-empty-title">Failed to load files</p>
<p class="jfm-empty-sub">${this._esc(err.message)}</p>
</div>`;
}
}
_renderList(files) {
if (!files.length) {
this._listEl.innerHTML = `
<div class="jfm-empty">
<div class="jfm-empty-icon">&#128193;</div>
<p class="jfm-empty-title">No files yet</p>
<p class="jfm-empty-sub">Upload or create a JSON file to get started</p>
</div>`;
return;
}
this._listEl.innerHTML = files.map(f => this._card(f)).join('');
}
_card(f) {
const u = this._uid;
const enabled = f.enabled !== false;
const displayName = this._esc(f.display_name || f.filename);
const filename = this._esc(f.filename);
const catName = this.toggleKey ? this._esc(f[this.toggleKey] || '') : '';
const showToggle = !!(this.actions.toggle && this.toggleKey && f[this.toggleKey]);
const hasEdit = !!this.actions.get && !!this.actions.save;
const hasDelete = !!this.actions.delete;
return `
<div class="jfm-card${enabled ? '' : ' jfm-off'}" data-jfm-file="${filename}">
<div class="jfm-card-top">
<span class="jfm-card-name" title="${filename}">${displayName}</span>
${showToggle ? `
<label class="jfm-toggle" title="${enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}">
<input type="checkbox" class="jfm-toggle-cb" data-cat="${catName}" ${enabled ? 'checked' : ''}>
<span>${enabled ? 'On' : 'Off'}</span>
</label>` : ''}
</div>
<div class="jfm-card-meta">
<span>&#128196; ${filename}</span>
<span>&#128202; ${f.entry_count ?? 0} entries &middot; ${this._fmtSize(f.size || 0)}</span>
<span>&#128337; ${this._fmtDate(f.modified)}</span>
</div>
<div class="jfm-card-actions">
${hasEdit ? `<button class="jfm-btn jfm-btn-sm" data-jfm="edit-file">&#9998; Edit</button>` : ''}
${hasDelete ? `<button class="jfm-btn jfm-btn-danger jfm-btn-sm jfm-del" data-jfm="del-file" title="Delete file">&#128465;</button>` : ''}
</div>
</div>`;
}
// ── Edit flow ────────────────────────────────────────────────────────
async _openEdit(filename) {
this._editFile = filename;
document.getElementById(`${this._uid}-edit-title`).textContent = `Edit: ${filename}`;
this._clearErr();
this._editorEl.value = 'Loading…';
this._updateStat();
this._editModal.hidden = false;
try {
const data = await this._api('get', { filename });
if (data.status !== 'success') throw new Error(data.message || 'Load failed');
this._editorEl.value = JSON.stringify(data.content, null, 2);
this._updateStat();
this._editorEl.focus();
this._editorEl.setSelectionRange(0, 0);
this._editorEl.scrollTop = 0;
} catch (err) {
this._showErr('Failed to load file: ' + err.message);
this._editorEl.value = '';
}
}
_closeEdit() {
if (this._editModal) this._editModal.hidden = true;
this._editFile = null;
this._clearErr();
}
_formatJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
this._editorEl.value = JSON.stringify(parsed, null, 2);
this._updateStat();
this._clearErr();
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
_validateJson() {
try {
const parsed = JSON.parse(this._editorEl.value);
const n = (typeof parsed === 'object' && parsed !== null) ? Object.keys(parsed).length : '?';
this._clearErr();
this._notify(`Valid JSON — ${n} top-level keys`, 'success');
} catch (err) {
this._showErr('Invalid JSON — ' + err.message);
}
}
async _doSave() {
if (!this._editFile) return;
let contentStr;
try {
const parsed = JSON.parse(this._editorEl.value);
contentStr = JSON.stringify(parsed, null, 2);
} catch (err) {
this._showErr('Cannot save — fix JSON first: ' + err.message);
return;
}
const btn = document.getElementById(`${this._uid}-save-btn`);
this._busy(btn, 'Saving…');
try {
const data = await this._api('save', { filename: this._editFile, content: contentStr });
if (data.status !== 'success') throw new Error(data.message || 'Save failed');
this._notify('File saved', 'success');
this._closeEdit();
this._loadList();
} catch (err) {
this._showErr('Save failed: ' + err.message);
this._idle(btn, 'Save');
}
}
// ── Delete flow ──────────────────────────────────────────────────────
_openDel(filename) {
this._deleteFile = filename;
const el = document.getElementById(`${this._uid}-del-name`);
if (el) el.textContent = filename;
if (this._delModal) this._delModal.hidden = false;
}
_closeDel() {
if (this._delModal) this._delModal.hidden = true;
this._deleteFile = null;
}
async _doDelete() {
if (!this._deleteFile) return;
const btn = document.getElementById(`${this._uid}-del-btn`);
this._busy(btn, 'Deleting…');
try {
const data = await this._api('delete', { filename: this._deleteFile });
if (data.status !== 'success') throw new Error(data.message || 'Delete failed');
this._notify('File deleted', 'success');
this._closeDel();
this._loadList();
} catch (err) {
this._notify('Delete failed: ' + err.message, 'error');
this._idle(btn, 'Delete');
}
}
// ── Create flow ──────────────────────────────────────────────────────
_openCreate() {
if (!this._createModal) return;
this.createFields.forEach(f => {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
if (el) el.value = '';
});
this._createModal.hidden = false;
const first = this.createFields[0];
if (first) document.getElementById(`${this._uid}-cf-${first.key}`)?.focus();
}
_closeCreate() {
if (this._createModal) this._createModal.hidden = true;
}
async _doCreate() {
const params = {};
for (const f of this.createFields) {
const el = document.getElementById(`${this._uid}-cf-${f.key}`);
const val = (el?.value || '').trim();
if (!val) {
this._notify(`"${f.label}" is required`, 'error');
el?.focus();
return;
}
if (f.pattern && !new RegExp(f.pattern).test(val)) {
this._notify(`"${f.label}" format is invalid`, 'error');
el?.focus();
return;
}
params[f.key] = val;
}
// Auto-derive display_name from category_name if left blank
const nameField = this.createFields.find(f => f.key === 'display_name');
if (nameField) {
const el = document.getElementById(`${this._uid}-cf-display_name`);
if (el && !el.value.trim()) {
const catEl = document.getElementById(`${this._uid}-cf-category_name`);
if (catEl?.value) {
params.display_name = catEl.value.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
}
}
const btn = document.getElementById(`${this._uid}-create-btn`);
this._busy(btn, 'Creating…');
try {
const data = await this._api('create', params);
if (data.status !== 'success') throw new Error(data.message || 'Create failed');
this._notify('File created', 'success');
this._closeCreate();
this._loadList();
} catch (err) {
this._notify('Create failed: ' + err.message, 'error');
this._idle(btn, 'Create');
}
}
// ── Upload ───────────────────────────────────────────────────────────
async _uploadFile(file) {
if (!file.name.endsWith('.json')) {
this._notify('Please select a .json file', 'error');
return;
}
let content;
try {
content = await file.text();
JSON.parse(content); // client-side validation
} catch (err) {
this._notify('Invalid JSON: ' + err.message, 'error');
return;
}
if (this._dropzone) this._dropzone.style.opacity = '.5';
try {
const data = await this._api('upload', { filename: file.name, content });
if (data.status !== 'success') throw new Error(data.message || 'Upload failed');
this._notify(`"${file.name}" uploaded`, 'success');
this._loadList();
} catch (err) {
this._notify('Upload failed: ' + err.message, 'error');
} finally {
if (this._dropzone) this._dropzone.style.opacity = '';
}
}
// ── Toggle ───────────────────────────────────────────────────────────
async _doToggle(catName, enabled, checkbox) {
checkbox.disabled = true;
try {
const params = { enabled };
if (this.toggleKey) params[this.toggleKey] = catName;
const data = await this._api('toggle', params);
if (data.status !== 'success') throw new Error(data.message || 'Toggle failed');
this._notify(enabled ? 'Category enabled' : 'Category disabled', 'success');
this._loadList();
} catch (err) {
this._notify('Toggle failed: ' + err.message, 'error');
checkbox.checked = !enabled; // revert
checkbox.disabled = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────
_closeAll() {
this._closeEdit();
this._closeDel();
this._closeCreate();
}
_updateStat() {
const v = this._editorEl?.value || '';
const lines = v ? v.split('\n').length : 0;
const el = document.getElementById(`${this._uid}-charcount`);
if (el) el.textContent = `${lines.toLocaleString()} lines · ${v.length.toLocaleString()} chars`;
}
_showErr(msg) {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = msg; el.hidden = false; }
}
_clearErr() {
const el = document.getElementById(`${this._uid}-edit-err`);
if (el) { el.textContent = ''; el.hidden = true; }
}
_notify(msg, type) {
if (typeof window.showNotification === 'function') {
window.showNotification(msg, type || 'info');
} else {
console.info(`[JsonFileManager] ${type || 'info'}: ${msg}`);
}
}
_busy(btn, label) {
if (!btn) return;
btn._jfmOriginal = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="jfm-spin"></span> ${this._esc(label)}`;
}
_idle(btn, label) {
if (!btn) return;
btn.disabled = false;
btn.innerHTML = btn._jfmOriginal || this._esc(label);
}
_esc(str) {
const d = document.createElement('div');
d.textContent = String(str ?? '');
return d.innerHTML;
}
_fmtSize(bytes) {
if (!bytes) return '0 B';
const i = Math.min(Math.floor(Math.log2(bytes + 1) / 10), 2);
const unit = ['B', 'KB', 'MB'][i];
const val = bytes / Math.pow(1024, i);
return (i ? val.toFixed(1) : val) + ' ' + unit;
}
_fmtDate(str) {
if (!str) return '—';
try {
return new Date(str).toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric'
});
} catch { return str; }
}
}
// ── Widget registry integration ──────────────────────────────────────────
window.JsonFileManager = JsonFileManager;
if (typeof window.LEDMatrixWidgets !== 'undefined') {
window.LEDMatrixWidgets.register('json-file-manager', {
name: 'JSON File Manager',
version: '1.0.0',
render(container, config, _value, options) {
new JsonFileManager(container, config || {}, options?.pluginId || '');
},
getValue() { return null; },
setValue() {}
});
console.log('[JsonFileManager] Registered with LEDMatrixWidgets');
} else {
console.log('[JsonFileManager] Loaded (LEDMatrixWidgets registry not available)');
}
})();

View File

@@ -3446,6 +3446,23 @@ function generateFieldHtml(key, prop, value, prefix = '') {
html += `<option value="${option}" ${selected}>${option}</option>`;
});
html += `</select>`;
} else if (prop['x-widget'] === 'json-file-manager') {
// Reusable JSON file manager widget (no CDN, keyboard shortcuts, configurable actions)
const widgetConfig = prop['x-widget-config'] || {};
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
const safeFieldId = (fullKey || 'file_manager').replace(/[^a-zA-Z0-9_-]/g, '_');
html += `<div id="${safeFieldId}_jfm_mount"></div>`;
setTimeout(() => {
const mount = document.getElementById(`${safeFieldId}_jfm_mount`);
if (!mount) return;
if (typeof JsonFileManager !== 'undefined') {
new JsonFileManager(mount, widgetConfig, pluginId);
} else {
mount.innerHTML = '<p style="color:#dc2626;font-size:.875rem;">json-file-manager widget not loaded. Check base.html includes json-file-manager.js.</p>';
}
}, 150);
} else if (prop['x-widget'] === 'custom-html') {
// Custom HTML widget - load HTML from plugin directory
const htmlFile = prop['x-html-file'];

View File

@@ -1370,34 +1370,64 @@
<!-- SSE connection for real-time updates -->
<script>
// Connect to SSE streams
const statsSource = new EventSource('/api/v3/stream/stats');
const displaySource = new EventSource('/api/v3/stream/display');
// 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');
statsSource.onmessage = function(event) {
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateSystemStats(data);
};
displaySource.onmessage = function(event) {
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDisplayPreview(data);
};
// Connection status
statsSource.addEventListener('open', function() {
document.getElementById('connection-status').innerHTML = `
function _setConnectionStatus(connected, reconnecting) {
const el = document.getElementById('connection-status');
if (!el) return;
if (connected) {
el.innerHTML = `
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-gray-600">Connected</span>
`;
});
statsSource.addEventListener('error', function() {
document.getElementById('connection-status').innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600">Disconnected</span>
} else if (reconnecting) {
el.innerHTML = `
<div class="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
<span class="text-gray-600">Reconnecting…</span>
`;
});
} else {
el.innerHTML = `
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<span class="text-gray-600" title="Connection lost — try refreshing the page">Disconnected</span>
`;
}
}
var _statsErrorCount = 0;
// Named on window so reconnectSSE() in app.js can reattach them after
// replacing the EventSource instances.
window._statsOpenHandler = function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
};
window._statsErrorHandler = function() {
_statsErrorCount++;
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
};
window._displayErrorHandler = function() {
// Display stream errors don't change the status badge but log to console
// so failures aren't completely silent.
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);
function updateSystemStats(data) {
// Update CPU in header
@@ -4595,6 +4625,9 @@
<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>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>