Files
LEDMatrix/web_interface/templates/v3/partials/plugins.html
T
ChuckandClaude Opus 5 4423ec33d5 feat(plugins): search, filter and sort for Installed Plugins, on a shared ListFilter helper (#540)
* feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper

The Installed Plugins grid had no way to narrow it down: no search, no way to
see only what's enabled, disabled, or out of date. On a rig with a couple dozen
plugins that means scrolling the whole grid to find one.

The two sections below it already solved this, twice, independently — the
Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines
(filter state, apply-filters-and-sort, page renderer, pagination strip,
active-filter badge, listener wiring). Rather than add a third copy, this
extracts the shared machinery and builds the new toolbar on it.

New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create()
owns debounced search, filter axes, sort, the active-filter count, Clear, and
optional pagination/persistence. Callers keep their own card markup via a
`render` callback. Three control types cover every axis the page uses: pills
(new), select (store category, starlark author) and cycle (the tri-state
All -> Installed -> Not Installed button).

Installed Plugins gets a compact toolbar: search box, one-click All / Enabled /
Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first,
recently updated, category). Filters reset on load, so you never come back to a
mysteriously short list. No new CSS — this is the first consumer of the
.filter-pill rules already sitting unused in app.css.

renderInstalledPlugins() is split so it still publishes canonical state while
renderInstalledCards() draws only the visible subset; the filtered list is
never assigned to window.installedPlugins, which the toggle handler,
isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all
read as their source of truth. Toggling a plugin while filtered pins its card
so it doesn't vanish from under the cursor.

The Store and Starlark migrations are behaviour-preserving: same element ids,
same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage),
same tri-state button markup, same pagination. Verified by differential tests
that run the old and new implementations side by side against identical
fixtures and compare every observable after each interaction. The only visible
change is the pagination attribute (data-store-page/data-starlark-page ->
data-list-page), which nothing outside its own click handler referenced.

Net -156 lines in plugins_manager.js while adding a feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep raw search text, and stop the store search refetching

Two review findings from CodeRabbit on #540.

Do not write the trimmed search value back into the input. setSearch() trimmed
before storing, and syncControls() then copied that trimmed value back over what
the user had typed. Pausing longer than the debounce after typing a space
deleted the space (and reset the caret), making multi-word terms effectively
untypable. The raw text is now kept alongside the trimmed one: filtering and
activeCount() still use the trimmed value, while the input keeps exactly what
was typed.

Remove the legacy #plugin-search / #plugin-category listeners in
initializePlugins(). They bound searchPluginStore as the event handler, so the
DOM event arrived as its `fetchCommitInfo` argument — always truthy, which
skipped the cached-filter fast path and refetched /api/v3/plugins/store/list
with commit info on every keystroke burst and category change. The store's
ListFilter controller already filters the cached list, which is what those two
controls should do. This double-binding predates this PR (the old code guarded
with _listenerSetup and _storeFilterInit, two different flags, so both sets
stayed live); it is fixed here because the refactor owns that wiring now.

Both fixes are covered by tests that fail without them: the trailing-space
regressions in the installed-plugins DOM suite, and a new whole-file jsdom test
that counts fetches while typing (1 request at init, 0 thereafter; previously
1 -> 2 -> 3 -> 5).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): build pagination via DOM APIs, drop computed member access

Addresses the five Codacy security findings, all in list_filter.js.

Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high,
"unsafe assignment to innerHTML"). The interpolated values were only page
integers and local class constants, so there was no injection path, but
concatenating markup into innerHTML is the pattern the scanners flag and
createElement is no less clear. Each button now also owns its click listener
directly instead of the container being re-queried afterwards, and the strip is
cleared with textContent = '' rather than by assigning empty markup. No
innerHTML assignment remains in the file.

haystack() now walks Object.entries(item) and keeps the configured fields,
instead of reading item[field] per field ("generic object injection sink").
Field order no longer drives the haystack order, which is irrelevant to the
substring test. matches() iterates controls with for...of instead of an index
("variable assigned to object injection sink").

