feat: add tabbed main screen with Store/Git/ZIP tabs

New main.html consolidates all three install methods into a single tabbed interface using the same nav-tabs pattern as the plugins list. Includes updated card design with thumbnail overlay and larger icons. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

TerminallyLazy committed Mar 3, 2026 at 23:40 UTC 99aea498b92e14cc1fd59d9fa632c20436a99d56
1 file changed +526
plugins/plugin_installer/webui/main.html new
+526
@@ -0,0 +1,526 @@
1 +<html>
2 +<head>
3 + <title>Install Plugin</title>
4 + <script type="module">
5 + import { store } from "/plugins/plugin_installer/webui/pluginInstallStore.js";
6 + </script>
7 +</head>
8 +<body>
9 + <div x-data>
10 + <template x-if="$store.pluginInstallStore">
11 + <div x-init="$store.pluginInstallStore.setTab('store')">
12 +
13 + <ul class="nav nav-tabs" role="tablist">
14 + <li class="nav-item" role="presentation">
15 + <button class="nav-link"
16 + :class="{ active: $store.pluginInstallStore.activeTab === 'store' }"
17 + type="button" role="tab"
18 + @click="$store.pluginInstallStore.setTab('store')">
19 + <span class="material-symbols-outlined pi-tab-icon">store</span> Browse
20 + </button>
21 + </li>
22 + <li class="nav-item" role="presentation">
23 + <button class="nav-link"
24 + :class="{ active: $store.pluginInstallStore.activeTab === 'git' }"
25 + type="button" role="tab"
26 + @click="$store.pluginInstallStore.setTab('git')">
27 + <span class="material-symbols-outlined pi-tab-icon">terminal</span> Git
28 + </button>
29 + </li>
30 + <li class="nav-item" role="presentation">
31 + <button class="nav-link"
32 + :class="{ active: $store.pluginInstallStore.activeTab === 'zip' }"
33 + type="button" role="tab"
34 + @click="$store.pluginInstallStore.setTab('zip')">
35 + <span class="material-symbols-outlined pi-tab-icon">upload_file</span> ZIP
36 + </button>
37 + </li>
38 + </ul>
39 +
40 + <!-- ═══ Store Tab ═══ -->
41 + <div x-show="$store.pluginInstallStore.activeTab === 'store'"
42 + x-init="$store.pluginInstallStore.resetIndex(); $store.pluginInstallStore.fetchIndex()">
43 +
44 + <div class="pi-index-toolbar">
45 + <input type="text" class="pi-search-input"
46 + x-model="$store.pluginInstallStore.search"
47 + @input="$store.pluginInstallStore.page = 1"
48 + placeholder="Search plugins...">
49 + <select class="pi-sort-select"
50 + x-model="$store.pluginInstallStore.sortBy">
51 + <option value="stars">Stars</option>
52 + <option value="name">Name</option>
53 + </select>
54 + </div>
55 +
56 + <div x-show="$store.pluginInstallStore.loading" class="pi-loading">
57 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Loading...'"></span>
58 + </div>
59 +
60 + <div x-show="$store.pluginInstallStore.error && !$store.pluginInstallStore.loading" class="pi-error">
61 + <span x-text="$store.pluginInstallStore.error"></span>
62 + </div>
63 +
64 + <div x-show="$store.pluginInstallStore.index && !$store.pluginInstallStore.loading" class="pi-grid">
65 + <template x-for="plugin in $store.pluginInstallStore.paginatedPlugins" :key="plugin.key">
66 + <div class="pi-card" @click="$store.pluginInstallStore.openDetail(plugin)">
67 + <div class="pi-card-thumb">
68 + <template x-if="plugin.thumbnail">
69 + <img :src="plugin.thumbnail" :alt="plugin.title" loading="lazy">
70 + </template>
71 + <template x-if="!plugin.thumbnail">
72 + <span class="material-symbols-outlined pi-card-placeholder">extension</span>
73 + </template>
74 + <div class="pi-card-thumb-overlay">
75 + <span class="pi-card-thumb-title" x-text="plugin.title || plugin.key"></span>
76 + <template x-if="plugin.installed">
77 + <span class="pi-badge-installed">Installed</span>
78 + </template>
79 + </div>
80 + </div>
81 + <div class="pi-card-body">
82 + <div class="pi-card-desc" x-text="$store.pluginInstallStore.truncate(plugin.description, 100)"></div>
83 + </div>
84 + <div class="pi-card-footer">
85 + <span class="pi-stars">
86 + <span class="material-symbols-outlined" style="font-size:0.9rem">star</span>
87 + <span x-text="plugin.stars || 0"></span>
88 + </span>
89 + </div>
90 + </div>
91 + </template>
92 + </div>
93 +
94 + <div x-show="$store.pluginInstallStore.index && !$store.pluginInstallStore.loading && $store.pluginInstallStore.filteredPlugins.length === 0"
95 + class="pi-empty">
96 + No plugins found matching your search.
97 + </div>
98 +
99 + <div x-show="$store.pluginInstallStore.totalPages > 1" class="pi-pagination">
100 + <button class="button"
101 + :disabled="$store.pluginInstallStore.page <= 1"
102 + @click="$store.pluginInstallStore.setPage($store.pluginInstallStore.page - 1)">
103 + <span class="material-symbols-outlined">chevron_left</span>
104 + </button>
105 + <span class="pi-page-info"
106 + x-text="$store.pluginInstallStore.page + ' / ' + $store.pluginInstallStore.totalPages">
107 + </span>
108 + <button class="button"
109 + :disabled="$store.pluginInstallStore.page >= $store.pluginInstallStore.totalPages"
110 + @click="$store.pluginInstallStore.setPage($store.pluginInstallStore.page + 1)">
111 + <span class="material-symbols-outlined">chevron_right</span>
112 + </button>
113 + </div>
114 + </div>
115 +
116 + <!-- ═══ Git Tab ═══ -->
117 + <div x-show="$store.pluginInstallStore.activeTab === 'git'"
118 + x-init="$store.pluginInstallStore.resetGit()">
119 +
120 + <div class="pi-form-group">
121 + <label class="pi-label">Git Repository URL</label>
122 + <input type="text" class="pi-input"
123 + x-model="$store.pluginInstallStore.gitUrl"
124 + :disabled="$store.pluginInstallStore.loading"
125 + placeholder="https://github.com/user/my-plugin.git">
126 + </div>
127 +
128 + <div class="pi-form-group">
129 + <label class="pi-label">Access Token <span class="pi-optional">(optional, for private repos)</span></label>
130 + <input type="password" class="pi-input"
131 + x-model="$store.pluginInstallStore.gitToken"
132 + :disabled="$store.pluginInstallStore.loading"
133 + placeholder="ghp_xxxx or glpat-xxxx">
134 + <div class="pi-hint">Token is used only for cloning and is not stored.</div>
135 + </div>
136 +
137 + <div class="pi-actions">
138 + <button class="button confirm"
139 + @click="$store.pluginInstallStore.installGit()"
140 + :disabled="$store.pluginInstallStore.loading || !$store.pluginInstallStore.gitUrl.trim()">
141 + <template x-if="$store.pluginInstallStore.loading">
142 + <span class="pi-button-loading">
143 + <span class="spinner"></span>
144 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Cloning...'"></span>
145 + </span>
146 + </template>
147 + <template x-if="!$store.pluginInstallStore.loading">
148 + <span>
149 + <span class="icon material-symbols-outlined">terminal</span> Clone & Install
150 + </span>
151 + </template>
152 + </button>
153 + </div>
154 +
155 + <div x-show="$store.pluginInstallStore.error" class="pi-error">
156 + <span x-text="$store.pluginInstallStore.error"></span>
157 + </div>
158 +
159 + <div x-show="$store.pluginInstallStore.result" class="pi-result">
160 + <div class="pi-result-icon">
161 + <span class="material-symbols-outlined">check_circle</span>
162 + </div>
163 + <div class="pi-result-text">
164 + <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
165 + <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
166 + </div>
167 + </div>
168 + </div>
169 +
170 + <!-- ═══ ZIP Tab ═══ -->
171 + <div x-show="$store.pluginInstallStore.activeTab === 'zip'"
172 + x-init="$store.pluginInstallStore.resetZip()">
173 +
174 + <div class="pi-upload-section">
175 + <label for="plugin-zip-file" class="pi-upload-btn button confirm"
176 + :class="{ 'pi-has-file': $store.pluginInstallStore.zipFile }">
177 + <span class="icon material-symbols-outlined">upload_file</span>
178 + <span x-text="$store.pluginInstallStore.zipFileName || 'Select Plugin ZIP File'"></span>
179 + </label>
180 + <input type="file" id="plugin-zip-file" accept=".zip" style="display:none"
181 + @change="$store.pluginInstallStore.handleFileUpload($event)">
182 + <div class="pi-hint">
183 + The ZIP should contain a folder with a plugin.yaml file.
184 + </div>
185 + </div>
186 +
187 + <div x-show="$store.pluginInstallStore.zipFile" class="pi-actions-center">
188 + <button class="button confirm"
189 + @click="$store.pluginInstallStore.installZip()"
190 + :disabled="$store.pluginInstallStore.loading">
191 + <span class="icon material-symbols-outlined">download</span> Install Plugin
192 + </button>
193 + </div>
194 +
195 + <div x-show="$store.pluginInstallStore.loading" class="pi-loading">
196 + <span x-text="$store.pluginInstallStore.loadingMessage || 'Processing...'"></span>
197 + </div>
198 +
199 + <div x-show="$store.pluginInstallStore.error" class="pi-error">
200 + <span x-text="$store.pluginInstallStore.error"></span>
201 + </div>
202 +
203 + <div x-show="$store.pluginInstallStore.result" class="pi-result">
204 + <div class="pi-result-icon">
205 + <span class="material-symbols-outlined">check_circle</span>
206 + </div>
207 + <div class="pi-result-text">
208 + <strong x-text="'Plugin installed: ' + ($store.pluginInstallStore.result?.title || $store.pluginInstallStore.result?.plugin_name || '')"></strong>
209 + <div class="pi-result-path" x-text="$store.pluginInstallStore.result?.path || ''"></div>
210 + </div>
211 + </div>
212 + </div>
213 +
214 + </div>
215 + </template>
216 + </div>
217 +
218 + <style>
219 + /* ── Tabs ─────────────────────────────── */
220 + .nav {
221 + display: flex;
222 + padding-left: 0;
223 + margin: 0 0 1rem 0;
224 + list-style: none;
225 + border-bottom: 1px solid var(--color-border);
226 + gap: 0.25rem;
227 + }
228 +
229 + .nav-link {
230 + font-family: "Rubik", Arial, Helvetica, sans-serif;
231 + border: 1px solid transparent;
232 + border-top-left-radius: 4px;
233 + border-top-right-radius: 4px;
234 + padding: 0.4rem 0.7rem;
235 + background: transparent;
236 + color: var(--color-text-secondary);
237 + cursor: pointer;
238 + display: inline-flex;
239 + align-items: center;
240 + gap: 0.3rem;
241 + }
242 +
243 + .nav-link.active {
244 + color: var(--color-text-primary);
245 + border-color: var(--color-border);
246 + border-bottom-color: transparent;
247 + background: var(--color-background);
248 + }
249 +
250 + .pi-tab-icon {
251 + font-size: 1.1rem;
252 + }
253 +
254 + /* ── Store Tab: Toolbar ──────────────── */
255 + .pi-index-toolbar {
256 + display: flex;
257 + gap: 0.5rem;
258 + margin-bottom: 1rem;
259 + }
260 +
261 + .pi-search-input {
262 + flex: 1;
263 + min-width: 0;
264 + padding: 0.5rem 0.75rem;
265 + border: 1px solid var(--color-border);
266 + border-radius: 4px;
267 + background: var(--color-bg-primary);
268 + color: var(--color-text-primary);
269 + font-size: 0.95rem;
270 + }
271 +
272 + .pi-sort-select {
273 + padding: 0.5rem;
274 + border: 1px solid var(--color-border);
275 + border-radius: 4px;
276 + background: var(--color-bg-primary);
277 + color: var(--color-text-primary);
278 + font-size: 0.9rem;
279 + max-width: 140px;
280 + flex-shrink: 0;
281 + }
282 +
283 + /* ── Store Tab: Grid ─────────────────── */
284 + .pi-grid {
285 + display: grid;
286 + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
287 + gap: 0.75rem;
288 + }
289 +
290 + .pi-card {
291 + border: 1px solid var(--color-border);
292 + border-radius: 6px;
293 + background: var(--color-background);
294 + cursor: pointer;
295 + transition: border-color 0.15s, box-shadow 0.15s;
296 + display: flex;
297 + flex-direction: column;
298 + overflow: hidden;
299 + }
300 +
301 + .pi-card:hover {
302 + border-color: var(--color-highlight);
303 + box-shadow: 0 2px 8px rgba(0,0,0,0.15);
304 + }
305 +
306 + .pi-card-thumb {
307 + width: 100%;
308 + height: 140px;
309 + overflow: hidden;
310 + display: flex;
311 + align-items: center;
312 + justify-content: center;
313 + background: var(--color-panel);
314 + position: relative;
315 + }
316 +
317 + .pi-card-thumb img {
318 + width: 100%;
319 + height: 100%;
320 + object-fit: contain;
321 + padding: 0.5rem;
322 + }
323 +
324 + .pi-card-placeholder {
325 + font-size: 4.5rem;
326 + color: var(--color-text-muted);
327 + }
328 +
329 + .pi-card-thumb-overlay {
330 + position: absolute;
331 + bottom: 0;
332 + left: 0;
333 + right: 0;
334 + padding: 0.4rem 0.6rem;
335 + background: rgba(0, 0, 0, 0.55);
336 + display: flex;
337 + align-items: center;
338 + gap: 0.4rem;
339 + }
340 +
341 + .pi-card-thumb-title {
342 + font-weight: 600;
343 + font-size: 0.85rem;
344 + color: #fff;
345 + white-space: nowrap;
346 + overflow: hidden;
347 + text-overflow: ellipsis;
348 + flex: 1;
349 + min-width: 0;
350 + }
351 +
352 + .pi-badge-installed {
353 + font-size: 0.65rem;
354 + font-weight: 600;
355 + padding: 0.1rem 0.4rem;
356 + border-radius: 3px;
357 + background: rgba(34,197,94,0.3);
358 + color: #4ade80;
359 + flex-shrink: 0;
360 + }
361 +
362 + .pi-card-body {
363 + padding: 0.5rem 0.75rem;
364 + flex: 1;
365 + }
366 +
367 + .pi-card-desc {
368 + font-size: 0.8rem;
369 + color: var(--color-text-secondary);
370 + line-height: 1.3;
371 + }
372 +
373 + .pi-card-footer {
374 + padding: 0.4rem 0.75rem;
375 + border-top: 1px solid var(--color-border);
376 + font-size: 0.8rem;
377 + color: var(--color-text-secondary);
378 + }
379 +
380 + .pi-stars {
381 + display: inline-flex;
382 + align-items: center;
383 + gap: 0.2rem;
384 + }
385 +
386 + .pi-pagination {
387 + display: flex;
388 + align-items: center;
389 + justify-content: center;
390 + gap: 0.75rem;
391 + margin-top: 1rem;
392 + padding: 0.5rem 0;
393 + }
394 +
395 + .pi-page-info {
396 + font-size: 0.9rem;
397 + color: var(--color-text-secondary);
398 + }
399 +
400 + /* ── Git Tab ─────────────────────────── */
401 + .pi-form-group {
402 + margin-bottom: 1rem;
403 + }
404 +
405 + .pi-label {
406 + display: block;
407 + font-weight: 600;
408 + margin-bottom: 0.35rem;
409 + }
410 +
411 + .pi-optional {
412 + font-weight: 400;
413 + color: var(--color-text-secondary);
414 + font-size: 0.85rem;
415 + }
416 +
417 + .pi-input {
418 + width: 100%;
419 + padding: 0.6rem 0.75rem;
420 + border: 1px solid var(--color-border);
421 + border-radius: 4px;
422 + background: var(--color-bg-primary);
423 + color: var(--color-text-primary);
424 + font-size: 0.95rem;
425 + box-sizing: border-box;
426 + }
427 +
428 + .pi-hint {
429 + margin-top: 0.35rem;
430 + font-size: 0.8rem;
431 + color: var(--color-text-secondary);
432 + }
433 +
434 + .pi-actions {
435 + display: flex;
436 + justify-content: flex-start;
437 + margin: 1rem 0;
438 + }
439 +
440 + /* ── ZIP Tab ─────────────────────────── */
441 + .pi-upload-section {
442 + text-align: center;
443 + padding: 2rem 1rem;
444 + border: 2px dashed var(--color-border);
445 + border-radius: 8px;
446 + margin-bottom: 1rem;
447 + }
448 +
449 + .pi-upload-btn {
450 + display: inline-flex;
451 + align-items: center;
452 + gap: 0.5rem;
453 + padding: 0.75rem 1.5rem;
454 + font-size: 1rem;
455 + cursor: pointer;
456 + }
457 +
458 + .pi-upload-btn.pi-has-file {
459 + background: var(--color-panel);
460 + border-color: var(--color-highlight);
461 + }
462 +
463 + .pi-actions-center {
464 + display: flex;
465 + justify-content: center;
466 + margin: 1rem 0;
467 + }
468 +
469 + /* ── Shared ──────────────────────────── */
470 + .pi-loading {
471 + text-align: center;
472 + padding: 2rem;
473 + color: var(--color-text-secondary);
474 + }
475 +
476 + .pi-error {
477 + color: var(--color-error);
478 + padding: 0.75rem;
479 + background: var(--color-error-bg, rgba(239,68,68,0.1));
480 + border-radius: 4px;
481 + margin-top: 0.75rem;
482 + }
483 +
484 + .pi-empty {
485 + text-align: center;
486 + padding: 2rem;
487 + color: var(--color-text-secondary);
488 + }
489 +
490 + .pi-button-loading {
491 + display: inline-flex;
492 + align-items: center;
493 + gap: 0.5rem;
494 + }
495 +
496 + .pi-result {
497 + display: flex;
498 + align-items: center;
499 + gap: 0.75rem;
500 + padding: 0.75rem;
501 + background: rgba(34,197,94,0.1);
502 + border: 1px solid rgba(34,197,94,0.3);
503 + border-radius: 4px;
504 + margin-top: 0.75rem;
505 + }
506 +
507 + .pi-result-icon .material-symbols-outlined {
508 + font-size: 2rem;
509 + color: #22c55e;
510 + }
511 +
512 + .pi-result-path {
513 + font-size: 0.85rem;
514 + color: var(--color-text-secondary);
515 + margin-top: 0.25rem;
516 + font-family: var(--font-family-code);
517 + }
518 +
519 + @media (max-width: 500px) {
520 + .pi-grid {
521 + grid-template-columns: 1fr;
522 + }
523 + }
524 + </style>
525 +</body>
526 +</html>