mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(sports): rebuild un-shared faces through the pinned layout engine unshare_element_fonts re-instantiates a duplicate font face so two elements can be told apart by id(). It did so through bare ImageFont.truetype, which takes PIL's default layout engine rather than the one src/common/font_layout.py pins. Raqm and Basic disagree on fractional advances -- that disagreement is the reason the pin exists, having broken golden images across machines -- so a rebuilt face could measure differently from the shared face it replaced, on any host where Raqm is installed. These were the only two call sites in src/ bypassing the pin. The guard asserts that the rebuild goes through the pinned loader rather than comparing engine values: where Raqm is absent, bare truetype returns BASIC anyway, so an engine comparison passes whether or not the pin is honoured. The first draft of this test did exactly that and passed with the bug reintroduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(web): drop the two dead client-side config-form renderers generateConfigForm and generateSimpleConfigForm (580 lines) were defined on the Alpine component and never called: server-side Jinja replaced them, as pages_v3.py:641 records. Nothing in any template invokes them -- there is no x-html in the templates and no bracket access on the component. They carried their own x-widget dispatch, which made them an active trap: the next person adding a widget would reasonably think both renderers needed updating. plugins/config_manager.js (PluginConfigManager, 133 lines) goes for the same reason -- loaded on every page from base.html, referenced only by itself and by an archived doc. Kept, having checked them: widgets/example-color-picker.js is the worked example docs/widget-guide.md points plugin authors at, and widgets/plugin-loader.js is the client half of a documented feature (manifest-declared plugin widgets) whose server route is missing -- soccer-scoreboard already ships a widgets/custom-leagues.js that this loader is meant to fetch. That is an unfinished feature to complete, not dead code to delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): serve plugin-declared widgets, and actually ask for them LEDMatrixWidgets.loadPluginWidget has always fetched /static/plugin-widgets/<plugin>/<widget>.js, and docs/widget-guide.md has always documented that path, but nothing served it. soccer-scoreboard has shipped a 17KB widgets/custom-leagues.js since August that could never load. Both halves were missing, not just the route: - serve_plugin_widget serves the script from the plugin's widgets/ directory as text/javascript. The manifest is the allowlist -- only a widget the plugin declares is reachable -- so installing a plugin does not publish everything it ships. Path handling mirrors the sibling serve_plugin_web_ui: allowlist regexes, os.path.basename, resolve() + relative_to() containment, and the ledmatrix- prefix fallback. The declared script name is guarded too, since it comes from the plugin rather than the request. - The config form never requested one. Its x-widget dispatch is a hardcoded list of core widget names, so a plugin's own widget fell through to a plain text input. An unrecognised x-widget on a string field now asks ensureWidget() for it. The text input stays as the fallback and is removed only once the widget has actually rendered, so a missing or broken widget costs the user an editor rather than their configured value on the next save. - manifest_schema.json gains "widgets", so the declaration is validated rather than merely tolerated by additionalProperties. Verified in a browser against the real partial: a declared widget loads, registers and renders, and its field posts exactly one value; a field whose widget 404s keeps its text input and still posts its value. Not addressed: loadPluginWidgetsFromManifest still has no caller. The per-field ensureWidget path is lazier and is what the form now uses, so that bulk helper is dead weight -- worth removing, but left alone here rather than inventing a call site for it. Known limitation, documented: only string-typed fields take this path. object/array/boolean/number fields and enums are dispatched by the template's own branches, which still only know core widgets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(element-style): a wrong-size BDF now keeps its font, not its size BDF fonts are fixed-size bitmap strikes: FreeType accepts only the pixel size baked into the file and raises for anything else. 32 of the 35 shipped fonts are BDF, so a size picked in the web UI usually is not a valid strike -- and load_font caught that failure with its generic "unloadable font" handler, which substitutes PressStart2P. Asking for 5x7.bdf at size 10 therefore rendered a completely different typeface, silently. It now falls back to the file's own native size instead, which is what SportsCore._load_custom_font_from_element_config has always done. The native size is read via FontManager._read_bdf_native_size rather than a fourth copy of that parser, matching how core.py already delegates. Also here, because they are the same code path: - native_bdf_size() is exposed for the web UI, which needs to know when a size field can take effect at all. None means "free choice". - ElementStyle.font_size now reports the size actually realised rather than the one requested. Callers lay out from it, and reserving space for a size nothing was drawn at is how this surfaces. - The module font cache is a bounded LRU (256) instead of an unbounded dict. The display process runs for weeks and every config save can add a (font, size) pair; every other hot cache in the codebase is bounded this way. Untouched configs are unaffected: the shipped classic fonts are the three TTFs, so nothing was hitting the substitution path by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): per-mode style and offset overrides Lets one element be styled differently per situation -- a scoreboard's live / upcoming / recent cards, weather's current / hourly / daily screens -- under customization.modes.<mode>. The mode is bound at construction rather than passed per call. That is what makes this cheap to adopt: SportsUpcoming and SportsRecent are already separate instances with distinct SKIN_MODE values, so binding once makes every existing style()/offset_value() call site mode-aware without editing any of them. A per-call mode argument exists for the rare host that renders more than one mode. The two layers answer different questions, deliberately: - The base layer keeps the existing "differs from the schema default" rule, because the save flow writes the full default object into config.json whether or not the user touched it. - A mode layer is pure override -- its fields default to None, so presence is intent. Nothing writes into it unasked, so there is nothing for the stricter rule to protect against. None therefore means inherit, and has to stay distinct from 0: a mode y_offset of 0 means "sit at the base position", not "no preference". This is the same distinction scroll_card.switch_* draws with "inherit". A malformed mode value falls back to the resolved base value rather than to the caller's default -- caught by the degradation tests, which is what they are for: resolving the mode first let one bad string in a mode block silently discard a good base offset. With no modes block, and for every existing caller, resolution is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): declare per-mode overrides in config_schema.json A plugin adds "x-style-modes": ["live", "upcoming", "recent"] alongside its x-style-elements declaration and gets a customization.modes.<mode> group per mode, with every field of every declared element repeated as an override. Those override fields are typed nullable and default to null, which is the whole trick. The save flow writes schema defaults into config.json wholesale, so giving a mode field the base element's default would make every mode a frozen copy of the base the first time a user pressed Save, and the base would stop reaching them. Null means inherit. The mutation test for this is explicit: with concrete defaults, a base font_size of 14 resolves as 10 with user_forced set. min/max from the declaration carry into the mode blocks, so an out-of-range override is rejected by validation rather than clamped silently at render time. Also: the emitted font field now carries "x-widget": "font-selector". The widget already shipped and the config form already allowlisted it -- the hint was simply never emitted, so the field rendered as a bare text box that the user had to type a font filename into. Verified through the real SchemaManager path -- load_schema, defaults extraction, merge_with_defaults, validation, then resolution -- rather than against a hand-built dict, since the thing at risk is what that pipeline does to a null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): render the config form from the schema the save route validates The form read config_schema.json with a raw json.load while api_v3.save_plugin_config went through SchemaManager. Those are not the same schema: SchemaManager applies expand_style_elements, which turns a compact customization.x-style-elements declaration into the per-element blocks the form knows how to render. Without it, that customization object has an x-style-elements key and no "properties", so the template's object branch matched nothing and the section rendered as empty space -- while saving still validated against the expanded shape. of-the-day ships the compact form, so its customization section has been invisible in the web UI. pages_v3 gains a schema_manager the way it already has config_manager and plugin_manager. use_cache=False matches the save route, so an edited schema is not served stale during plugin development. The raw read stays as a fallback for callers that register this blueprint without one. Checked before making the change: load_schema does nothing here except read, validate and expand -- inject_skin_selector is a separate method it does not call -- so this is not a behaviour change for schemas without the declaration. The test pair renders the same compact schema with and without a SchemaManager, so it documents exactly what was broken as well as what is fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): style-editor widget -- a row per element instead of 65 accordions Rendered element by element, a realistic scoreboard's customization block is 65 nested sections, and reaching one per-mode font size takes five levels of expanding. The widget collapses that to one compact row per element -- font, size, colour, X, Y -- with a tab per declared mode. It emits ordinary inputs under the same dotted names the generic renderer would produce, so the save/validate/merge pipeline is untouched: no hidden JSON blob and no new server-side parsing. It is driven entirely by the schema block it is handed, so fields added to the schema later appear without editing the widget. If it fails to load or throws, the generic nested rendering it replaces is left in place. Fixing two things the save path got wrong for nullable fields, found by posting what the widget actually emits: - The indexed-array recombiner (text_color.0/.1/.2 -> one list) compared the declared type to the string 'array', so a per-mode colour, typed ["array", "null"], was never reassembled and failed validation on save. _parse_form_value_with_schema had the same comparison. - A blank nullable field became [] rather than None, which then failed the minItems the colour array declares. Null is the inherit sentinel, so it has to survive. And two things the widget itself got wrong, found by looking at it: - An unset base control fell back to the select's first option, so an untouched scoreboard claimed every element used 10x20.bdf -- and the size box then locked itself to that bitmap font's fixed size. Base controls now show the schema default; mode controls stay blank, because blank there means inherit. - Elements arrived alphabetised (Detail and Odds above Score). Flask's JSON provider sorts keys, so declaration order has to be stated explicitly; expand_style_elements now emits x-propertyOrder, which the generic renderer already honoured too. Size is disabled and shown as fixed for a bitmap font, using the scalable/native_size the font catalog now reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): visibility, alignment and scale per element Completes the customization vocabulary: hide an element, align it, and resize a logo, alongside the font/size/colour/offset that already existed. All three per mode. They resolve to "change nothing" until the user asks for something -- True, None and 1.0 -- rather than to whatever the schema declares. That is the same invariant the font fields keep: a caller that honours them still renders an untouched config exactly as it did before they existed. A schema default therefore does not count as a choice, which matters because the save flow writes that default into config either way. scale sits in the layout block with the offsets rather than in the element block, because it is geometry: a logo has a scale and no font. The widget's columns come from the schema, so a logo row shows visibility, offsets and scale and no empty font cell. Two bugs found by the tests rather than by reading: - A nullable enum needs null in its enum list, not just in its type. The mode copy of `align` defaulted to null and then failed its own schema, so a plugin declaring any enum field with modes could not save at all. Six tests failed on this before any of them reached what they were testing. - defaults_from_schema only ever extracted font/font_size/text_color, so the schema defaults for the new fields were invisible to the resolver and a declared default read as a user choice. Widget: the table scrolls horizontally and pins the element-name column. Nine columns do not fit the config panel, and clipping them hid the offsets entirely while scrolling them made every row anonymous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): resolve elements under the names plugins actually use Two naming conventions collided as the scoreboards grew. Counted across the published schemas: the style block names elements with a _text suffix (score_text, status_text, detail_text), while the layout block mostly uses the bare noun (score, date, time, odds) -- except status_text, which kept the suffix in seven plugins and lost it in two. records vs record splits seven to two the same way. A lookup now tries the exact name first and then the spellings that mean the same thing. Exact-first is what makes this inert for any config that already matches; the aliases only decide cases that resolved to nothing before. This is also what makes migrating to the compact declaration form safe. That form uses one key for both blocks, so a scoreboard adopting it asks for layout.score_text while its users have layout.score saved -- without the aliases, every offset they had dialled in would silently become 0. Applies to the style block, the layout block, the schema defaults and the per-mode overrides, since the drift shows up in all four. Not attempting to canonicalise on write: renaming keys in config.json would break the plugins still reading the old spelling from their own bundled code, and the drift costs a dict miss rather than correctness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(plugins): BasePlugin.styles -- per-element styling every plugin inherits Adopting the element-style system meant repeating three things in every plugin: a guarded import, finding its own config_schema.json, and rebuilding the resolver when on_config_change swapped the config dict. This is those three things once, on the class all 45 plugins already inherit from. title = self.styles.style('title_text', classic_font='PressStart2P-Regular.ttf', classic_size=8, classic_color=(255, 255, 255)) The classic_* arguments are the adoption contract: with nothing configured they come back verbatim, so a plugin that switches to this renders exactly as before until a user changes something. A plugin with one instance per display mode sets STYLE_MODE on the class and every existing lookup becomes mode-aware without a call site changing -- which is the point of binding the mode to the resolver rather than passing it per call. styles_for() covers a plugin that renders several modes from one instance. Schema discovery reads the concrete class's own module rather than this file, because this file lives in src/plugin_system where no plugin schema exists -- the same trap SportsCore._config_schema_path documents. The first mutation test for that passed anyway: an installed plugin's module directory and its entry under plugins_dir are the same path, so the test could not tell the two apart. The case where they diverge is a plugin symlinked in for development, and the test now forces that shape. Getting discovery wrong is silent rather than loud: with no schema the resolver has no defaults to compare against, so every configured value reads as a deliberate override and the plugin quietly stops honouring its own shipped styling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(element-style): adopt hand-written customization blocks, and widen the font list Nineteen plugins spell their style elements out longhand instead of declaring them -- football's block is 701 lines for seven elements -- and predate this system entirely. Core now recognises that shape, so they pick up the row-per-element editor and the real font picker on a core update rather than on a plugin release. Checked against every published schema: 21 plugins adopt, and the defaults of each still validate against the schema generated for it. Detection requires *every* field in a block to be one this system understands. A looser "has at least one style field" rule sweeps in baseball's `count`, which carries a text_color beside geometry that means nothing here. That distinction took three attempts to test: the first two assertions passed under both rules, because an over-eager rule leaves a fontless block looking untouched and only surfaces as an extra row in the editor. The hardcoded font enum is replaced rather than extended. Football lists five of the thirty-five installed fonts, which is why a font a user uploads can never appear in one. It is not a curated safe set -- it omits some twenty other faces that fit the declared size cap just as well -- it is the fonts that happened to exist when it was written. Widening it does need a guard, though, and not the one the schema already has: a bitmap font ignores font_size and renders at its size baked into the file, so `maximum: 16` cannot stop a 27px face. The picker now filters out fixed-size fonts taller than the element's own declared ceiling, which drops exactly the four that would overflow a 32px panel and keeps the other thirty. Per-mode overrides stay opt-in: core cannot invent a plugin's display modes, so `x-style-modes` remains the one line that unlocks them. Their layout half covers every positionable element rather than only those with a style block -- the two namespaces do not line up in a hand-written schema, and football positions six things (logos, timeouts, possession) that have no style block at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(web): remove the two Fonts-tab panels that reported invented data "Element Font Overrides" let a user configure an override, showed a success toast, and changed nothing. All three endpoints behind it were stubs -- GET returned a hardcoded {}, POST and DELETE returned success without calling anything -- each marked "This would integrate with the actual font system". Wiring them to FontManager would not have fixed it. The machinery there is real (_load_overrides/_save_overrides persist config/font_overrides.json, resolve_font applies them, and the countdown plugin genuinely consumes it), but the panel's element dropdown offered eleven invented keys -- nfl.live.score, clock.time, weather.current -- that no plugin has ever read. An override saved against one of those would have persisted correctly and still done nothing. "Detected Manager Fonts" goes for the same reason. It claimed to show "fonts currently in use by managers (auto-detected)"; its own comment said "we'll simulate this", and it listed every font in the catalog with a hardcoded usage_count of 1 -- the panel beside it, with fabricated numbers attached. Per-element font choice now lives in each plugin's own config editor, against the elements that plugin actually has, and covers size, colour, offsets, visibility, alignment and scale rather than family and size. Kept: the font library (upload, preview, delete), which works, and /fonts/tokens, which is a stub but genuinely feeds the preview's size dropdown. FontManager's override methods are untouched -- countdown uses them. Verified in a browser with the tab's JS running: no console errors, 35 fonts listed, upload and preview intact. Removing the panel meant unwiring it from populateFontSelects too, which would otherwise have bailed out early on the missing select and left the preview dropdown empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(sports): one reader for element colours and layout offsets There were two copies of the per-element colour read and three of the layout-offset read. They had already drifted -- the scroll-card renderer carries a comment about having ignored offsets its own schema advertised -- and each new capability had to be added to all of them or silently work in some places and not others. All of them now go through src.element_style, which is what carries the alias handling and the per-mode lookup. That lands immediately for the nine plugins importing these modules: a scoreboard asking for `score_text` offsets finds the `layout.score` its users configured, and a Live instance resolves its own colours through SKIN_MODE without any call site passing a mode. _normalize_color learned "#RRGGBB" in the process. The scoreboards' own readers have always accepted it, so the shared one had to, or consolidating would have quietly dropped a form users' configs may hold. _coerce_offset picked up the non-finite guard the scroll-card reader had and the other two did not. _get_layout_offset is promoted onto SportsCoreSharedMixin. Each plugin still carries its own copy in its bundled sports.py, which wins by MRO -- so adopting this is a deletion in the plugin, and until that deletion nothing changes for it. Note for whoever runs the suite next: test_display_dirty_tracking.py is order-dependent. Fifteen of its tests failed in one full run and passed in the next with no change in between, and pass in isolation. Pre-existing, unrelated to this, but it makes a full-run diff untrustworthy until it is fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(changelog): record the element-style work under Unreleased This file's own preamble asks for it: a plugin may delete its bundled fallback copy of a core module only when its manifest floors on the first release that shipped that module, which requires the additions to be recorded here against a version. Names a plugin can now import and floor on -- the stateless layout_offset and element_color readers, alias_keys, native_bdf_size, the resolver's mode binding, BasePlugin.styles, and the promoted SportsCoreSharedMixin._get_layout_offset -- plus the schema and web-UI changes, the four fixes and the three removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(fonts): log the BDF native-size read failure instead of swallowing it The bdf-native-size lookup in get_fonts_catalog() caught any exception and silently discarded it. Every other guarded read added in this PR (the manifest parse in _declared_widget_script, the SchemaManager fallback in _load_plugin_config_partial) logs before falling through to the same degraded behavior. This one didn't, which is the shape a silent-exception-swallow lint rule flags. Behavior is unchanged -- native_size still comes back None -- but a corrupt or unreadable BDF file now leaves a trace. Verified: font-related tests (140) and the full suite still pass, with only the 2 pre-existing Europe/Kiev/Asia/Calcutta tzdata-alias failures already present on origin/main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: address CodeRabbit findings on the style-editor/font-selector PR - Fix _load_font_sized double-wrapping the (font, size) tuple on the missing-font path, which handed callers a tuple instead of a font. - Fix _set_nested_value skipping an explicit None when the key already existed, which silently kept stale overrides when a user cleared a nullable per-mode field or blanked all channels of an indexed color. - Preserve BDF scalable/native_size metadata through fetchFontCatalog's catalog-format mapping so maxFixedSize filtering actually applies. - Stop caching an empty array on a failed font-catalog fetch so a later call can retry instead of being stuck with the failed result. - Keep a saved font selected in the style editor even when it no longer fits a newly declared maxFixedSize, instead of silently deselecting it. - Don't drop in-progress user edits to fallback fields when a plugin widget finishes loading asynchronously and takes over the form. - Tighten the removed font-override endpoint test to assert 405, not just != 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): a partial save no longer switches off checkboxes it never showed An HTML checkbox posts nothing when unchecked, so the save route walked the schema and forced every boolean missing from the form to False. That is right for the rendered form and wrong for every other caller: a script, the MQTT bridge or a curl against the documented endpoint never rendered a checkbox, and reading its silence as "all off" turns a one-field save into a mass disable. Found on hardware. Posting four customization.* keys to a live device switched off nfl.enabled, ncaa_fb.enabled and every display-mode toggle in one request. The form now reports the top-level sections it drew (__rendered_section), and inside those an absent checkbox still means unchecked -- including a section whose only fields are checkboxes that are all off, which no heuristic could recover. A post with no marker only touches objects it actually posted a field from. Meta fields are dropped before form keys are treated as config paths, because unknown keys are otherwise written straight into config.json. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(sports): resolve element colour by name, and honour visible/align/scale Two of the three gaps this framework shipped with. Colour by name. A draw resolved its colour by comparing the *identity* of the font object it was handed, which cannot tell two elements apart when they share a face -- so those draws went out white. Every bitmap font is in that case, because a freetype.Face cannot be re-instantiated to un-share it, which is how an element rendered in any of the 32 shipped BDF fonts silently lost a colour its picker had offered all along. _draw_text_with_outline now takes element="score_text" and reads the colour by name; the identity path remains for un-annotated callers, but narrows before giving up -- one configured colour among the sharers is the only thing the user can have meant. Visible, align and scale. The resolver has understood these since the framework landed and nothing consumed them: an element could be marked hidden in the web UI and still render. Adds the stateless readers, the mixin accessors, and a scale parameter on the one shared logo-sizing seam (keyed into the cache, so two elements scaled differently cannot be served each other's image). Naming an element in a draw also honours its visibility. Untouched configs are unaffected: every new parameter defaults to today's behaviour, and all ten affected plugins render pixel-identically to main across every harness size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(plugins): how to declare styleable elements; harden the widget's lookups The plugin-author guide for the compact x-style-elements declaration -- what each key does, how to read values back without breaking the "user-forced only when it differs from the default" rule, and why a hand-written block needs no changes to be adopted. Also clears the static-analysis findings on style-editor.js. Every lookup in that file is keyed by something out of a schema or a saved config, so a key of __proto__ or constructor would walk the prototype chain and hand back a function instead of a schema; reads now go through an own-property helper. The panel registry became a list, and the flagged vars moved to their function roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): clear the remaining static-analysis findings Five, all on lines this branch touched. The Python one is not a new defect: _set_missing_booleans_to_false's first parameter was always named `config`, which shadows the `config` submodule imported for its side effects at the bottom of this module. Editing the signature simply put the existing warning on a changed line. The parameter is the plugin's config dict, so `plugin_config` is what it should have been called anyway; callers pass it positionally and are unaffected. The JavaScript ones are the object-injection rule firing on reads keyed by data. own() now goes through a property descriptor, so the one unavoidable data-keyed read is no longer a computed member access; at() consumes its path instead of indexing it; and the column set is a Map, which has no prototype to pollute and needs no guarded reads at all. Verified the widget still renders identically against football's real schema: 29 element rows, all four mode tabs, values populated, no console errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): drop the hasOwnProperty alias the descriptor read made redundant own() now reads through Object.getOwnPropertyDescriptor, so the alias it used to call has no remaining reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1375 lines
96 KiB
HTML
1375 lines
96 KiB
HTML
{# Plugin Configuration Partial - Server-side rendered form #}
|
|
{# This template is loaded via HTMX when a plugin tab is clicked #}
|
|
|
|
{% import 'v3/partials/_macros.html' as ui %}
|
|
|
|
{# ===== MACROS FOR FORM FIELD GENERATION ===== #}
|
|
|
|
{# Render a single form field based on schema type #}
|
|
{% macro render_field(key, prop, value, prefix='', plugin_id='') %}
|
|
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
|
|
{% set field_id = (plugin_id ~ '-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
|
{% set description = prop.description if prop.description else '' %}
|
|
{% set _pt = prop.get('type') %}
|
|
{% set field_type = _pt if (_pt is string) else ((_pt | first) if (_pt and _pt is iterable and _pt is not string) else 'string') %}
|
|
|
|
{# Handle nested objects - check for widget first #}
|
|
{% if field_type == 'object' %}
|
|
{% set obj_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% if obj_widget == 'schedule-picker' %}
|
|
{# 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>
|
|
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('schedule-picker');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ obj_value|tojson|safe }};
|
|
var config = { 'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }} };
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', pluginId: '{{ plugin_id }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% elif obj_widget == 'time-range' %}
|
|
{# 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>
|
|
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}" value='{{ (obj_value|tojson|safe)|replace("'", "'") }}'>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('time-range');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ obj_value|tojson|safe }};
|
|
var config = { 'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }} };
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', pluginId: '{{ plugin_id }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% elif obj_widget == 'style-editor' %}
|
|
{# Composite per-element style editor. It renders its own inputs
|
|
with the same dotted names the generic renderer would produce
|
|
(customization.score_text.font, ...text_color.0, ...), so the
|
|
save/validate/merge pipeline is untouched -- no hidden JSON
|
|
blob, no new server-side parsing. It falls back to the normal
|
|
nested rendering if the widget cannot be loaded. #}
|
|
{% 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="style-editor-container mt-1"></div>
|
|
<div id="{{ field_id }}_fallback">
|
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
function fallbackIsDirty(root) {
|
|
if (!root) { return false; }
|
|
var controls = root.querySelectorAll('input, select, textarea');
|
|
for (var i = 0; i < controls.length; i++) {
|
|
var c = controls[i];
|
|
if (c.type === 'checkbox' || c.type === 'radio') {
|
|
if (c.checked !== c.defaultChecked) { return true; }
|
|
} else if (c.tagName === 'SELECT') {
|
|
for (var j = 0; j < c.options.length; j++) {
|
|
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
|
|
}
|
|
} else if (c.value !== c.defaultValue) {
|
|
return true;
|
|
}
|
|
}
|
|
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 container = document.getElementById('{{ field_id }}_container');
|
|
var fallback = document.getElementById('{{ field_id }}_fallback');
|
|
if (!container) { return; }
|
|
// A user may already be editing the plain fallback fields
|
|
// while this widget's script is still loading; swapping
|
|
// in the widget now would replace them with the stale
|
|
// server-rendered value and silently drop that edit.
|
|
if (fallbackIsDirty(fallback)) { container.remove(); return; }
|
|
var value = {{ obj_value|tojson|safe }};
|
|
var config = { schema: {{ prop|tojson|safe }} };
|
|
try {
|
|
widget.render(container, config, value, {
|
|
fieldId: '{{ field_id }}',
|
|
name: '{{ full_key }}',
|
|
pluginId: '{{ plugin_id }}'
|
|
});
|
|
} catch (e) {
|
|
console.error('[style-editor] render failed', e);
|
|
container.remove();
|
|
return;
|
|
}
|
|
// The widget owns these fields now; drop the generic
|
|
// rendering so the form does not post both.
|
|
if (fallback) { fallback.remove(); }
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% elif prop.properties %}
|
|
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
|
{% endif %}
|
|
{% else %}
|
|
<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 }}{{ ui.help_tip(description, label) }}
|
|
</label>
|
|
|
|
{# Boolean - check for widget first #}
|
|
{% if field_type == 'boolean' %}
|
|
{% set bool_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% if bool_widget == 'toggle-switch' %}
|
|
{# Render toggle-switch widget #}
|
|
<div id="{{ field_id }}_container" class="toggle-switch-container"></div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('toggle-switch');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ value|tojson|safe if value is not none else 'false' }};
|
|
var config = {
|
|
'type': 'boolean',
|
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
|
|
};
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% else %}
|
|
{# Default checkbox - value="true" ensures checked sends "true" not "on" #}
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="true"
|
|
{% 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>
|
|
{% endif %}
|
|
|
|
{# Enum dropdown. Option text comes from x-options.labels when the
|
|
schema supplies it -- the same convention the checkbox-group
|
|
widget already uses -- because humanising the raw value cannot
|
|
express every label: "vs" reads as "Vs", and "abbrev" says
|
|
nothing about the "Sep 19" it produces. Values without a label
|
|
fall back to the humanised form, so existing schemas render
|
|
exactly as before. #}
|
|
{% elif prop.enum %}
|
|
{% set enum_labels = (prop.get('x-options') or prop.get('x_options') or {}).get('labels') or {} %}
|
|
<select id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
class="form-select w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black">
|
|
{% for option in prop.enum %}
|
|
<option value="{{ option }}" {% if value == option %}selected{% endif %}>
|
|
{{ enum_labels.get(option, option|replace('_', ' ')|title) }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
|
|
{# Number input - check for widget first #}
|
|
{% elif field_type in ['number', 'integer'] %}
|
|
{% set num_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% if num_widget in ['slider', 'number-input'] %}
|
|
{# Render slider or number-input widget #}
|
|
<div id="{{ field_id }}_container" class="{{ num_widget }}-container"></div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('{{ num_widget }}');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ value|tojson if value is not none else (prop.default|tojson if prop.default is defined else 'null') }};
|
|
var config = {
|
|
'type': '{{ field_type }}',
|
|
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
|
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }}
|
|
};
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% else %}
|
|
{# Default number input #}
|
|
<input type="number"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ value if value is not none else (prop.default if prop.default is defined else '') }}"
|
|
{% if prop.minimum is defined %}min="{{ prop.minimum }}"{% endif %}
|
|
{% if prop.maximum is defined %}max="{{ prop.maximum }}"{% endif %}
|
|
{% if field_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
{% endif %}
|
|
|
|
{# Array - check for file upload widget first (to avoid breaking static-image plugin), then checkbox-group, then array of objects #}
|
|
{% elif field_type == 'array' %}
|
|
{% set x_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% if x_widget == 'file-upload' %}
|
|
{# File upload widget for arrays #}
|
|
{% set upload_config = prop.get('x-upload-config') or {} %}
|
|
{% set max_files = upload_config.get('max_files', 10) %}
|
|
{% set allowed_types = upload_config.get('allowed_types', ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']) %}
|
|
{% set max_size_mb = upload_config.get('max_size_mb', 5) %}
|
|
{% set plugin_id_from_config = upload_config.get('plugin_id', plugin_id) %}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
|
|
<div id="{{ field_id }}_upload_widget" class="mt-1">
|
|
<!-- File Upload Drop Zone -->
|
|
<div id="{{ field_id }}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="{{ field_id }}"
|
|
data-plugin-id="{{ plugin_id_from_config }}"
|
|
data-upload-endpoint="{{ upload_config.get('endpoint', '/api/v3/plugins/assets/upload') }}"
|
|
data-file-type="{{ upload_config.get('file_type', 'image') }}"
|
|
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()">
|
|
<input type="file"
|
|
id="{{ field_id }}_file_input"
|
|
multiple
|
|
accept="{{ allowed_types|join(',') }}"
|
|
style="display: none;"
|
|
data-field-id="{{ field_id }}"
|
|
data-plugin-id="{{ plugin_id_from_config }}"
|
|
data-upload-endpoint="{{ upload_config.get('endpoint', '/api/v3/plugins/assets/upload') }}"
|
|
data-file-type="{{ upload_config.get('file_type', 'image') }}"
|
|
data-multiple="true"
|
|
data-max-files="{{ max_files }}"
|
|
data-max-size-mb="{{ max_size_mb }}"
|
|
data-allowed-types="{{ allowed_types|join(',') }}"
|
|
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max {{ max_files }} files, {{ max_size_mb }}MB each (PNG, JPG, GIF, BMP)</p>
|
|
</div>
|
|
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
|
<i class="fas fa-info-circle mr-1"></i>
|
|
Remember to save configuration after upload
|
|
</p>
|
|
|
|
<!-- Uploaded Images List -->
|
|
<div id="{{ field_id }}_image_list" class="mt-4 space-y-2">
|
|
{% for img in array_value %}
|
|
{% set img_id = img.get('id', loop.index0) %}
|
|
{% set img_schedule = img.get('schedule', {}) %}
|
|
{% set has_schedule = img_schedule.get('enabled', false) and img_schedule.get('mode') and img_schedule.get('mode') != 'always' %}
|
|
<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', '') }}"
|
|
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">
|
|
<i class="fas fa-image text-gray-400"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate">{{ img.get('original_filename') or img.get('filename', 'Image') }}</p>
|
|
<p class="text-xs text-gray-500">
|
|
{% if img.get('size') %}{{ (img.get('size') / 1024)|round }} KB{% endif %}
|
|
{% if img.get('uploaded_at') %} • {{ img.get('uploaded_at') }}{% endif %}
|
|
</p>
|
|
{% if has_schedule %}
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>Scheduled
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
<button type="button"
|
|
data-field-id="{{ field_id }}"
|
|
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>
|
|
</button>
|
|
<button type="button"
|
|
data-field-id="{{ field_id }}"
|
|
data-image-id="{{ img_id }}"
|
|
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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="schedule_{{ img_id|string|replace('.', '_')|replace('-', '_') }}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<!-- Hidden input to store image data -->
|
|
<input type="hidden" id="{{ field_id }}_images_data" name="{{ full_key }}" value='{{ (array_value|tojson|safe)|replace("'", "'") }}'>
|
|
</div>
|
|
{% elif x_widget == 'checkbox-group' %}
|
|
{# Checkbox group widget for multi-select arrays with enum items #}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
{% set items_schema = prop.get('items') or {} %}
|
|
{% set enum_items = items_schema.get('enum') or [] %}
|
|
{% set x_options = prop.get('x-options') or {} %}
|
|
{% set labels = x_options.get('labels') or {} %}
|
|
{# A saved value that is no longer one of the options -- a team
|
|
code the league retired, an option dropped from the schema --
|
|
has no checkbox to render, so it would sit unseen in the
|
|
hidden input below and be posted back on save. The schema
|
|
rejects it and the save endpoint returns 400, which blocks
|
|
editing any other field on the plugin until the stale entry
|
|
is found and removed. Drop them here instead, and say which,
|
|
so the value is not lost silently. Only when the widget
|
|
actually has options: an empty enum means nothing to check
|
|
against, and filtering on it would wipe the field. #}
|
|
{% set stale_values = (array_value | reject('in', enum_items) | list) if enum_items else [] %}
|
|
{% set array_value = (array_value | select('in', enum_items) | list) if enum_items else array_value %}
|
|
|
|
{% if stale_values %}
|
|
<div class="mt-1 mb-2 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
|
data-stale-options="{{ field_id }}">
|
|
No longer offered, and will be removed when you save:
|
|
<span class="font-mono">{{ stale_values | join(', ') }}</span>.
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="mt-1 space-y-2">
|
|
{% for option in enum_items %}
|
|
{% set is_checked = option in array_value %}
|
|
{% set option_label = labels.get(option, option|replace('_', ' ')|title) %}
|
|
{% set checkbox_id = (field_id ~ '_' ~ option)|replace('.', '_')|replace(' ', '_') %}
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
id="{{ checkbox_id }}"
|
|
name="{{ full_key }}[]"
|
|
data-checkbox-group="{{ field_id }}"
|
|
data-option-value="{{ option }}"
|
|
value="{{ option }}"
|
|
{% if is_checked %}checked{% endif %}
|
|
onchange="updateCheckboxGroupData('{{ field_id }}')"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm text-gray-700">{{ option_label }}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
{# Hidden input to store selected values as JSON array (like array-of-objects pattern) #}
|
|
<input type="hidden" id="{{ field_id }}_data" name="{{ full_key }}_data" value='{{ (array_value|tojson|safe)|replace("'", "'") }}'>
|
|
{# Sentinel hidden input with bracket notation to allow clearing array to [] when all unchecked #}
|
|
{# This ensures the field is always submitted, even when all checkboxes are unchecked #}
|
|
<input type="hidden" name="{{ full_key }}[]" value="">
|
|
{% elif x_widget == 'google-calendar-picker' %}
|
|
{# Google Calendar picker — dynamically loads calendars from the API #}
|
|
{# Normalise: if value is a string (legacy comma-separated), split it; otherwise fall back to default or [] #}
|
|
{% if value is not none and value is string and value %}
|
|
{% set array_value = value.split(',') | map('trim') | list %}
|
|
{% elif value is not none and value is iterable and value is not string %}
|
|
{% set array_value = value %}
|
|
{% elif prop.default is defined and prop.default is string and prop.default %}
|
|
{% set array_value = prop.default.split(',') | map('trim') | list %}
|
|
{% elif prop.default is defined and prop.default is iterable and prop.default is not string %}
|
|
{% set array_value = prop.default %}
|
|
{% else %}
|
|
{% set array_value = [] %}
|
|
{% endif %}
|
|
<div id="{{ field_id }}_container" class="google-calendar-picker-container mt-1"></div>
|
|
<script>
|
|
(function() {
|
|
var MAX_RETRIES = 40;
|
|
var retries = 0;
|
|
function initWidget() {
|
|
if (retries >= MAX_RETRIES) { return; }
|
|
retries++;
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('google-calendar-picker');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ array_value|tojson|safe }};
|
|
widget.render(container, {}, value, { fieldId: '{{ field_id }}', pluginId: '{{ plugin_id }}', name: '{{ full_key }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% elif x_widget == 'day-selector' %}
|
|
{# Day selector widget for selecting days of the week #}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
<div id="{{ field_id }}_container" class="day-selector-container mt-1"></div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('day-selector');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ array_value|tojson|safe }};
|
|
var config = { 'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }} };
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', pluginId: '{{ plugin_id }}', name: '{{ full_key }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% else %}
|
|
{# Check for custom-feeds widget first #}
|
|
{% set items_schema = prop.get('items') or {} %}
|
|
{% if x_widget == 'custom-feeds' %}
|
|
{# Custom feeds table interface - widget-specific implementation #}
|
|
{# Validate that required fields exist in schema #}
|
|
{% set item_properties = items_schema.get('properties', {}) %}
|
|
{% if not (item_properties.get('name') and item_properties.get('url')) %}
|
|
{# Fallback to generic if schema doesn't match expected structure #}
|
|
<p class="text-xs text-amber-600 mt-1">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Custom feeds widget requires 'name' and 'url' properties in items schema.
|
|
</p>
|
|
{% else %}
|
|
{% set max_items = prop.get('maxItems', 50) %}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
|
|
<div class="custom-feeds-table-container mt-1">
|
|
<table class="min-w-full divide-y divide-gray-200 border border-gray-300 rounded-lg">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">URL</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Logo</th>
|
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Enabled</th>
|
|
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
|
{% for item in array_value %}
|
|
{% set item_index = loop.index0 %}
|
|
<tr class="custom-feed-row" data-index="{{ item_index }}">
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<input type="text"
|
|
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"
|
|
placeholder="Feed Name"
|
|
required>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<input type="url"
|
|
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"
|
|
placeholder="https://example.com/feed"
|
|
required>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
{% set logo_value = item.get('logo') or {} %}
|
|
{% set logo_path = logo_value.get('path', '') %}
|
|
<div class="flex items-center space-x-2">
|
|
<input type="file"
|
|
id="{{ field_id }}_logo_{{ item_index }}"
|
|
accept="image/png,image/jpeg,image/bmp,image/gif"
|
|
style="display: none;"
|
|
onchange="handleCustomFeedLogoUpload(event, '{{ field_id }}', {{ item_index }}, '{{ plugin_id }}', '{{ full_key }}')">
|
|
<button type="button"
|
|
onclick="document.getElementById('{{ field_id }}_logo_{{ item_index }}').click()"
|
|
class="px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded">
|
|
<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 }}">
|
|
<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 %}
|
|
<span class="text-xs text-gray-400">No logo</span>
|
|
{% endif %}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-center">
|
|
<input type="hidden" name="{{ full_key }}.{{ item_index }}.enabled" value="{{ 'true' if item.get('enabled', true) else 'false' }}">
|
|
<input type="checkbox"
|
|
name="{{ full_key }}.{{ item_index }}.enabled"
|
|
{% if item.get('enabled', true) %}checked{% endif %}
|
|
value="true"
|
|
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
|
|
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)"
|
|
class="text-red-600 hover:text-red-800 px-2 py-1">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<button type="button"
|
|
onclick="addCustomFeedRow('{{ field_id }}', '{{ full_key }}', {{ max_items }}, '{{ plugin_id }}')"
|
|
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
|
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
|
<i class="fas fa-plus mr-1"></i> Add Feed
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
{% elif x_widget == 'array-table' %}
|
|
{# Generic array-of-objects table widget - reads columns from schema #}
|
|
{% set item_properties = items_schema.get('properties', {}) %}
|
|
{% set max_items = prop.get('maxItems', 50) %}
|
|
{% set array_value = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else []) %}
|
|
|
|
{# Use x-columns if specified, otherwise auto-detect first 4 simple properties #}
|
|
{% set x_columns = prop.get('x-columns') %}
|
|
{% if x_columns %}
|
|
{% set display_columns = x_columns %}
|
|
{% else %}
|
|
{% set display_columns = [] %}
|
|
{% for col_name in item_properties.keys() %}
|
|
{% set col_def = item_properties[col_name] %}
|
|
{% if col_def.get('type') not in ['object', 'array'] and display_columns|length < 4 %}
|
|
{% set _ = display_columns.append(col_name) %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
<div class="array-table-container mt-1" data-field-id="{{ field_id }}" data-full-key="{{ full_key }}" data-max-items="{{ max_items }}" data-plugin-id="{{ plugin_id }}">
|
|
<div style="overflow-x:auto">
|
|
<table class="divide-y divide-gray-200 border border-gray-300 rounded-lg" style="min-width:max-content;width:100%">
|
|
<thead class="bg-gray-50">
|
|
<tr>
|
|
{% for col_name in display_columns %}
|
|
{% set col_def = item_properties.get(col_name, {}) %}
|
|
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
|
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
|
{% set col_enum = col_def.get('enum', []) %}
|
|
{% set _raw_ctype = col_def.get('type', 'string') %}
|
|
{% if _raw_ctype is iterable and _raw_ctype is not string %}
|
|
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
|
|
{% else %}
|
|
{% set col_ctype = _raw_ctype or 'string' %}
|
|
{% endif %}
|
|
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
|
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
|
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
|
{% elif col_enum %}{% set col_min_w = '90px' %}
|
|
{% elif col_ctype == 'boolean' %}{% set col_min_w = '60px' %}
|
|
{% elif col_ctype in ['integer', 'number'] %}{% set col_min_w = '80px' %}
|
|
{% else %}{% set col_min_w = '110px' %}{% endif %}
|
|
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:{{ col_min_w }}">{{ col_title }}</th>
|
|
{% endfor %}
|
|
<th class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider" style="min-width:90px">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="{{ field_id }}_tbody" class="bg-white divide-y divide-gray-200">
|
|
{% for item in array_value %}
|
|
{% set item_index = loop.index0 %}
|
|
<tr class="array-table-row" data-index="{{ item_index }}">
|
|
{% for col_name in display_columns %}
|
|
{% set col_def = item_properties.get(col_name, {}) %}
|
|
{# 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 %}
|
|
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
|
|
{% else %}
|
|
{% set col_type = _raw_type or 'string' %}
|
|
{% endif %}
|
|
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
|
{% set col_enum = col_def.get('enum', []) %}
|
|
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
|
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
|
{% elif col_xwidget == 'time-picker' %}{% set td_min_w = '115px' %}
|
|
{% elif col_xwidget == 'file-upload-single' %}{% set td_min_w = '200px' %}
|
|
{% elif col_enum %}{% set td_min_w = '90px' %}
|
|
{% elif col_type == 'boolean' %}{% set td_min_w = '60px' %}
|
|
{% elif col_type in ['integer', 'number'] %}{% set td_min_w = '80px' %}
|
|
{% else %}{% set td_min_w = '110px' %}{% endif %}
|
|
<td class="px-3 py-3 whitespace-nowrap" style="min-width:{{ td_min_w }};vertical-align:middle">
|
|
{% if col_type == 'boolean' %}
|
|
{# Hidden sentinel ensures unchecked boxes still submit "false" (browsers
|
|
omit unchecked checkboxes entirely). It shares the checkbox's `name`,
|
|
so it must always mirror the checkbox's actual state — both here at
|
|
render time and on every toggle — or whichever of the two same-named
|
|
inputs the save request happens to prefer can silently revert this
|
|
field to false regardless of what's checked. #}
|
|
<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 }}"
|
|
{% if col_value %}checked{% endif %}
|
|
value="true"
|
|
onchange="this.previousElementSibling.value = this.checked ? 'true' : 'false'"
|
|
class="h-4 w-4 text-blue-600">
|
|
{% elif col_type == 'integer' or col_type == 'number' %}
|
|
<input type="number"
|
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
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 %}
|
|
{% if col_type == 'integer' %}step="1"{% else %}step="any"{% endif %}
|
|
class="block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
{% if col_def.get('description') %}title="{{ col_def.get('description') }}"{% endif %}>
|
|
{% elif col_enum %}
|
|
{# Labels are opt-in here and the fallback stays the raw
|
|
value: table columns hold things like ticker symbols,
|
|
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 }}"
|
|
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>
|
|
{% endif %}{% endfor %}
|
|
</select>
|
|
{% elif col_xwidget == 'date-picker' %}
|
|
<input type="date"
|
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
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 }}"
|
|
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 %}
|
|
<input type="text"
|
|
id="{{ cell_input_id }}"
|
|
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>
|
|
<input type="file"
|
|
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)">
|
|
</label>
|
|
</div>
|
|
{% else %}
|
|
<input type="text"
|
|
name="{{ full_key }}.{{ item_index }}.{{ col_name }}"
|
|
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 %}
|
|
{% if col_def.get('pattern') %}pattern="{{ col_def.get('pattern') }}"{% endif %}
|
|
{% if col_def.get('minLength') %}minlength="{{ col_def.get('minLength') }}"{% endif %}
|
|
{% if col_def.get('maxLength') %}maxlength="{{ col_def.get('maxLength') }}"{% endif %}>
|
|
{% endif %}
|
|
</td>
|
|
{% endfor %}
|
|
|
|
{# Actions cell: delete + optional edit button for advanced props #}
|
|
{% set has_advanced = namespace(value=false) %}
|
|
{% for k in item_properties.keys() %}{% if k not in display_columns and k != 'id' %}{% set has_advanced.value = true %}{% endif %}{% endfor %}
|
|
<td class="px-3 py-3 whitespace-nowrap text-center" style="min-width:90px;vertical-align:middle">
|
|
<button type="button"
|
|
onclick="removeArrayTableRow(this)"
|
|
class="text-red-600 hover:text-red-800 px-2 py-1">
|
|
<i class="fas fa-trash"></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"
|
|
title="Edit layout, style and other advanced properties">
|
|
<i class="fas fa-sliders-h"></i>
|
|
</button>
|
|
{% endif %}
|
|
</td>
|
|
|
|
{# Hidden cell: flat hidden inputs for non-displayed props (layout, style, etc.) #}
|
|
{% if has_advanced.value %}
|
|
{% set adv_schema = namespace(d={}) %}
|
|
{% for k, v in item_properties.items() %}{% if k not in display_columns and k != 'id' %}{% set _ = adv_schema.d.update({k: v}) %}{% endif %}{% endfor %}
|
|
<td style="display:none" class="array-table-advanced-data"
|
|
data-prop-schema='{{ adv_schema.d|tojson }}'>
|
|
{% for prop_name, prop_schema in adv_schema.d.items() %}
|
|
{% set prop_type = prop_schema.get('type', 'string') %}
|
|
{% if prop_type == 'object' and prop_schema.get('properties') %}
|
|
{% for sub_name, sub_schema in prop_schema.get('properties', {}).items() %}
|
|
{% set sub_val = item.get(prop_name, {}).get(sub_name) %}
|
|
{% set sub_default = sub_schema.get('default') %}
|
|
{% set final_val = sub_val if sub_val is not none else sub_default %}
|
|
<input type="hidden"
|
|
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}.{{ sub_name }}"
|
|
data-nested-prop="{{ prop_name }}.{{ sub_name }}"
|
|
data-prop-type="{{ sub_schema.get('type', 'string') }}"
|
|
data-prop-schema='{{ sub_schema|tojson }}'
|
|
value="{{ final_val if final_val is not none else '' }}">
|
|
{% endfor %}
|
|
{% else %}
|
|
{% set prop_val = item.get(prop_name) %}
|
|
{% set prop_default = prop_schema.get('default') %}
|
|
{% set final_val = prop_val if prop_val is not none else prop_default %}
|
|
<input type="hidden"
|
|
name="{{ full_key }}.{{ item_index }}.{{ prop_name }}"
|
|
data-nested-prop="{{ prop_name }}"
|
|
data-prop-type="{{ prop_schema.get('type', 'string') }}"
|
|
data-prop-schema='{{ prop_schema|tojson }}'
|
|
value="{{ final_val if final_val is not none else '' }}">
|
|
{% endif %}
|
|
{% endfor %}
|
|
</td>
|
|
{% endif %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<button type="button"
|
|
onclick="addArrayTableRow(this)"
|
|
data-field-id="{{ field_id }}"
|
|
data-full-key="{{ full_key }}"
|
|
data-max-items="{{ max_items }}"
|
|
data-plugin-id="{{ plugin_id }}"
|
|
data-item-properties='{% set ns = namespace(d={}) %}{% for k in display_columns %}{% if k in item_properties %}{% set _ = ns.d.update({k: item_properties[k]}) %}{% endif %}{% endfor %}{{ ns.d|tojson }}'
|
|
data-full-item-properties='{{ item_properties|tojson }}'
|
|
data-display-columns='{{ display_columns|tojson }}'
|
|
class="mt-3 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md"
|
|
{% if array_value|length >= max_items %}disabled style="opacity: 0.5;"{% endif %}>
|
|
<i class="fas fa-plus mr-1"></i> Add Item
|
|
</button>
|
|
</div>{# end overflow-x:auto wrapper #}
|
|
</div>
|
|
{% elif x_widget == 'color-picker' %}
|
|
{# RGB color array: R / G / B number inputs + visual swatch + sync'd hex picker #}
|
|
{% set color_arr = value if value is not none and value is iterable and value is not string else (prop.default if prop.default is defined and prop.default is iterable and prop.default is not string else [255, 255, 255]) %}
|
|
{% set r_val = color_arr[0] if color_arr|length > 0 else 255 %}
|
|
{% set g_val = color_arr[1] if color_arr|length > 1 else 255 %}
|
|
{% set b_val = color_arr[2] if color_arr|length > 2 else 255 %}
|
|
{% set hex_val = '#%02x%02x%02x' % (r_val|int, g_val|int, b_val|int) %}
|
|
<div class="flex items-center gap-3 flex-wrap mt-1" id="{{ field_id }}_color_row">
|
|
<input type="color"
|
|
id="{{ field_id }}_hex"
|
|
value="{{ hex_val }}"
|
|
class="h-9 w-12 cursor-pointer rounded border border-gray-300"
|
|
title="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>
|
|
<input type="number" min="0" max="255" step="1"
|
|
id="{{ field_id }}_r"
|
|
name="{{ full_key }}.0"
|
|
value="{{ r_val }}"
|
|
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
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>
|
|
<input type="number" min="0" max="255" step="1"
|
|
id="{{ field_id }}_g"
|
|
name="{{ full_key }}.1"
|
|
value="{{ g_val }}"
|
|
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
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>
|
|
<input type="number" min="0" max="255" step="1"
|
|
id="{{ field_id }}_b"
|
|
name="{{ full_key }}.2"
|
|
value="{{ b_val }}"
|
|
class="w-16 px-2 py-1 border border-gray-300 rounded text-sm text-center"
|
|
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="w-8 h-8 rounded border border-gray-300 flex-shrink-0"
|
|
style="background-color: rgb({{ r_val }}, {{ g_val }}, {{ b_val }})"
|
|
title="Color preview"></div>
|
|
</div>
|
|
{% else %}
|
|
{# Generic array-of-objects would go here if needed in the future #}
|
|
{# For now, fall back to regular array input (comma-separated) #}
|
|
{# Regular array input (comma-separated) #}
|
|
{% set array_value = value if value is not none else (prop.default if prop.default is defined else []) %}
|
|
<input type="text"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ array_value|join(', ') if array_value is iterable and array_value is not string else '' }}"
|
|
placeholder="Enter values separated by commas"
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-xs text-gray-400 mt-1">Separate multiple values with commas</p>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{# String/default field - check for widgets #}
|
|
{% else %}
|
|
{% set str_widget = prop.get('x-widget') or prop.get('x_widget') %}
|
|
{% set str_value = value if value is not none else (prop.default if prop.default is defined else '') %}
|
|
{% if str_widget == 'file-upload' %}
|
|
{# Single-file upload widget for string fields (e.g., credentials.json) #}
|
|
{% set upload_config = prop.get('x-upload-config') or {} %}
|
|
{% set upload_endpoint = upload_config.get('upload_endpoint', '') %}
|
|
{% set target_filename = upload_config.get('target_filename', 'file.json') %}
|
|
{% set max_size_mb = upload_config.get('max_size_mb', 1) %}
|
|
{% set allowed_extensions = upload_config.get('allowed_extensions', ['.json']) %}
|
|
<div id="{{ field_id }}_upload_widget" class="mt-1">
|
|
<div id="{{ field_id }}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label="Upload {{ target_filename }}"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="{{ field_id }}"
|
|
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"
|
|
accept="{{ allowed_extensions|join(',') }}"
|
|
style="display: none;"
|
|
data-field-id="{{ field_id }}"
|
|
data-upload-endpoint="{{ upload_endpoint }}"
|
|
data-target-filename="{{ target_filename }}"
|
|
data-max-size-mb="{{ max_size_mb }}"
|
|
data-allowed-extensions="{{ allowed_extensions|join(',') }}"
|
|
onchange="window.handleSingleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-2xl text-gray-400 mb-1"></i>
|
|
<p class="text-sm text-gray-600">Click to upload {{ target_filename }}</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max {{ max_size_mb }}MB ({{ allowed_extensions|join(', ') }})</p>
|
|
</div>
|
|
<div id="{{ field_id }}_upload_status" class="mt-2 text-xs text-gray-500" aria-live="polite"></div>
|
|
<input type="hidden"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ str_value }}">
|
|
</div>
|
|
{% elif str_widget == 'json-file-manager' %}
|
|
{# Embedded file manager — plugin's web_ui/file_manager.html served via /v3/plugin-ui/ route #}
|
|
<div class="mt-1 rounded-lg border border-gray-200 overflow-hidden">
|
|
<iframe id="{{ field_id }}_frame"
|
|
src="/v3/plugin-ui/{{ plugin_id }}/web-ui/file_manager.html"
|
|
style="width:100%;height:640px;border:none;"
|
|
title="File Manager for {{ plugin_id }}"></iframe>
|
|
</div>
|
|
<p class="text-xs text-amber-600 mt-2 flex items-center">
|
|
<i class="fas fa-info-circle mr-1"></i>
|
|
Changes in the file manager save immediately — no need to click Save Configuration.
|
|
</p>
|
|
{% elif str_widget in ['text-input', 'textarea', 'select-dropdown', 'toggle-switch', 'radio-group', 'date-picker', 'time-picker', 'slider', 'color-picker', 'email-input', 'url-input', 'password-input', 'font-selector', 'file-upload-single', 'plugin-file-manager', 'google-oauth'] %}
|
|
{# Render widget container #}
|
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
|
<script>
|
|
(function() {
|
|
function initWidget() {
|
|
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
|
var widget = window.LEDMatrixWidgets.get('{{ str_widget }}');
|
|
if (!widget) { setTimeout(initWidget, 50); return; }
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
if (!container) return;
|
|
var value = {{ str_value|tojson|safe }};
|
|
var config = {
|
|
'type': '{{ field_type }}',
|
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
|
'minimum': {{ prop.minimum|tojson if prop.minimum is defined else 'null' }},
|
|
'maximum': {{ prop.maximum|tojson if prop.maximum is defined else 'null' }},
|
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
|
'x-upload-config': {{ (prop.get('x-upload-config') or prop.get('x_upload_config') or {})|tojson|safe }},
|
|
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
|
};
|
|
widget.render(container, config, value, { fieldId: '{{ field_id }}', name: '{{ full_key }}', pluginId: '{{ plugin_id }}' });
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initWidget);
|
|
} else {
|
|
setTimeout(initWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% else %}
|
|
{# Default text input #}
|
|
<input type="text"
|
|
id="{{ field_id }}"
|
|
name="{{ full_key }}"
|
|
value="{{ str_value }}"
|
|
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
|
{% if str_widget %}
|
|
{# An x-widget the core does not ship may be supplied by the
|
|
plugin itself (manifest "widgets", served from its widgets/
|
|
directory). Ask the loader for it; the text input above is
|
|
the fallback and stays put unless the widget really renders,
|
|
so a missing or broken widget degrades to an editable field
|
|
rather than dropping the value on save. #}
|
|
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
|
<script>
|
|
(function() {
|
|
var WIDGET = {{ str_widget|tojson|safe }};
|
|
var PLUGIN = {{ plugin_id|tojson|safe }};
|
|
function initPluginWidget() {
|
|
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
|
|
setTimeout(initPluginWidget, 50);
|
|
return;
|
|
}
|
|
var container = document.getElementById('{{ field_id }}_container');
|
|
var fallback = document.getElementById('{{ field_id }}');
|
|
if (!container) { return; }
|
|
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
|
|
if (!available) { container.remove(); return; }
|
|
var widget = window.LEDMatrixWidgets.get(WIDGET);
|
|
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
|
|
// Read the fallback's live value rather than the
|
|
// server-rendered one, so an edit typed while the
|
|
// widget script was still loading isn't lost.
|
|
var value = fallback ? fallback.value : {{ str_value|tojson|safe }};
|
|
var config = {
|
|
'type': '{{ field_type }}',
|
|
'enum': {{ (prop.enum or [])|tojson|safe }},
|
|
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
|
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
|
};
|
|
try {
|
|
widget.render(container, config, value, {
|
|
fieldId: '{{ field_id }}',
|
|
name: '{{ full_key }}',
|
|
pluginId: PLUGIN
|
|
});
|
|
} catch (e) {
|
|
console.error('[PluginWidget] render failed for ' + WIDGET, e);
|
|
container.remove();
|
|
return;
|
|
}
|
|
// Only now is the widget authoritative for this
|
|
// field; drop the fallback so the form posts one
|
|
// value rather than two under the same name.
|
|
if (fallback) { fallback.remove(); }
|
|
}).catch(function(e) {
|
|
console.warn('[PluginWidget] could not load ' + WIDGET, e);
|
|
container.remove();
|
|
});
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initPluginWidget);
|
|
} else {
|
|
setTimeout(initPluginWidget, 50);
|
|
}
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endmacro %}
|
|
|
|
{# Render a nested/collapsible section for object types #}
|
|
{% macro render_nested_section(key, prop, value, prefix='', plugin_id='') %}
|
|
{% set full_key = (prefix ~ '.' ~ key) if prefix else key %}
|
|
{% set section_id = (plugin_id ~ '-section-' ~ full_key)|replace('.', '-')|replace('_', '-') %}
|
|
{% set label = prop.title if prop.title else key|replace('_', ' ')|title %}
|
|
{% set description = prop.description if prop.description else '' %}
|
|
{% set nested_value = value if value else {} %}
|
|
|
|
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
|
onclick="toggleSection('{{ section_id }}')">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">{{ label }}</h4>
|
|
{% if description %}
|
|
<p class="text-sm text-gray-600 mt-1">{{ description }}</p>
|
|
{% endif %}
|
|
</div>
|
|
<i id="{{ section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="{{ section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
|
|
{% set property_order = prop['x-propertyOrder'] if 'x-propertyOrder' in prop else prop.properties.keys()|list %}
|
|
{% for nested_key in property_order %}
|
|
{% if nested_key in prop.properties %}
|
|
{% set nested_prop = prop.properties[nested_key] %}
|
|
{% set nested_val = nested_value[nested_key] if nested_key in nested_value else none %}
|
|
{{ render_field(nested_key, nested_prop, nested_val, full_key, plugin_id) }}
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
{# ===== MAIN TEMPLATE ===== #}
|
|
|
|
<div class="plugin-config-container"
|
|
data-plugin-id="{{ plugin.id }}"
|
|
x-data="{ saving: false, saveError: null, saveSuccess: false }">
|
|
|
|
<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">{{ plugin.name or plugin.id }}</h2>
|
|
<p class="mt-1 text-sm text-gray-600">{{ plugin.description or 'Plugin configuration' }}</p>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<button type="button"
|
|
onclick="window.previewPluginNow('{{ plugin.id }}')"
|
|
class="btn bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 text-sm rounded-md"
|
|
title="Run this plugin on the display for 60 seconds and open the live preview">
|
|
<i class="fas fa-play mr-1"></i>Preview on display
|
|
</button>
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
id="plugin-enabled-{{ plugin.id }}"
|
|
name="enabled"
|
|
value="true"
|
|
{% if plugin.enabled %}checked{% endif %}
|
|
hx-post="/api/v3/plugins/toggle?plugin_id={{ plugin.id }}"
|
|
hx-trigger="change"
|
|
hx-swap="none"
|
|
hx-vals='js:{enabled: document.getElementById("plugin-enabled-{{ plugin.id }}").checked ? "true" : "false"}'
|
|
hx-on::after-request="handleToggleResponse(event, '{{ plugin.id }}')"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm {% if plugin.enabled %}text-green-600{% else %}text-gray-500{% endif %}">
|
|
{% if plugin.enabled %}Enabled{% else %}Disabled{% endif %}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="plugin-config-form-{{ plugin.id }}"
|
|
hx-post="/api/v3/plugins/config?plugin_id={{ plugin.id }}"
|
|
hx-trigger="submit"
|
|
hx-swap="none"
|
|
hx-indicator="#save-indicator-{{ plugin.id }}"
|
|
hx-on::before-request="this.querySelector('[type=submit]').disabled = true"
|
|
hx-on::after-request="handleConfigSave(event, '{{ plugin.id }}')"
|
|
onsubmit="return validatePluginConfigForm(this, '{{ plugin.id }}');">
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{# Plugin Information Panel #}
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Information</h3>
|
|
<dl class="space-y-2">
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Name</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.name or plugin.id }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Author</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.author or 'Unknown' }}</dd>
|
|
</div>
|
|
{% if plugin.version %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Version</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.version }}</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.last_commit %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Commit</dt>
|
|
<dd class="text-sm text-gray-900 font-mono">
|
|
{{ plugin.last_commit[:7] if plugin.last_commit|length > 7 else plugin.last_commit }}
|
|
{% if plugin.branch %}
|
|
<span class="text-gray-500">({{ plugin.branch }})</span>
|
|
{% endif %}
|
|
</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.category %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Category</dt>
|
|
<dd class="text-sm text-gray-900">{{ plugin.category }}</dd>
|
|
</div>
|
|
{% endif %}
|
|
{% if plugin.tags %}
|
|
<div>
|
|
<dt class="text-sm font-medium text-gray-500">Tags</dt>
|
|
<dd class="flex flex-wrap gap-1 mt-1">
|
|
{% for tag in plugin.tags %}
|
|
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">{{ tag }}</span>
|
|
{% endfor %}
|
|
</dd>
|
|
</div>
|
|
{% endif %}
|
|
</dl>
|
|
|
|
{# On-Demand Controls #}
|
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fas fa-bolt text-blue-500"></i>
|
|
<span class="text-sm font-semibold text-gray-900">On-Demand Controls</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button"
|
|
onclick="runPluginOnDemand('{{ plugin.id }}')"
|
|
class="px-3 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md flex items-center gap-2 transition-colors">
|
|
<i class="fas fa-play-circle"></i>
|
|
<span>Run On-Demand</span>
|
|
</button>
|
|
<button type="button"
|
|
onclick="stopOnDemand()"
|
|
class="px-3 py-2 text-sm bg-red-600 hover:bg-red-700 text-white rounded-md flex items-center gap-2 transition-colors">
|
|
<i class="fas fa-stop"></i>
|
|
<span>Stop On-Demand</span>
|
|
</button>
|
|
</div>
|
|
{% if not plugin.enabled %}
|
|
<p class="text-xs text-amber-600">Plugin is disabled, but on-demand will temporarily enable it.</p>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# Configuration Form Panel #}
|
|
<div class="bg-gray-50 rounded-lg p-4">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Configuration</h3>
|
|
{{ ui.settings_filter("Filter this plugin's settings…") }}
|
|
<div class="space-y-4 max-h-96 overflow-y-auto pr-2">
|
|
{% if schema and schema.properties %}
|
|
{# Use property order if defined, otherwise use natural order #}
|
|
{# Skip 'enabled' field - it's handled by the header toggle #}
|
|
{% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %}
|
|
{# Flat (non-object) properties flagged "x-advanced": true are
|
|
grouped into one collapsed "Advanced Settings" section after
|
|
the basic fields. Object-type properties already render as
|
|
their own collapsible sections, so the flag is ignored for
|
|
them. Schemas without the flag render exactly as before. #}
|
|
{% set tiers = namespace(basic=[], advanced=[]) %}
|
|
{% for key in property_order %}
|
|
{% if key in schema.properties and key != 'enabled' %}
|
|
{% set prop = schema.properties[key] %}
|
|
{% set is_object = prop.type is defined and 'object' in prop.type %}
|
|
{% if prop.get('x-advanced') and not is_object %}
|
|
{% set tiers.advanced = tiers.advanced + [key] %}
|
|
{% else %}
|
|
{% set tiers.basic = tiers.basic + [key] %}
|
|
{% endif %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{# Tell the save path which sections this form actually
|
|
drew. An unchecked checkbox posts nothing, so without
|
|
this the server cannot tell "the user cleared it"
|
|
from "the caller never had that field" -- and a
|
|
partial post would read as every box being off. #}
|
|
{% for key in tiers.basic + tiers.advanced %}
|
|
<input type="hidden" name="__rendered_section" value="{{ key }}">
|
|
{% endfor %}
|
|
{% for key in tiers.basic %}
|
|
{% set prop = schema.properties[key] %}
|
|
{% set value = config[key] if key in config else none %}
|
|
{{ render_field(key, prop, value, '', plugin.id) }}
|
|
{% endfor %}
|
|
{% if tiers.advanced %}
|
|
{% set adv_section_id = (plugin.id ~ '-section-advanced-settings')|replace('.', '-')|replace('_', '-') %}
|
|
<div class="nested-section border border-gray-300 rounded-lg mb-4">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors rounded-t-lg"
|
|
aria-controls="{{ adv_section_id }}"
|
|
aria-expanded="false"
|
|
onclick="toggleSection('{{ adv_section_id }}')">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">
|
|
<i class="fas fa-sliders-h mr-1 text-gray-500"></i>Advanced Settings ({{ tiers.advanced|length }})
|
|
</h4>
|
|
<p class="text-sm text-gray-600 mt-1">Optional fine-tuning — the defaults work for most setups.</p>
|
|
</div>
|
|
<i id="{{ adv_section_id }}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="{{ adv_section_id }}" class="nested-content bg-gray-50 px-4 py-4 space-y-3 hidden" style="display: none;">
|
|
{% for key in tiers.advanced %}
|
|
{% set prop = schema.properties[key] %}
|
|
{% set value = config[key] if key in config else none %}
|
|
{{ render_field(key, prop, value, '', plugin.id) }}
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% else %}
|
|
{# No schema - render simple form from config #}
|
|
{% if config %}
|
|
{% 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">
|
|
{{ 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 %}
|
|
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 }}"
|
|
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 }}"
|
|
class="form-input w-full rounded-md border-gray-300 bg-gray-900 text-white placeholder:text-gray-400">
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% else %}
|
|
<p class="text-gray-500 text-sm">No configuration options available for this plugin.</p>
|
|
{% endif %}
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Web UI Actions — hide if schema has a dedicated file-manager widget,
|
|
or if every action is marked ui_hidden in the manifest. #}
|
|
{% set has_file_manager_widget = namespace(value=false) %}
|
|
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
|
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
|
{% set has_file_manager_widget.value = true %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% set visible_actions = [] %}
|
|
{% for _a in web_ui_actions %}
|
|
{% if not _a.get('ui_hidden', false) %}
|
|
{% set _ = visible_actions.append(_a) %}
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% if visible_actions and not has_file_manager_widget.value %}
|
|
<div class="mt-6 pt-4 border-t border-gray-200">
|
|
<h3 class="text-md font-medium text-gray-900 mb-3">Plugin Actions</h3>
|
|
{% if visible_actions[0].section_description %}
|
|
<p class="text-sm text-gray-600 mb-4">{{ visible_actions[0].section_description }}</p>
|
|
{% endif %}
|
|
<div class="space-y-3">
|
|
{% for action in visible_actions %}
|
|
{% set action_id = "action-" ~ action.id ~ "-" ~ loop.index0 %}
|
|
{% set status_id = "action-status-" ~ action.id ~ "-" ~ loop.index0 %}
|
|
{% set bg_color = action.color or 'blue' %}
|
|
{% if bg_color == 'green' %}
|
|
{% set bg_class = 'bg-green-50' %}
|
|
{% set border_class = 'border-green-200' %}
|
|
{% set text_class = 'text-green-900' %}
|
|
{% set text_light_class = 'text-green-700' %}
|
|
{% set btn_class = 'bg-green-600 hover:bg-green-700' %}
|
|
{% elif bg_color == 'red' %}
|
|
{% set bg_class = 'bg-red-50' %}
|
|
{% set border_class = 'border-red-200' %}
|
|
{% set text_class = 'text-red-900' %}
|
|
{% set text_light_class = 'text-red-700' %}
|
|
{% set btn_class = 'bg-red-600 hover:bg-red-700' %}
|
|
{% elif bg_color == 'yellow' %}
|
|
{% set bg_class = 'bg-yellow-50' %}
|
|
{% set border_class = 'border-yellow-200' %}
|
|
{% set text_class = 'text-yellow-900' %}
|
|
{% set text_light_class = 'text-yellow-700' %}
|
|
{% set btn_class = 'bg-yellow-600 hover:bg-yellow-700' %}
|
|
{% elif bg_color == 'purple' %}
|
|
{% set bg_class = 'bg-purple-50' %}
|
|
{% set border_class = 'border-purple-200' %}
|
|
{% set text_class = 'text-purple-900' %}
|
|
{% set text_light_class = 'text-purple-700' %}
|
|
{% set btn_class = 'bg-purple-600 hover:bg-purple-700' %}
|
|
{% else %}
|
|
{% set bg_class = 'bg-blue-50' %}
|
|
{% set border_class = 'border-blue-200' %}
|
|
{% set text_class = 'text-blue-900' %}
|
|
{% set text_light_class = 'text-blue-700' %}
|
|
{% set btn_class = 'bg-blue-600 hover:bg-blue-700' %}
|
|
{% endif %}
|
|
<div class="{{ bg_class }} border {{ border_class }} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium {{ text_class }} mb-1">
|
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.title or action.id }}
|
|
</h4>
|
|
<p class="text-sm {{ text_light_class }}">{{ action.description or '' }}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="{{ action_id }}"
|
|
onclick="executePluginAction('{{ action.id }}', {{ loop.index0 }}, '{{ plugin.id }}')"
|
|
data-plugin-id="{{ plugin.id }}"
|
|
data-action-id="{{ action.id }}"
|
|
class="btn {{ btn_class }} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
{% if action.icon %}<i class="{{ action.icon }} mr-2"></i>{% endif %}{{ action.button_text or action.title or action.id }}
|
|
</button>
|
|
</div>
|
|
<div id="{{ status_id }}" class="mt-3 hidden"></div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Action Buttons #}
|
|
<div class="flex justify-end space-x-3 mt-6 pt-6 border-t border-gray-200">
|
|
<button type="button"
|
|
onclick="refreshPluginConfig('{{ plugin.id }}')"
|
|
class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
|
</button>
|
|
<button type="button"
|
|
hx-post="/api/v3/plugins/update?plugin_id={{ plugin.id }}"
|
|
hx-swap="none"
|
|
hx-on::after-request="handlePluginUpdate(event, '{{ plugin.id }}')"
|
|
class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-download mr-2"></i>Update
|
|
</button>
|
|
<button type="button"
|
|
onclick="if(confirm('Are you sure you want to uninstall {{ plugin.name or plugin.id }}?')) uninstallPlugin('{{ plugin.id }}')"
|
|
class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-trash mr-2"></i>Uninstall
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md flex items-center">
|
|
<span id="save-indicator-{{ plugin.id }}" class="htmx-indicator mr-2">
|
|
<i class="fas fa-spinner fa-spin"></i>
|
|
</span>
|
|
<i class="fas fa-save mr-2 save-icon"></i>
|
|
<span>Save Configuration</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|