The rendered pagination is unchanged: same buttons, labels, page numbers,
disabled states and classes. The old-vs-new differential tests now compare
pagination structurally (tag, text, page, disabled, sorted class list) rather
than as an HTML string, since building nodes legitimately serialises
differently — «/» as characters rather than &laquo;/&raquo;, disabled="" rather
than a bare attribute. That comparison is stronger than the string one it
replaces, and the real-DOM suite still drives the actual page buttons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* fix(plugins): keep configured field order when building the search haystack

The previous commit swapped item[field] for Object.entries(item) to clear a
static-analysis object-injection warning, and in doing so changed the order of
the haystack: entries follow the object's own key insertion order, not the
configured `fields` order. Since the values are concatenated, that order decides
which values end up adjacent, so a multi-word query spanning a field boundary
matched differently. For store fields [name, description, author, id, ...] and
API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched
before and stopped matching after.

That contradicted the behaviour-preservation claim for the store and starlark
migrations, and the differential tests missed it because every fixture query was
a single word.

Values now come out of a Map built from Object.entries, iterated in `fields`
order: the original haystack is restored, and there is still no computed member
access for the analyser to flag.

Regression coverage for the ordering itself, at both levels:
  - unit: phrases spanning name->id and category->tags, plus the reverse
    (object-key) order asserted NOT to match
  - differential: the same class of query compared old-vs-new, with a guard that
    the phrase actually matches something so a mutual zero-result cannot pass
    vacuously

Verified both fail without this fix (3 unit, 2 differential) and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

* test(web): add JS suites for ListFilter and the plugin-manager grids

