| 1 | <html> |
| 2 | <head> |
| 3 | <title>Plugins</title> |
| 4 | <script type="module"> |
| 5 | import { store } from "/components/plugins/list/pluginListStore.js"; |
| 6 | </script> |
| 7 | </head> |
| 8 | |
| 9 | <body> |
| 10 | <div x-data> |
| 11 | <template x-if="$store.pluginListStore"> |
| 12 | <div x-init="$store.pluginListStore.init && $store.pluginListStore.init()"> |
| 13 | |
| 14 | <ul class="nav nav-tabs" id="plugin-list-tabs" role="tablist"> |
| 15 | <li class="nav-item" role="presentation"> |
| 16 | <button class="nav-link" |
| 17 | :class="{ active: $store.pluginListStore.activeTab === 'custom' }" |
| 18 | id="plugins-custom-tab" |
| 19 | type="button" |
| 20 | role="tab" |
| 21 | aria-controls="plugins-custom" |
| 22 | :aria-selected="$store.pluginListStore.activeTab === 'custom'" |
| 23 | @click="$store.pluginListStore.setTab('custom')"> |
| 24 | Custom |
| 25 | </button> |
| 26 | </li> |
| 27 | <li class="nav-item" role="presentation"> |
| 28 | <button class="nav-link" |
| 29 | :class="{ active: $store.pluginListStore.activeTab === 'builtin' }" |
| 30 | id="plugins-builtin-tab" |
| 31 | type="button" |
| 32 | role="tab" |
| 33 | aria-controls="plugins-builtin" |
| 34 | :aria-selected="$store.pluginListStore.activeTab === 'builtin'" |
| 35 | @click="$store.pluginListStore.setTab('builtin')"> |
| 36 | Builtin |
| 37 | </button> |
| 38 | </li> |
| 39 | <li class="nav-item" role="presentation"> |
| 40 | <button class="nav-link" |
| 41 | :class="{ active: $store.pluginListStore.activeTab === 'pluginHub' }" |
| 42 | id="plugins-plugin-hub-tab" |
| 43 | type="button" |
| 44 | role="tab" |
| 45 | aria-controls="plugins-plugin-hub" |
| 46 | :aria-selected="$store.pluginListStore.activeTab === 'pluginHub'" |
| 47 | @click="$store.pluginListStore.setTab('pluginHub')"> |
| 48 | <x-icon class="pi-tab-icon" name="store"></x-icon> Browse |
| 49 | </button> |
| 50 | </li> |
| 51 | </ul> |
| 52 | |
| 53 | <template x-if="$store.pluginListStore.activeTab !== 'pluginHub'"> |
| 54 | <div> |
| 55 | <div class="plugins-toolbar"> |
| 56 | <div class="plugins-toolbar-search"> |
| 57 | <input type="text" |
| 58 | class="plugins-search-input" |
| 59 | placeholder="Search plugins by name or description..." |
| 60 | x-model="$store.pluginListStore.searchQuery"> |
| 61 | </div> |
| 62 | <div class="plugins-toolbar-actions"> |
| 63 | <button type="button" |
| 64 | class="button" |
| 65 | title="Refresh" |
| 66 | @click="$store.pluginListStore.refresh()" |
| 67 | :disabled="$store.pluginListStore.loading"> |
| 68 | <x-icon class="icon" name="refresh"></x-icon> Refresh |
| 69 | </button> |
| 70 | |
| 71 | <x-extension id="plugins-list-header-buttons"></x-extension> |
| 72 | </div> |
| 73 | </div> |
| 74 | |
| 75 | <div x-show="$store.pluginListStore.loading" class="loading"> |
| 76 | <span>Loading plugins...</span> |
| 77 | </div> |
| 78 | |
| 79 | <div class="plugins-list"> |
| 80 | <template x-for="plugin in (($store.pluginListStore.plugins || []).filter((plugin) => { |
| 81 | const q = (($store.pluginListStore.searchQuery || '').trim().toLowerCase()); |
| 82 | if (!q) return true; |
| 83 | const name = (plugin.display_name || plugin.name || '').toLowerCase(); |
| 84 | const description = (plugin.description || '').toLowerCase(); |
| 85 | return name.includes(q) || description.includes(q); |
| 86 | }))" :key="plugin.path || plugin.name"> |
| 87 | <div class="plugin-card"> |
| 88 | <div class="plugin-header"> |
| 89 | <template x-if="plugin.thumbnail_url"> |
| 90 | <img class="plugin-thumb" |
| 91 | :src="plugin.thumbnail_url" |
| 92 | :alt="plugin.display_name || plugin.name" |
| 93 | loading="lazy"> |
| 94 | </template> |
| 95 | <div class="plugin-heading"> |
| 96 | <div class="plugin-title" x-text="plugin.display_name || plugin.name || '(unnamed plugin)'"></div> |
| 97 | <code class="plugin-path" x-text="plugin.path"></code> |
| 98 | </div> |
| 99 | <div class="plugin-actions" x-data="{ actionsOpen: false }"> |
| 100 | <template x-if="plugin.has_main_screen"> |
| 101 | <button type="button" |
| 102 | class="button" |
| 103 | title="Open" |
| 104 | @click="$store.pluginListStore.openPlugin(plugin)"> |
| 105 | <x-icon class="icon" name="dashboard"></x-icon> Open |
| 106 | </button> |
| 107 | </template> |
| 108 | <template x-if="$store.pluginListStore.canOpenPluginConfig(plugin)"> |
| 109 | <button type="button" |
| 110 | class="button" |
| 111 | title="Config" |
| 112 | @click="$store.pluginListStore.openPluginConfig(plugin)"> |
| 113 | <x-icon class="icon" name="settings"></x-icon> Config |
| 114 | </button> |
| 115 | </template> |
| 116 | <template x-if="plugin.has_execute_script"> |
| 117 | <button type="button" |
| 118 | class="button" |
| 119 | title="Execute" |
| 120 | @click="$store.pluginListStore.openPluginExecute(plugin)"> |
| 121 | <x-icon class="icon" name="terminal"></x-icon> Execute |
| 122 | </button> |
| 123 | </template> |
| 124 | <button type="button" |
| 125 | class="button icon-button" |
| 126 | title="Info" |
| 127 | aria-label="Info" |
| 128 | @click="$store.pluginListStore.openPluginInfo(plugin)"> |
| 129 | <x-icon class="icon" name="info"></x-icon> |
| 130 | </button> |
| 131 | <div class="dropdown plugin-actions-dropdown" |
| 132 | @click.outside="actionsOpen = false" |
| 133 | @keydown.escape.window="if (actionsOpen) actionsOpen = false"> |
| 134 | <button type="button" |
| 135 | class="btn-icon-action dropdown-trigger" |
| 136 | title="More actions" |
| 137 | aria-label="More actions" |
| 138 | @click.stop="actionsOpen = !actionsOpen" |
| 139 | :aria-expanded="actionsOpen.toString()"> |
| 140 | <x-icon name="more_vert"></x-icon> |
| 141 | </button> |
| 142 | <div class="dropdown-menu" |
| 143 | x-show="actionsOpen" |
| 144 | x-transition |
| 145 | style="display: none;"> |
| 146 | <x-extension id="plugins-list-dropdown-start"></x-extension> |
| 147 | <template x-if="plugin.has_readme"> |
| 148 | <button type="button" |
| 149 | class="dropdown-item" |
| 150 | title="README" |
| 151 | @click="$store.pluginListStore.openPluginDoc(plugin, 'readme'); actionsOpen = false"> |
| 152 | <x-icon name="description"></x-icon> |
| 153 | <span>README</span> |
| 154 | </button> |
| 155 | </template> |
| 156 | <template x-if="plugin.has_license"> |
| 157 | <button type="button" |
| 158 | class="dropdown-item" |
| 159 | title="License" |
| 160 | @click="$store.pluginListStore.openPluginDoc(plugin, 'license'); actionsOpen = false"> |
| 161 | <x-icon name="gavel"></x-icon> |
| 162 | <span>License</span> |
| 163 | </button> |
| 164 | </template> |
| 165 | <x-extension id="plugins-list-dropdown-end"></x-extension> |
| 166 | <template x-if="plugin.is_custom"> |
| 167 | <button type="button" |
| 168 | class="dropdown-item plugin-dropdown-delete" |
| 169 | title="Delete" |
| 170 | @click="$confirmClick($event, () => { $store.pluginListStore.deletePlugin(plugin); actionsOpen = false; })"> |
| 171 | <x-icon name="delete"></x-icon> |
| 172 | <span>Delete</span> |
| 173 | </button> |
| 174 | </template> |
| 175 | </div> |
| 176 | </div> |
| 177 | </div> |
| 178 | </div> |
| 179 | |
| 180 | <div class="plugin-footer-row"> |
| 181 | <div class="plugin-description" x-text="plugin.description || 'No description provided.'"></div> |
| 182 | <div class="plugin-status-group"> |
| 183 | <label class="toggle plugin-status-toggle" |
| 184 | :class="{ 'disabled-appearance': plugin.always_enabled || $store.pluginListStore.loading }" |
| 185 | :title="plugin.always_enabled ? 'Always enabled' : ($store.pluginListStore.isPluginEnabled(plugin) ? 'Disable plugin' : 'Enable plugin')"> |
| 186 | <input type="checkbox" |
| 187 | :checked="$store.pluginListStore.isPluginEnabled(plugin)" |
| 188 | :disabled="plugin.always_enabled || $store.pluginListStore.loading" |
| 189 | @change="$store.pluginListStore.updateToggle(plugin, $event.target.checked)" |
| 190 | @click.stop> |
| 191 | <span class="toggler"></span> |
| 192 | </label> |
| 193 | <span class="plugin-status-text" x-text="$store.pluginListStore.toggleStatusLabel(plugin)"></span> |
| 194 | </div> |
| 195 | </div> |
| 196 | </div> |
| 197 | </template> |
| 198 | </div> |
| 199 | </div> |
| 200 | </template> |
| 201 | |
| 202 | <template x-if="$store.pluginListStore.activeTab === 'pluginHub'"> |
| 203 | <x-component path="/plugins/_plugin_installer/webui/install-index.html"></x-component> |
| 204 | </template> |
| 205 | </div> |
| 206 | </template> |
| 207 | </div> |
| 208 | |
| 209 | <style> |
| 210 | .nav { |
| 211 | display: flex; |
| 212 | padding-left: 0; |
| 213 | margin: 0 0 1rem 0; |
| 214 | list-style: none; |
| 215 | border-bottom: 1px solid var(--color-border); |
| 216 | gap: 0.25rem; |
| 217 | } |
| 218 | |
| 219 | .nav-link { |
| 220 | font-family: "Rubik", Arial, Helvetica, sans-serif; |
| 221 | border: 1px solid transparent; |
| 222 | border-top-left-radius: 4px; |
| 223 | border-top-right-radius: 4px; |
| 224 | padding: 0.4rem 0.7rem; |
| 225 | background: transparent; |
| 226 | color: var(--color-text-secondary); |
| 227 | cursor: pointer; |
| 228 | display: inline-flex; |
| 229 | align-items: center; |
| 230 | gap: 0.3rem; |
| 231 | } |
| 232 | |
| 233 | .nav-link.active { |
| 234 | color: var(--color-text-primary); |
| 235 | border-color: var(--color-border); |
| 236 | border-bottom-color: transparent; |
| 237 | background: var(--color-background); |
| 238 | } |
| 239 | |
| 240 | .pi-tab-icon { |
| 241 | font-size: 1.1rem; |
| 242 | } |
| 243 | |
| 244 | .plugins-toolbar { |
| 245 | display: flex; |
| 246 | align-items: center; |
| 247 | gap: 0.75rem; |
| 248 | flex-wrap: wrap; |
| 249 | margin-top: 0.5rem; |
| 250 | margin-bottom: 0.75rem; |
| 251 | } |
| 252 | |
| 253 | .plugins-toolbar-search { |
| 254 | flex: 1 1 22rem; |
| 255 | min-width: 14rem; |
| 256 | } |
| 257 | |
| 258 | .plugins-search-input { |
| 259 | width: 100%; |
| 260 | padding: 0.4rem 0.7rem; |
| 261 | border: 1px solid var(--color-border); |
| 262 | border-radius: 4px; |
| 263 | background: var(--color-background); |
| 264 | color: var(--color-text-primary); |
| 265 | font-family: "Rubik", Arial, Helvetica, sans-serif; |
| 266 | font-size: 0.95rem; |
| 267 | } |
| 268 | |
| 269 | .plugins-search-input:focus { |
| 270 | outline: none; |
| 271 | border-color: var(--color-primary); |
| 272 | } |
| 273 | |
| 274 | .plugins-toolbar-actions { |
| 275 | margin-left: auto; |
| 276 | display: flex; |
| 277 | flex: 0 0 auto; |
| 278 | gap: var(--spacing-sm); |
| 279 | } |
| 280 | |
| 281 | .plugins-list { |
| 282 | margin-top: 0.25rem; |
| 283 | } |
| 284 | |
| 285 | .plugin-card { |
| 286 | border: 1px solid var(--color-border); |
| 287 | border-radius: 10px; |
| 288 | padding: 0.75rem; |
| 289 | margin-top: 0.75rem; |
| 290 | background: var(--color-background); |
| 291 | } |
| 292 | |
| 293 | .plugin-header { |
| 294 | display: flex; |
| 295 | align-items: flex-start; |
| 296 | justify-content: space-between; |
| 297 | gap: 1rem; |
| 298 | flex-wrap: nowrap; |
| 299 | min-width: 0; |
| 300 | } |
| 301 | |
| 302 | .plugin-heading { |
| 303 | display: flex; |
| 304 | flex-direction: column; |
| 305 | min-width: 0; |
| 306 | flex: 1 1 auto; |
| 307 | } |
| 308 | |
| 309 | .plugin-thumb { |
| 310 | width: 44px; |
| 311 | height: 44px; |
| 312 | border-radius: 0.5rem; |
| 313 | object-fit: contain; |
| 314 | flex-shrink: 0; |
| 315 | background: var(--color-panel); |
| 316 | border: 1px solid var(--color-border); |
| 317 | } |
| 318 | |
| 319 | .plugin-title { |
| 320 | font-weight: 600; |
| 321 | font-size: 1rem; |
| 322 | min-width: 0; |
| 323 | flex: 1 1 auto; |
| 324 | } |
| 325 | |
| 326 | .plugin-actions { |
| 327 | display: flex; |
| 328 | align-items: center; |
| 329 | gap: 0.5rem; |
| 330 | padding-bottom: var(--spacing-sm); |
| 331 | flex-wrap: nowrap; |
| 332 | min-width: 0; |
| 333 | flex: 0 0 auto; |
| 334 | max-width: 100%; |
| 335 | justify-content: flex-end; |
| 336 | } |
| 337 | |
| 338 | .plugin-actions .button { |
| 339 | padding: 0.3rem 0.6rem; |
| 340 | font-size: 0.9rem; |
| 341 | } |
| 342 | |
| 343 | .plugin-actions .button .icon { |
| 344 | font-size: 1.1rem; |
| 345 | } |
| 346 | |
| 347 | .plugin-actions-dropdown { |
| 348 | flex: 0 0 auto; |
| 349 | } |
| 350 | |
| 351 | .plugin-actions-dropdown:not(:has(.dropdown-menu .dropdown-item)) { |
| 352 | display: none; |
| 353 | } |
| 354 | |
| 355 | .plugin-actions-dropdown .dropdown-menu { |
| 356 | margin-top: 0; |
| 357 | top: calc(100% - 1px); |
| 358 | } |
| 359 | |
| 360 | .plugin-actions-dropdown .plugin-dropdown-delete, |
| 361 | .plugin-actions-dropdown .plugin-dropdown-delete .material-symbols-outlined { |
| 362 | color: var(--color-accent); |
| 363 | opacity: 1; |
| 364 | } |
| 365 | |
| 366 | .plugin-actions-dropdown .plugin-dropdown-delete:hover { |
| 367 | background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); |
| 368 | } |
| 369 | |
| 370 | .plugin-footer-row { |
| 371 | display: flex; |
| 372 | align-items: center; |
| 373 | justify-content: space-between; |
| 374 | gap: 1rem; |
| 375 | margin-top: 0.5rem; |
| 376 | } |
| 377 | |
| 378 | .plugin-status-group { |
| 379 | display: flex; |
| 380 | align-items: center; |
| 381 | gap: 0.5rem; |
| 382 | flex: 0 0 auto; |
| 383 | } |
| 384 | |
| 385 | .plugin-status-toggle { |
| 386 | width: 48px; |
| 387 | height: 28px; |
| 388 | flex: 0 0 48px; |
| 389 | } |
| 390 | |
| 391 | .plugin-status-toggle .toggler { |
| 392 | border-radius: 999px; |
| 393 | } |
| 394 | |
| 395 | .plugin-status-toggle .toggler:before { |
| 396 | width: 20px; |
| 397 | height: 20px; |
| 398 | left: 4px; |
| 399 | bottom: 4px; |
| 400 | } |
| 401 | |
| 402 | .plugin-status-toggle input:checked + .toggler:before { |
| 403 | transform: translateX(20px); |
| 404 | } |
| 405 | |
| 406 | .plugin-status-toggle input:disabled + .toggler { |
| 407 | cursor: default; |
| 408 | } |
| 409 | |
| 410 | .plugin-status-toggle.disabled-appearance { |
| 411 | opacity: 0.6; |
| 412 | } |
| 413 | |
| 414 | .plugin-status-text { |
| 415 | color: var(--color-text-primary); |
| 416 | font-size: 0.9rem; |
| 417 | font-weight: 600; |
| 418 | min-width: 2rem; |
| 419 | } |
| 420 | |
| 421 | .plugin-description { |
| 422 | color: var(--color-text-secondary); |
| 423 | font-size: var(--font-size-small); |
| 424 | flex: 1 1 auto; |
| 425 | min-width: 0; |
| 426 | } |
| 427 | |
| 428 | .plugin-path { |
| 429 | margin-top: 0.35rem; |
| 430 | font-size: 0.85rem; |
| 431 | color: var(--color-text-muted); |
| 432 | min-width: 0; |
| 433 | max-width: 100%; |
| 434 | white-space: pre-wrap; |
| 435 | word-break: break-word; |
| 436 | overflow-wrap: anywhere; |
| 437 | } |
| 438 | |
| 439 | .loading { |
| 440 | width: 100%; |
| 441 | text-align: center; |
| 442 | margin-top: 0.75rem; |
| 443 | margin-bottom: 0.75rem; |
| 444 | color: var(--color-secondary); |
| 445 | } |
| 446 | |
| 447 | @media (max-width: 768px) { |
| 448 | .plugin-header { |
| 449 | flex-direction: column; |
| 450 | align-items: stretch; |
| 451 | } |
| 452 | |
| 453 | .plugin-heading { |
| 454 | width: 100%; |
| 455 | flex: 0 0 auto; |
| 456 | } |
| 457 | |
| 458 | .plugin-actions { |
| 459 | width: 100%; |
| 460 | justify-content: flex-start; |
| 461 | padding-bottom: 0; |
| 462 | flex-wrap: wrap; |
| 463 | } |
| 464 | |
| 465 | .plugin-path { |
| 466 | white-space: normal; |
| 467 | word-break: break-word; |
| 468 | } |
| 469 | } |
| 470 | </style> |
| 471 | </body> |
| 472 | |
| 473 | </html> |