No JS toolchain exists in this repo, so these are plain node scripts with no
framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js`
runs everything, skipping the DOM suites (rather than failing) when jsdom is
absent or nothing is listening, so it stays useful in a bare checkout.

  unit/test_list_filter.js    ListFilter search/filter/sort/count/sticky, driven
                              through the installed-plugins config eval'd
                              verbatim out of plugins_manager.js so the test
                              cannot drift from the real configuration
  unit/test_render_cards.js   renderInstalledCards markup, both empty states,
                              and escaping of hostile plugin metadata
  dom/test_installed_dom.js   the toolbar in a real DOM, including the HTMX
                              partial re-swap and a getComputedStyle check that
                              .filter-pill[data-active] matches what we emit
  dom/test_store_dom.js       store pagination, per-page, category, tri-state
                              Installed button, persistence across a re-boot
  dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts
                              requests, so a keystroke cannot refetch the store

The DOM suites deliberately fetch the partial and the plugin data from a running
web interface instead of using fixtures, so a renamed element id or a changed
payload shape fails them loudly. Point them at a rig with a full plugin set when
it matters (BASE=http://host:5000); a dev box with two plugins installed passes
while exercising very little.

Several assertions exist to stop specific bugs recurring: trailing spaces
surviving the search debounce, a query spanning two adjacent search fields
(haystack field order is load-bearing), and window.installedPlugins staying at
full length while the grid is filtered. Others guard against passing vacuously —
counting only non-skeleton cards, and checking a search phrase matches something
before comparing two result sets.

The old-vs-new differential suites that verified the store and starlark
migrations are not included: they compared against the pre-refactor code, which
now exists only in git history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:04:47 -04:00

668 lines
35 KiB
HTML

<div class="bg-white rounded-lg shadow-md p-6" data-plugins-loaded="true">
<div class="section-header">
<h2 class="text-xl font-bold text-gray-900 mb-1">Plugin Management</h2>
<p class="text-sm text-gray-600">Manage installed plugins, configure settings, and browse the plugin store.</p>
</div>
<!-- Plugin Controls -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center space-x-4">
<button type="button" id="refresh-plugins-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-sync-alt mr-2"></i>Refresh Plugins
</button>
<button type="button" id="update-all-plugins-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md flex items-center">
<i class="fas fa-cloud-download-alt mr-2"></i>Check &amp; Update All
</button>
<button type="button" id="restart-display-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
<i class="fas fa-redo mr-2"></i>Restart Display
</button>
</div>
</div>
<!-- Plugin Content Area -->
<div class="space-y-8">
<!-- Installed Plugins Section (Always visible at top) -->
<div id="installed-plugins-section" class="mb-8">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
<div class="flex items-center gap-3">
<h3 class="text-lg font-bold text-gray-900">Installed Plugins</h3>
<span id="installed-count" class="text-sm text-gray-500 font-medium">0 installed</span>
</div>
</div>
<!-- Search / Filter / Sort Bar -->
<div id="installed-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Search -->
<div class="relative flex-1 min-w-[12rem]">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm pointer-events-none"></i>
<input type="text"
id="installed-search"
class="form-control text-sm pl-9 pr-8 py-2"
placeholder="Search installed plugins&hellip;"
autocomplete="off"
aria-label="Search installed plugins">
<button type="button" id="installed-search-clear"
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search">
<i class="fas fa-times-circle"></i>
</button>
</div>
<!-- Status filter pills -->
<div id="installed-filter-pills" class="flex items-center gap-1.5" role="group" aria-label="Filter installed plugins by status">
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="all" data-active="true" aria-pressed="true">All</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="enabled" data-active="false" aria-pressed="false">Enabled</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="disabled" data-active="false" aria-pressed="false">Disabled</button>
<button type="button" class="filter-pill text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white"
data-installed-filter="updates" data-active="false" aria-pressed="false"
title="Show only plugins with a newer version available">
Updates<span id="installed-updates-count" class="badge badge-info ml-1.5 hidden">0</span>
</button>
</div>
<!-- Sort -->
<select id="installed-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white" aria-label="Sort installed plugins">
<option value="a-z">A &rarr; Z</option>
<option value="z-a">Z &rarr; A</option>
<option value="status">Updates first</option>
<option value="recent">Recently updated</option>
<option value="category">Category</option>
</select>
<div class="flex-1"></div>
<!-- Clear -->
<button type="button" id="installed-clear-filters"
class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
<i class="fas fa-times mr-1"></i>Clear
</button>
</div>
<div id="installed-plugins-content" class="block">
<div id="installed-plugins-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<!-- Skeleton cards shown while installed plugins load -->
<div class="installed-skeleton plugin-card animate-pulse">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
<div class="installed-skeleton plugin-card animate-pulse hidden md:block">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-2/3"></div>
<div class="h-3 bg-gray-200 rounded w-1/3"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-4/5"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
<div class="installed-skeleton plugin-card animate-pulse hidden lg:block">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-4/5"></div>
<div class="h-3 bg-gray-200 rounded w-2/5"></div>
<div class="h-3 bg-gray-200 rounded w-3/5"></div>
</div>
<div class="h-6 w-10 bg-gray-200 rounded-full ml-4 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-3/4"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-auto"></div>
</div>
</div>
</div>
</div>
<!-- Plugin Store Section (Always visible at bottom) -->
<div id="plugin-store-section" class="border-t border-gray-200 pt-8 mt-8">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
<div class="flex items-center gap-3">
<h3 class="text-lg font-bold text-gray-900">Plugin Store</h3>
<span id="store-count" class="text-sm text-gray-500 font-medium">
<i class="fas fa-spinner fa-spin mr-1"></i>Loading...
</span>
</div>
</div>
<div id="plugin-store-content" class="block">
<!-- GitHub Token Configuration (Combined Warning + Settings) -->
<div id="github-token-container" class="mb-5">
<!-- Warning Banner (shown when no token configured) -->
<div id="github-auth-warning" class="hidden bg-yellow-50 border-l-4 border-yellow-400 p-4 rounded-r-lg">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-yellow-700">
<strong>Limited API Access:</strong> GitHub API requests are limited to <span id="rate-limit-count">60</span> per hour without authentication.
Add a GitHub token to increase this to 5,000 requests/hour and get real-time plugin stats.
</p>
<p class="mt-2 text-sm text-yellow-700">
<a href="https://github.com/settings/tokens/new?description=LEDMatrix%20Plugin%20Manager&scopes=" target="_blank" class="font-medium underline hover:text-yellow-800">
Create a GitHub Token →
</a>
<span class="mx-2">|</span>
<a href="#" onclick="event.preventDefault(); openGithubTokenSettings()" class="font-medium underline hover:text-yellow-800">
Configure Token
</a>
</p>
</div>
<div class="flex-shrink-0">
<button onclick="dismissGithubWarning()" class="text-yellow-700 hover:text-yellow-900">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
<!-- Settings Panel (expandable configuration form) -->
<div id="github-token-settings" class="hidden bg-blue-50 border border-blue-200 rounded-lg p-5 shadow-sm mt-3">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center space-x-2">
<i class="fab fa-github text-blue-600 text-xl"></i>
<h4 class="font-bold text-gray-900">GitHub API Configuration</h4>
</div>
<div class="flex items-center gap-2">
<button id="toggle-github-token-collapse" class="text-gray-600 hover:text-gray-900 text-sm flex items-center font-medium transition-colors">
<i class="fas fa-chevron-up mr-1" id="github-token-icon-collapse"></i>
<span>Collapse</span>
</button>
</div>
</div>
<div id="github-token-content" class="block">
<p class="text-sm text-gray-600 mb-3">
Configure your GitHub Personal Access Token to increase API rate limits and get real-time plugin statistics.
</p>
<div class="space-y-3">
<div>
<label for="github-token-input" class="block text-sm font-medium text-gray-700 mb-1">
GitHub Personal Access Token
</label>
<div class="relative">
<input type="password" id="github-token-input"
class="w-full px-3 py-2 pr-20 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx">
<button type="button" onclick="toggleGithubTokenVisibility()"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
<i id="github-token-icon" class="fas fa-eye"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">
Token is stored in config_secrets.json. No scopes required for public repositories.
</p>
</div>
<div class="flex items-center justify-between">
<a href="https://github.com/settings/tokens/new?description=LEDMatrix%20Plugin%20Manager&scopes="
target="_blank"
class="text-sm text-blue-600 hover:text-blue-800">
<i class="fas fa-external-link-alt mr-1"></i>Create Token on GitHub
</a>
<div class="flex gap-2">
<button onclick="loadGithubToken()" class="px-3 py-1.5 text-sm bg-gray-600 hover:bg-gray-700 text-white rounded-md">
<i class="fas fa-sync mr-1"></i>Load Current
</button>
<button onclick="saveGithubToken()" class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">
<i class="fas fa-save mr-1"></i>Save Token
</button>
</div>
</div>
<div class="bg-white border border-blue-200 rounded p-3">
<p class="text-xs text-gray-600">
<strong>Rate Limits:</strong><br>
Without token: 60 requests/hour<br>
With token: 5,000 requests/hour
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
<option value="sports">Sports</option>
<option value="content">Content</option>
<option value="time">Time</option>
<option value="weather">Weather</option>
<option value="financial">Financial</option>
<option value="media">Media</option>
<option value="demo">Demo</option>
</select>
</div>
<!-- Sort & Filter Bar -->
<div id="store-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="store-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
<option value="author">Author</option>
<option value="newest">Newest</option>
</select>
<div class="w-px h-6 bg-gray-300"></div>
<!-- Installed filter toggle -->
<button id="store-filter-installed" class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-100 transition-colors" title="Cycle: All → Installed → Not Installed">
<i class="fas fa-filter mr-1 text-gray-400"></i>All
</button>
<div class="flex-1"></div>
<!-- Active filter count + clear -->
<span id="store-active-filters" class="hidden text-xs text-blue-600 font-medium"></span>
<button id="store-clear-filters" class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
<i class="fas fa-times mr-1"></i>Clear Filters
</button>
</div>
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="store-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="store-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="12">12 per page</option>
<option value="24">24 per page</option>
<option value="48">48 per page</option>
</select>
<div id="store-pagination-top" class="flex items-center gap-1"></div>
</div>
</div>
<div id="plugin-store-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
<!-- Loading skeleton — hidden by showStoreLoading(false) when data arrives -->
<div class="store-loading col-span-full">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{% for _ in range(10) %}
<div class="plugin-card animate-pulse">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0 space-y-2">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
<div class="h-5 w-14 bg-gray-200 rounded-full ml-3 flex-shrink-0"></div>
</div>
<div class="space-y-2 mb-4">
<div class="h-3 bg-gray-200 rounded w-full"></div>
<div class="h-3 bg-gray-200 rounded w-5/6"></div>
<div class="h-3 bg-gray-200 rounded w-4/6"></div>
</div>
<div class="flex gap-2 mt-auto">
<div class="h-3 bg-gray-200 rounded w-12"></div>
<div class="h-3 bg-gray-200 rounded w-16"></div>
</div>
<div class="h-8 bg-gray-200 rounded w-full mt-3"></div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Bottom Pagination -->
<div class="flex flex-wrap items-center justify-between gap-3 mt-4">
<span id="store-results-info-bottom" class="text-sm text-gray-600"></span>
<div id="store-pagination-bottom" class="flex items-center gap-1"></div>
</div>
</div>
<!-- Starlark Apps Section (Tronbyte Community Apps) -->
<div id="starlark-apps-section" class="border-t border-gray-200 pt-8 mt-8">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
<div class="flex items-center gap-3">
<h3 class="text-lg font-bold text-gray-900"><i class="fas fa-star text-yellow-500 mr-2"></i>Starlark Apps</h3>
<span id="starlark-apps-count" class="text-sm text-gray-500 font-medium"></span>
</div>
<button id="toggle-starlark-section" class="text-sm text-blue-600 hover:text-blue-800 flex items-center font-medium transition-colors">
<i class="fas fa-chevron-down mr-1" id="starlark-section-icon"></i>
<span>Show</span>
</button>
</div>
<div id="starlark-section-content" class="hidden">
<p class="text-sm text-gray-600 mb-4">Browse and install Starlark apps from the <a href="https://github.com/tronbyt/apps" target="_blank" class="text-blue-600 hover:text-blue-800 underline">Tronbyte community repository</a>. Requires <strong>Pixlet</strong> binary.</p>
<!-- Pixlet Status Banner -->
<div id="starlark-pixlet-status" class="mb-4"></div>
<!-- Search Row -->
<div class="flex gap-3 mb-4">
<input type="text" id="starlark-search" placeholder="Search by name, description, author..." class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="starlark-category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
</select>
</div>
<!-- Sort & Filter Bar -->
<div id="starlark-filter-bar" class="flex flex-wrap items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
<!-- Sort -->
<select id="starlark-sort" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="a-z">A → Z</option>
<option value="z-a">Z → A</option>
<option value="category">Category</option>
<option value="author">Author</option>
</select>
<div class="w-px h-6 bg-gray-300"></div>
<!-- Installed filter toggle -->
<button id="starlark-filter-installed" class="text-sm px-3 py-1.5 rounded-md border border-gray-300 bg-white hover:bg-gray-100 transition-colors" title="Cycle: All → Installed → Not Installed">
<i class="fas fa-filter mr-1 text-gray-400"></i>All
</button>
<!-- Author filter -->
<select id="starlark-filter-author" class="text-sm px-3 py-1.5 border border-gray-300 rounded-md bg-white">
<option value="">All Authors</option>
</select>
<div class="flex-1"></div>
<!-- Active filter count + clear -->
<span id="starlark-active-filters" class="hidden text-xs text-blue-600 font-medium"></span>
<button id="starlark-clear-filters" class="hidden text-sm px-3 py-1.5 rounded-md border border-red-300 bg-white text-red-600 hover:bg-red-50 transition-colors">
<i class="fas fa-times mr-1"></i>Clear Filters
</button>
</div>
<!-- Results Bar (top pagination) -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<span id="starlark-results-info" class="text-sm text-gray-600"></span>
<div class="flex items-center gap-3">
<select id="starlark-per-page" class="text-sm px-2 py-1 border border-gray-300 rounded-md bg-white">
<option value="24">24 per page</option>
<option value="48">48 per page</option>
<option value="96">96 per page</option>
</select>
<div id="starlark-pagination-top" class="flex items-center gap-1"></div>
</div>
</div>
<!-- Starlark Apps Grid -->
<div id="starlark-apps-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
</div>
<!-- Bottom Pagination -->
<div class="flex flex-wrap items-center justify-between gap-3 mt-4">
<span id="starlark-results-info-bottom" class="text-sm text-gray-600"></span>
<div id="starlark-pagination-bottom" class="flex items-center gap-1"></div>
</div>
<!-- Upload .star file -->
<div class="flex gap-3 mt-6 pt-4 border-t border-gray-200">
<button id="starlark-upload-btn" class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm">
<i class="fas fa-upload mr-2"></i>Upload .star File
</button>
</div>
</div>
</div>
<!-- Install from GitHub URL Section (Separate section, always visible) -->
<div class="border-t border-gray-200 pt-8 mt-8">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-gray-200">
<div>
<h3 class="text-lg font-bold text-gray-900">Install from GitHub</h3>
<p class="text-sm text-gray-600 mt-1">Install plugins directly from GitHub repositories</p>
</div>
<button id="toggle-github-install" class="text-sm text-blue-600 hover:text-blue-800 flex items-center font-medium transition-colors">
<i class="fas fa-chevron-down mr-1" id="github-install-icon"></i>
<span>Show</span>
</button>
</div>
<div id="github-install-section" class="hidden space-y-4">
<!-- Saved Repositories Section -->
<div class="bg-blue-50 rounded-lg p-5 border border-blue-200 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h4 class="text-base font-bold text-gray-900">
<i class="fas fa-bookmark mr-2 text-blue-600"></i>Saved Repositories
</h4>
<span id="saved-repos-count" class="text-xs text-gray-600 font-medium">0 saved</span>
</div>
<p class="text-xs text-gray-600 mb-3">Saved repositories are automatically loaded and their plugins appear in the Plugin Store above.</p>
<div id="saved-repositories-list" class="space-y-2 mb-3">
<!-- Saved repositories will be loaded here -->
</div>
<button id="refresh-saved-repos" class="text-xs text-blue-600 hover:text-blue-800">
<i class="fas fa-sync mr-1"></i>Refresh
</button>
</div>
<!-- Direct Plugin Installation -->
<div class="bg-gray-50 rounded-lg p-5 border border-gray-200 shadow-sm">
<h4 class="text-base font-bold text-gray-900 mb-3">
<i class="fas fa-code-branch mr-2 text-blue-600"></i>Install Single Plugin
</h4>
<p class="text-xs text-gray-600 mb-3">Install a plugin directly from its GitHub repository URL</p>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="github-plugin-url"
placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url"
onclick="if(window.handleGitHubPluginInstall){window.handleGitHubPluginInstall()}else{alert('Function not loaded yet, please refresh the page')}"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md whitespace-nowrap">
<i class="fas fa-download mr-2"></i>Install
</button>
</div>
<div class="flex items-center gap-2">
<label for="plugin-branch-input" class="text-xs text-gray-600 whitespace-nowrap">
<i class="fas fa-code-branch mr-1"></i>Branch (optional):
</label>
<input type="text" id="plugin-branch-input"
placeholder="main, test, etc. (default: main)"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div id="github-plugin-status" class="mt-2 text-sm"></div>
</div>
<!-- Registry-Style Monorepo Installation -->
<div class="bg-gray-50 rounded-lg p-5 border border-gray-200 shadow-sm">
<h4 class="text-base font-bold text-gray-900 mb-3">
<i class="fas fa-folder-open mr-2 text-green-600"></i>Browse Plugin Registry
</h4>
<p class="text-xs text-gray-600 mb-3">Load a registry-style monorepo (like the official ledmatrix-plugins repo) to browse and install plugins</p>
<div class="flex gap-2 mb-2">
<input type="text" id="github-registry-url"
placeholder="https://github.com/user/ledmatrix-plugins"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button id="load-registry-from-url" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md whitespace-nowrap">
<i class="fas fa-search mr-2"></i>Load Registry
</button>
</div>
<div class="flex gap-2 mb-3">
<button id="save-registry-url" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md whitespace-nowrap">
<i class="fas fa-bookmark mr-2"></i>Save Repository
</button>
<div id="registry-status" class="flex-1 text-sm"></div>
</div>
<div id="custom-registry-plugins" class="hidden">
<div class="border-t border-gray-300 pt-3 mt-3">
<p class="text-xs font-medium text-gray-700 mb-2">Available Plugins:</p>
<div id="custom-registry-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<!-- Custom registry plugins will be loaded here -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- On-Demand Modal moved to base.html so it's always available -->
<style>
/* View toggle button styles */
.view-toggle-btn {
transition: all 0.2s ease;
}
.view-toggle-btn.active {
background-color: #2563eb;
color: white;
}
.view-toggle-btn:not(.active) {
color: #374151;
}
.view-toggle-btn:not(.active):hover {
background-color: #e5e7eb;
}
/* CodeMirror editor styles */
.CodeMirror {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 14px;
height: auto;
min-height: 400px;
}
.CodeMirror.cm-error {
border-color: #ef4444;
}
/* Plugin config view styles */
.plugin-config-view {
transition: opacity 0.2s ease;
}
/* Nested config section styles */
.nested-section {
position: relative;
margin-bottom: 1.5rem;
transition: all 0.2s ease;
z-index: 1;
clear: both;
/* Contain content but allow expansion */
overflow: visible;
/* Ensure proper stacking context */
isolation: isolate;
}
.nested-section button {
position: relative;
z-index: 2;
}
.nested-section button:hover {
background-color: #f3f4f6;
}
.nested-section .nested-content {
position: relative;
transition: max-height 0.3s ease, opacity 0.3s ease;
overflow: hidden;
z-index: 1;
/* Ensure content doesn't get clipped by parent */
min-height: 0;
/* Contain content properly */
contain: layout style;
}
/* When expanded, allow content to flow naturally */
.nested-content.expanded {
overflow: visible;
/* Ensure expanded content is fully visible */
min-height: auto;
}
.nested-section i {
transition: transform 0.3s ease;
}
/* Smooth toggle animation */
.nested-content.collapsed {
opacity: 0;
max-height: 0;
overflow: hidden;
}
.nested-content.expanded {
opacity: 1;
max-height: none !important; /* Remove height constraint to allow natural expansion */
padding-bottom: 1rem !important; /* Ensure proper padding at bottom to prevent cutoff */
overflow: visible; /* Allow content to flow naturally when expanded */
margin-bottom: 0.5rem; /* Add spacing at bottom when expanded */
}
/* Nested sections within nested sections - add indentation and spacing */
.nested-content .nested-section {
margin-left: 1rem;
margin-bottom: 1.5rem; /* Increased spacing to prevent overlap */
margin-top: 0.5rem;
}
/* Deeply nested sections need even more spacing */
.nested-content .nested-content .nested-section {
margin-bottom: 2rem;
margin-top: 0.5rem;
}
/* Form group spacing within nested sections */
.nested-content .form-group {
margin-bottom: 0.75rem;
}
/* Ensure form-groups that come after nested sections have proper spacing */
.nested-section + .form-group {
margin-top: 1.5rem !important;
position: relative;
z-index: 0;
clear: both;
/* Ensure form-group doesn't overlap */
display: block;
width: 100%;
/* Ensure it's in normal document flow */
float: none;
}
/* Ensure any content after nested sections is properly spaced */
.nested-section ~ .form-group {
clear: both !important;
position: relative;
z-index: 0;
display: block;
/* Prevent overlap */
margin-top: 1.5rem !important;
width: 100%;
float: none;
}
/* Make nested section headers slightly smaller for hierarchy */
.nested-content .nested-section h4 {
font-size: 0.95em;
}
</style>