@lapis-notes/workspace 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE.md +3 -3
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.js +3 -0
  5. package/dist/plugin-management/CommunityPluginsSettingsPage.svelte +152 -73
  6. package/dist/plugin-management/PluginBrowser.svelte +558 -0
  7. package/dist/plugin-management/PluginBrowser.svelte.d.ts +17 -0
  8. package/dist/plugin-management/PluginBrowserCard.svelte +230 -0
  9. package/dist/plugin-management/PluginBrowserCard.svelte.d.ts +9 -0
  10. package/dist/plugin-management/PluginBrowserDetail.svelte +1168 -0
  11. package/dist/plugin-management/PluginBrowserDetail.svelte.d.ts +20 -0
  12. package/dist/plugin-management/PluginBrowserDirectory.svelte +1069 -0
  13. package/dist/plugin-management/PluginBrowserDirectory.svelte.d.ts +21 -0
  14. package/dist/plugin-management/PluginBrowserLanding.svelte +341 -0
  15. package/dist/plugin-management/PluginBrowserLanding.svelte.d.ts +12 -0
  16. package/dist/plugin-management/PluginRegistryBadge.svelte +41 -0
  17. package/dist/plugin-management/PluginRegistryBadge.svelte.d.ts +8 -0
  18. package/dist/plugin-management/PluginRegistryContentState.svelte +80 -0
  19. package/dist/plugin-management/PluginRegistryContentState.svelte.d.ts +14 -0
  20. package/dist/plugin-management/PluginRegistryImage.svelte +64 -0
  21. package/dist/plugin-management/PluginRegistryImage.svelte.d.ts +9 -0
  22. package/dist/plugin-management/PluginRegistryMedia.svelte +223 -0
  23. package/dist/plugin-management/PluginRegistryMedia.svelte.d.ts +8 -0
  24. package/dist/plugin-management/PluginRegistryMediaViewer.svelte +155 -0
  25. package/dist/plugin-management/PluginRegistryMediaViewer.svelte.d.ts +13 -0
  26. package/dist/plugin-management/PluginRegistryRow.svelte +120 -0
  27. package/dist/plugin-management/PluginRegistryRow.svelte.d.ts +25 -0
  28. package/dist/plugin-management/PluginRegistrySettingsPage.svelte +1009 -512
  29. package/dist/plugin-management/PluginRegistryStatus.svelte +33 -0
  30. package/dist/plugin-management/PluginRegistryStatus.svelte.d.ts +8 -0
  31. package/dist/plugin-management/PluginRegistryToolbar.svelte +159 -0
  32. package/dist/plugin-management/PluginRegistryToolbar.svelte.d.ts +21 -0
  33. package/dist/plugin-management/community-presentation/CommunityChip.svelte +229 -0
  34. package/dist/plugin-management/community-presentation/CommunityChip.svelte.d.ts +4 -0
  35. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte +42 -0
  36. package/dist/plugin-management/community-presentation/CommunityMemberAvatar.svelte.d.ts +13 -0
  37. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte +282 -0
  38. package/dist/plugin-management/community-presentation/RegistryMediaGallery.svelte.d.ts +8 -0
  39. package/dist/plugin-management/community-presentation/RegistryMediaViewer.css +179 -0
  40. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte +241 -0
  41. package/dist/plugin-management/community-presentation/RegistryMediaViewer.svelte.d.ts +14 -0
  42. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte +216 -0
  43. package/dist/plugin-management/community-presentation/RegistryPluginChangelog.svelte.d.ts +8 -0
  44. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte +179 -0
  45. package/dist/plugin-management/community-presentation/RegistryVerifiedImage.svelte.d.ts +15 -0
  46. package/dist/plugin-management/community-presentation/avatar-model.d.ts +14 -0
  47. package/dist/plugin-management/community-presentation/avatar-model.js +21 -0
  48. package/dist/plugin-management/community-presentation/community-member-avatar.css +137 -0
  49. package/dist/plugin-management/community-presentation/community-typography.css +30 -0
  50. package/dist/plugin-management/community-presentation/model-adapter.d.ts +54 -0
  51. package/dist/plugin-management/community-presentation/model-adapter.js +90 -0
  52. package/dist/plugin-management/community-presentation/model.d.ts +9 -0
  53. package/dist/plugin-management/community-presentation/model.js +40 -0
  54. package/dist/plugin-management/community-presentation/props.d.ts +21 -0
  55. package/dist/plugin-management/community-presentation/props.js +1 -0
  56. package/dist/plugin-management/community-presentation/registry-filter-icons.d.ts +3 -0
  57. package/dist/plugin-management/community-presentation/registry-filter-icons.js +70 -0
  58. package/dist/plugin-management/community-presentation/registry-markdown.d.ts +1 -0
  59. package/dist/plugin-management/community-presentation/registry-markdown.js +57 -0
  60. package/dist/plugin-management/community-presentation/registry-plugin-icons.d.ts +2 -0
  61. package/dist/plugin-management/community-presentation/registry-plugin-icons.js +31 -0
  62. package/dist/plugin-management/plugin-browser-model.d.ts +44 -0
  63. package/dist/plugin-management/plugin-browser-model.js +104 -0
  64. package/dist/plugin-management/plugin-browser-navigation.d.ts +3 -0
  65. package/dist/plugin-management/plugin-browser-navigation.js +38 -0
  66. package/dist/plugin-management/plugin-download-stats.d.ts +1 -0
  67. package/dist/plugin-management/plugin-download-stats.js +15 -0
  68. package/dist/plugin-management/plugin-management.css +1088 -117
  69. package/dist/plugin-management/plugin-registry-controller.svelte.d.ts +29 -0
  70. package/dist/plugin-management/plugin-registry-controller.svelte.js +154 -0
  71. package/dist/plugin-management/plugin-registry-detail-session.svelte.d.ts +18 -0
  72. package/dist/plugin-management/plugin-registry-detail-session.svelte.js +106 -0
  73. package/dist/plugin-management/plugin-registry-detail.d.ts +17 -0
  74. package/dist/plugin-management/plugin-registry-detail.js +1 -0
  75. package/dist/plugin-management/plugins-view.svelte.d.ts +41 -0
  76. package/dist/plugin-management/plugins-view.svelte.js +109 -0
  77. package/package.json +5 -5
@@ -0,0 +1,1069 @@
1
+ <script lang="ts">
2
+ import { Button } from "@lapismd/design-core/shadcn/button";
3
+ import * as Breadcrumb from "@lapismd/design-core/shadcn/breadcrumb";
4
+ import * as Collapsible from "@lapismd/design-core/shadcn/collapsible";
5
+ import * as Empty from "@lapismd/design-core/shadcn/empty";
6
+ import { Input } from "@lapismd/design-core/shadcn/input";
7
+ import * as Select from "@lapismd/design-core/shadcn/select";
8
+ import * as Tooltip from "@lapismd/design-core/shadcn/tooltip";
9
+ import { AppShell } from "@lapismd/design-core/shell";
10
+ import { WorkspaceSidebarToggle } from "@lapismd/design-core/workspace/sidebar-toggle";
11
+ import CheckIcon from "@lucide/svelte/icons/check";
12
+ import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
13
+ import LayoutGridIcon from "@lucide/svelte/icons/layout-grid";
14
+ import ListIcon from "@lucide/svelte/icons/list";
15
+ import PackageIcon from "@lucide/svelte/icons/package";
16
+ import SlidersHorizontalIcon from "@lucide/svelte/icons/sliders-horizontal";
17
+ import XIcon from "@lucide/svelte/icons/x";
18
+ import FilterXIcon from "@lucide/svelte/icons/filter-x";
19
+ import type { Component } from "svelte";
20
+
21
+ import {
22
+ categoriesFor as collectRegistryCategories,
23
+ filterPlugins as filterRegistryEntries,
24
+ titleCase as titleCaseRegistryValue,
25
+ type PluginDirectoryState as RegistryDirectoryState,
26
+ } from "./plugin-browser-model";
27
+ import CommunityChip from "./community-presentation/CommunityChip.svelte";
28
+ import { registryFilterIcon } from "./community-presentation/registry-filter-icons.js";
29
+ import RegistryPluginCard from "./PluginBrowserCard.svelte";
30
+
31
+ import type {
32
+ PluginCatalogEntry as RegistryCatalogEntry,
33
+ PluginCatalogStatus as RegistryCatalogStatus,
34
+ } from "@lapis-notes/api";
35
+ import { registryStatusLabel } from "./community-presentation/model-adapter";
36
+ import type { PluginRegistryController } from "./plugin-registry-controller.svelte";
37
+ let {
38
+ registry,
39
+ compact = false,
40
+ filtersOpen = false,
41
+ ontogglefilters,
42
+ onhome,
43
+ contentRef = $bindable(null),
44
+ directoryState,
45
+ installedIds,
46
+ updateIds,
47
+ downloads,
48
+ onopen: onOpenPlugin,
49
+ onchange,
50
+ }: {
51
+ registry: PluginRegistryController;
52
+ compact?: boolean;
53
+ filtersOpen?: boolean;
54
+ ontogglefilters: (event: MouseEvent) => void;
55
+ onhome: () => void;
56
+ contentRef?: HTMLDivElement | null;
57
+ directoryState: RegistryDirectoryState;
58
+ installedIds: Set<string>;
59
+ updateIds: ReadonlySet<string>;
60
+ downloads: (id: string) => number;
61
+ onopen: (plugin: RegistryCatalogEntry) => void;
62
+ onchange: (state: RegistryDirectoryState) => void;
63
+ } = $props();
64
+ const plugins = $derived(registry.catalogEntries);
65
+ let bodyElement = $state<HTMLElement | null>(null);
66
+ let filterElement = $state<HTMLElement | null>(null);
67
+ let filterWidth = $state<number>();
68
+ let resizePointerId = $state<number | null>(null);
69
+ let resizeStartX = 0;
70
+ let resizeStartWidth = 0;
71
+ let typeOpen = $state(true);
72
+ let statusOpen = $state(true);
73
+ let categoryOpen = $state(true);
74
+ let platformOpen = $state(true);
75
+ let otherOpen = $state(true);
76
+ let categoriesExpanded = $state(false);
77
+ const results = $derived(
78
+ filterRegistryEntries(
79
+ plugins,
80
+ directoryState,
81
+ installedIds,
82
+ downloads,
83
+ updateIds,
84
+ ),
85
+ );
86
+ const categories = $derived(collectRegistryCategories(plugins));
87
+ const platforms = $derived(
88
+ [...new Set(plugins.flatMap((plugin) => plugin.platforms))].toSorted(),
89
+ );
90
+ const statuses = $derived<RegistryCatalogStatus[]>([
91
+ "active",
92
+ "pending",
93
+ "deprecated",
94
+ "disabled",
95
+ "revoked",
96
+ ]);
97
+ const activeFilterCount = $derived(
98
+ directoryState.categories.length +
99
+ directoryState.platforms.length +
100
+ directoryState.statuses.length +
101
+ (directoryState.officialOnly ? 1 : 0) +
102
+ (directoryState.updatesOnly ? 1 : 0),
103
+ );
104
+ const resizeMinimum = 192;
105
+ const hoverScrollbarProps = { scrollbarVisibility: "hover" } as const;
106
+ const sortLabels: Record<RegistryDirectoryState["sort"], string> = {
107
+ name: "Name",
108
+ new: "New",
109
+ updated: "Recently updated",
110
+ status: "Status",
111
+ downloads: "Most downloaded",
112
+ };
113
+ const resizeMaximum = $derived(resizeBounds().max);
114
+ const presentedFilterWidth = $derived(
115
+ Math.round(
116
+ filterWidth ?? filterElement?.getBoundingClientRect().width ?? 224,
117
+ ),
118
+ );
119
+
120
+ function replaceState(next: Partial<RegistryDirectoryState>): void {
121
+ onchange({ ...directoryState, ...next });
122
+ }
123
+
124
+ function toggleListValue(
125
+ key: "categories" | "platforms" | "statuses",
126
+ value: string,
127
+ ): void {
128
+ const current = directoryState[key];
129
+ replaceState({
130
+ [key]: current.includes(value)
131
+ ? current.filter((candidate) => candidate !== value)
132
+ : [...current, value],
133
+ });
134
+ }
135
+
136
+ function resetFilters(): void {
137
+ replaceState({
138
+ categories: [],
139
+ platforms: [],
140
+ statuses: [],
141
+ officialOnly: false,
142
+ updatesOnly: false,
143
+ });
144
+ }
145
+
146
+ function countFor(
147
+ predicate: (plugin: RegistryCatalogEntry) => boolean,
148
+ ): number {
149
+ return plugins.filter(predicate).length;
150
+ }
151
+
152
+ function resizeBounds(): { min: number; max: number } {
153
+ const available = (bodyElement?.clientWidth ?? 768) - 384;
154
+ return {
155
+ min: resizeMinimum,
156
+ max: Math.max(resizeMinimum, Math.min(384, available)),
157
+ };
158
+ }
159
+
160
+ function clampFilterWidth(width: number): number {
161
+ const { min, max } = resizeBounds();
162
+ return Math.min(max, Math.max(min, width));
163
+ }
164
+
165
+ function currentFilterWidth(): number {
166
+ return filterWidth ?? filterElement?.getBoundingClientRect().width ?? 224;
167
+ }
168
+
169
+ function beginResize(event: PointerEvent): void {
170
+ if (event.button !== 0) return;
171
+ resizePointerId = event.pointerId;
172
+ resizeStartX = event.clientX;
173
+ resizeStartWidth = currentFilterWidth();
174
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
175
+ event.preventDefault();
176
+ }
177
+
178
+ function moveResize(event: PointerEvent): void {
179
+ if (event.pointerId !== resizePointerId) return;
180
+ filterWidth = clampFilterWidth(
181
+ resizeStartWidth + event.clientX - resizeStartX,
182
+ );
183
+ }
184
+
185
+ function finishResize(event: PointerEvent): void {
186
+ if (event.pointerId !== resizePointerId) return;
187
+ resizePointerId = null;
188
+ const handle = event.currentTarget as HTMLElement;
189
+ if (handle.hasPointerCapture(event.pointerId)) {
190
+ handle.releasePointerCapture(event.pointerId);
191
+ }
192
+ }
193
+
194
+ function resizeWithKeyboard(event: KeyboardEvent): void {
195
+ const { min, max } = resizeBounds();
196
+ let nextWidth = currentFilterWidth();
197
+ if (event.key === "ArrowLeft") nextWidth -= 16;
198
+ else if (event.key === "ArrowRight") nextWidth += 16;
199
+ else if (event.key === "Home") nextWidth = min;
200
+ else if (event.key === "End") nextWidth = max;
201
+ else return;
202
+ event.preventDefault();
203
+ filterWidth = clampFilterWidth(nextWidth);
204
+ }
205
+
206
+ function filterIcon(id: string, selected: boolean): Component {
207
+ return selected ? CheckIcon : registryFilterIcon(id);
208
+ }
209
+ </script>
210
+
211
+ {#snippet filterMark(id: string, selected = false)}
212
+ {@const Icon = filterIcon(id, selected)}
213
+ <span
214
+ class="registry-directory__filter-icon"
215
+ data-filter-icon={selected ? "check" : id}
216
+ >
217
+ <Icon aria-hidden="true" />
218
+ </span>
219
+ {/snippet}
220
+
221
+ {#snippet filters()}
222
+ <div class="registry-directory__filter-groups">
223
+ <Collapsible.Root bind:open={typeOpen} class="registry-directory__group">
224
+ <h2>
225
+ <Collapsible.Trigger
226
+ class="registry-directory__group-trigger"
227
+ aria-label={`${typeOpen ? "Collapse" : "Expand"} Type filters`}
228
+ >
229
+ <span>Type</span>
230
+ <ChevronDownIcon aria-hidden="true" />
231
+ </Collapsible.Trigger>
232
+ </h2>
233
+ <Collapsible.Content class="registry-directory__group-content">
234
+ <button
235
+ type="button"
236
+ class="registry-directory__filter selected"
237
+ aria-pressed="true"
238
+ >
239
+ <span>{@render filterMark("package")} Plugins</span>
240
+ <span>{plugins.length}</span>
241
+ </button>
242
+ </Collapsible.Content>
243
+ </Collapsible.Root>
244
+
245
+ <Collapsible.Root bind:open={statusOpen} class="registry-directory__group">
246
+ <h2>
247
+ <Collapsible.Trigger
248
+ class="registry-directory__group-trigger"
249
+ aria-label={`${statusOpen ? "Collapse" : "Expand"} Status filters`}
250
+ >
251
+ <span>Status</span>
252
+ <ChevronDownIcon aria-hidden="true" />
253
+ </Collapsible.Trigger>
254
+ </h2>
255
+ <Collapsible.Content class="registry-directory__group-content">
256
+ {#each statuses as status}
257
+ <button
258
+ type="button"
259
+ class="registry-directory__filter"
260
+ class:selected={directoryState.statuses.includes(status)}
261
+ aria-pressed={directoryState.statuses.includes(status)}
262
+ onclick={() => toggleListValue("statuses", status)}
263
+ >
264
+ <span>
265
+ {@render filterMark(
266
+ status,
267
+ directoryState.statuses.includes(status),
268
+ )}
269
+ {registryStatusLabel(status)}
270
+ </span>
271
+ <span
272
+ >{countFor(
273
+ (plugin) => (plugin.status ?? "active") === status,
274
+ )}</span
275
+ >
276
+ </button>
277
+ {/each}
278
+ </Collapsible.Content>
279
+ </Collapsible.Root>
280
+
281
+ <Collapsible.Root
282
+ bind:open={categoryOpen}
283
+ class="registry-directory__group"
284
+ >
285
+ <h2>
286
+ <Collapsible.Trigger
287
+ class="registry-directory__group-trigger"
288
+ aria-label={`${categoryOpen ? "Collapse" : "Expand"} Category filters`}
289
+ >
290
+ <span>Categories</span>
291
+ <ChevronDownIcon aria-hidden="true" />
292
+ </Collapsible.Trigger>
293
+ </h2>
294
+ <Collapsible.Content class="registry-directory__group-content">
295
+ {#each categories as category, index (category.id)}
296
+ {#if categoriesExpanded || index < 8}
297
+ <button
298
+ type="button"
299
+ class="registry-directory__filter"
300
+ class:selected={directoryState.categories.includes(category.id)}
301
+ aria-pressed={directoryState.categories.includes(category.id)}
302
+ onclick={() => toggleListValue("categories", category.id)}
303
+ >
304
+ <span>
305
+ {@render filterMark(
306
+ category.id,
307
+ directoryState.categories.includes(category.id),
308
+ )}
309
+ {category.label}
310
+ </span>
311
+ <span>{category.count}</span>
312
+ </button>
313
+ {/if}
314
+ {/each}
315
+ {#if categories.length > 8}
316
+ <button
317
+ class="registry-directory__show-more"
318
+ type="button"
319
+ aria-expanded={categoriesExpanded}
320
+ onclick={() => (categoriesExpanded = !categoriesExpanded)}
321
+ >
322
+ {categoriesExpanded ? "Show less" : "Show more"}
323
+ </button>
324
+ {/if}
325
+ </Collapsible.Content>
326
+ </Collapsible.Root>
327
+
328
+ <Collapsible.Root
329
+ bind:open={platformOpen}
330
+ class="registry-directory__group"
331
+ >
332
+ <h2>
333
+ <Collapsible.Trigger
334
+ class="registry-directory__group-trigger"
335
+ aria-label={`${platformOpen ? "Collapse" : "Expand"} Platform filters`}
336
+ >
337
+ <span>Platforms</span>
338
+ <ChevronDownIcon aria-hidden="true" />
339
+ </Collapsible.Trigger>
340
+ </h2>
341
+ <Collapsible.Content class="registry-directory__group-content">
342
+ {#each platforms as platform}
343
+ <button
344
+ type="button"
345
+ class="registry-directory__filter"
346
+ class:selected={directoryState.platforms.includes(platform)}
347
+ aria-pressed={directoryState.platforms.includes(platform)}
348
+ onclick={() => toggleListValue("platforms", platform)}
349
+ >
350
+ <span>
351
+ {@render filterMark(
352
+ platform,
353
+ directoryState.platforms.includes(platform),
354
+ )}
355
+ {titleCaseRegistryValue(platform)}
356
+ </span>
357
+ <span>
358
+ {countFor((plugin) => plugin.platforms.includes(platform))}
359
+ </span>
360
+ </button>
361
+ {/each}
362
+ </Collapsible.Content>
363
+ </Collapsible.Root>
364
+
365
+ <Collapsible.Root bind:open={otherOpen} class="registry-directory__group">
366
+ <h2>
367
+ <Collapsible.Trigger
368
+ class="registry-directory__group-trigger"
369
+ aria-label={`${otherOpen ? "Collapse" : "Expand"} Other filters`}
370
+ >
371
+ <span>Other</span>
372
+ <ChevronDownIcon aria-hidden="true" />
373
+ </Collapsible.Trigger>
374
+ </h2>
375
+ <Collapsible.Content class="registry-directory__group-content">
376
+ <button
377
+ type="button"
378
+ class="registry-directory__filter"
379
+ class:selected={directoryState.updatesOnly}
380
+ aria-pressed={directoryState.updatesOnly}
381
+ onclick={() =>
382
+ replaceState({ updatesOnly: !directoryState.updatesOnly })}
383
+ >
384
+ <span
385
+ >{@render filterMark(
386
+ "updates",
387
+ directoryState.updatesOnly,
388
+ )}Updates</span
389
+ >
390
+ <span>{countFor((plugin) => updateIds.has(plugin.id))}</span>
391
+ </button>
392
+
393
+ <button
394
+ type="button"
395
+ class="registry-directory__filter"
396
+ class:selected={directoryState.officialOnly}
397
+ aria-pressed={directoryState.officialOnly}
398
+ onclick={() =>
399
+ replaceState({ officialOnly: !directoryState.officialOnly })}
400
+ >
401
+ <span>
402
+ {@render filterMark("official", directoryState.officialOnly)}
403
+ Official
404
+ </span>
405
+ <span>{countFor((plugin) => plugin.channel === "official")}</span>
406
+ </button>
407
+ </Collapsible.Content>
408
+ </Collapsible.Root>
409
+ </div>
410
+ {/snippet}
411
+
412
+ {#snippet filterToggle()}
413
+ <WorkspaceSidebarToggle
414
+ side="left"
415
+ label={filtersOpen ? "Hide plugin filters" : "Show plugin filters"}
416
+ size="small"
417
+ expanded={filtersOpen}
418
+ onSelect={ontogglefilters}
419
+ />
420
+ {/snippet}
421
+
422
+ <AppShell.Body
423
+ element="section"
424
+ bind:ref={bodyElement}
425
+ layout="regions"
426
+ label="Plugin directory"
427
+ class="registry-directory"
428
+ style={filterWidth === undefined
429
+ ? undefined
430
+ : `--ui-shell-body-sidebar-width: ${filterWidth}px`}
431
+ >
432
+ <AppShell.Body.Sidebar
433
+ {...hoverScrollbarProps}
434
+ bind:ref={filterElement}
435
+ panelId="registry-filters"
436
+ side="left"
437
+ label="Plugin filters"
438
+ mobileLabel="Plugin filters"
439
+ class="registry-directory__filters-region"
440
+ >
441
+ <div class="registry-directory__filters">
442
+ <div class="registry-directory__filters-header">
443
+ <strong>Filters</strong>
444
+ {#if activeFilterCount > 0}
445
+ <button type="button" onclick={resetFilters}>Clear</button>
446
+ {/if}
447
+ </div>
448
+ {@render filters()}
449
+ </div>
450
+ </AppShell.Body.Sidebar>
451
+
452
+ {#if !compact}<button
453
+ type="button"
454
+ class="registry-directory__resize-handle"
455
+ data-resizing={resizePointerId === null ? undefined : ""}
456
+ data-min-width={resizeMinimum}
457
+ data-max-width={resizeMaximum}
458
+ data-current-width={presentedFilterWidth}
459
+ aria-label="Resize plugin filters panel"
460
+ title="Drag to resize. Double-click to reset width."
461
+ onpointerdown={beginResize}
462
+ onpointermove={moveResize}
463
+ onpointerup={finishResize}
464
+ onpointercancel={finishResize}
465
+ ondblclick={() => (filterWidth = undefined)}
466
+ onkeydown={resizeWithKeyboard}
467
+ >
468
+ <span aria-hidden="true"></span>
469
+ </button>{/if}
470
+
471
+ <div class="registry-directory__main">
472
+ <nav
473
+ class="registry-directory__heading"
474
+ aria-label="Plugin directory controls"
475
+ data-compact={compact || undefined}
476
+ >
477
+ {#if compact}
478
+ <div class="registry-directory__path">
479
+ {@render filterToggle()}
480
+ <Breadcrumb.Root>
481
+ <Breadcrumb.List>
482
+ <Breadcrumb.Item>
483
+ <Breadcrumb.Link
484
+ href="#/registry"
485
+ onclick={(event) => {
486
+ event.preventDefault();
487
+ onhome();
488
+ }}>Registry</Breadcrumb.Link
489
+ >
490
+ </Breadcrumb.Item>
491
+ <Breadcrumb.Separator />
492
+ <Breadcrumb.Item
493
+ ><Breadcrumb.Page>Plugins</Breadcrumb.Page></Breadcrumb.Item
494
+ >
495
+ </Breadcrumb.List>
496
+ </Breadcrumb.Root>
497
+ </div>
498
+ {/if}
499
+ <div class="registry-directory__tools" aria-label="Result controls">
500
+ <div class="registry-directory__view" aria-label="View mode">
501
+ <Button
502
+ type="button"
503
+ size="sm"
504
+ variant={directoryState.view === "list" ? "secondary" : "ghost"}
505
+ aria-pressed={directoryState.view === "list"}
506
+ onclick={() => replaceState({ view: "list" })}
507
+ >
508
+ <ListIcon data-icon="inline-start" aria-hidden="true" /> List
509
+ </Button>
510
+ <Button
511
+ type="button"
512
+ size="sm"
513
+ variant={directoryState.view === "grid" ? "secondary" : "ghost"}
514
+ aria-pressed={directoryState.view === "grid"}
515
+ onclick={() => replaceState({ view: "grid" })}
516
+ >
517
+ <LayoutGridIcon data-icon="inline-start" aria-hidden="true" /> Cards
518
+ </Button>
519
+ </div>
520
+ <Select.Root
521
+ type="single"
522
+ value={directoryState.sort}
523
+ onValueChange={(value) => {
524
+ if (value) {
525
+ replaceState({ sort: value as RegistryDirectoryState["sort"] });
526
+ }
527
+ }}
528
+ >
529
+ <Select.Trigger
530
+ size="sm"
531
+ class="registry-directory__sort-trigger"
532
+ aria-label="Sort plugins"
533
+ role="combobox"
534
+ >
535
+ <span class="registry-directory__sort-label"
536
+ >{sortLabels[directoryState.sort]}</span
537
+ >
538
+ </Select.Trigger>
539
+ <Select.Content aria-label="Sort plugins">
540
+ <Select.Group>
541
+ {#each Object.entries(sortLabels) as [value, label] (value)}
542
+ <Select.Item {value} {label}>{label}</Select.Item>
543
+ {/each}
544
+ </Select.Group>
545
+ </Select.Content>
546
+ </Select.Root>
547
+ </div>
548
+ </nav>
549
+ <AppShell.Body.Content
550
+ bind:ref={contentRef}
551
+ {...hoverScrollbarProps}
552
+ label="Plugin directory results"
553
+ class="registry-directory__results-region"
554
+ >
555
+ <section class="registry-directory__results">
556
+ <div class="registry-directory__search" data-ui-part="registry-search">
557
+ <p aria-live="polite">
558
+ {results.length}
559
+ {results.length === 1 ? "result" : "results"}
560
+ </p>
561
+ <Input
562
+ value={directoryState.query}
563
+ aria-label="Search registry plugins"
564
+ placeholder="Search plugins"
565
+ oninput={(event) =>
566
+ replaceState({ query: event.currentTarget.value })}
567
+ />
568
+ </div>
569
+
570
+ {#if activeFilterCount > 0}
571
+ <div
572
+ class="registry-directory__active-filters"
573
+ aria-label="Active filters"
574
+ >
575
+ {#each directoryState.categories as value}
576
+ <CommunityChip
577
+ variant="muted"
578
+ ariaLabel={`Remove ${titleCaseRegistryValue(value)} filter`}
579
+ onclick={() => toggleListValue("categories", value)}
580
+ >{titleCaseRegistryValue(value)}
581
+ <XIcon
582
+ data-icon="inline-end"
583
+ aria-hidden="true"
584
+ /></CommunityChip
585
+ >
586
+ {/each}
587
+ {#each directoryState.platforms as value}
588
+ <CommunityChip
589
+ variant="muted"
590
+ onclick={() => toggleListValue("platforms", value)}
591
+ >{titleCaseRegistryValue(value)}
592
+ <XIcon
593
+ data-icon="inline-end"
594
+ aria-hidden="true"
595
+ /></CommunityChip
596
+ >
597
+ {/each}
598
+ {#each directoryState.statuses as value}
599
+ <CommunityChip
600
+ variant="muted"
601
+ onclick={() => toggleListValue("statuses", value)}
602
+ >{registryStatusLabel(value)}
603
+ <XIcon
604
+ data-icon="inline-end"
605
+ aria-hidden="true"
606
+ /></CommunityChip
607
+ >
608
+ {/each}
609
+ {#if directoryState.officialOnly}
610
+ <CommunityChip
611
+ variant="muted"
612
+ onclick={() => replaceState({ officialOnly: false })}
613
+ >Official
614
+ <XIcon
615
+ data-icon="inline-end"
616
+ aria-hidden="true"
617
+ /></CommunityChip
618
+ >
619
+ {/if}
620
+ {#if directoryState.updatesOnly}
621
+ <CommunityChip
622
+ variant="muted"
623
+ ariaLabel="Remove Updates filter"
624
+ onclick={() => replaceState({ updatesOnly: false })}
625
+ >
626
+ Updates <XIcon data-icon="inline-end" aria-hidden="true" />
627
+ </CommunityChip>
628
+ {/if}
629
+ <Tooltip.Provider>
630
+ <Tooltip.Root>
631
+ <Tooltip.Trigger>
632
+ {#snippet child({ props })}
633
+ <Button
634
+ {...props}
635
+ type="button"
636
+ size="icon-xs"
637
+ variant="ghost"
638
+ class="registry-directory__clear-all"
639
+ aria-label="Clear all filters"
640
+ onclick={resetFilters}
641
+ >
642
+ <FilterXIcon
643
+ data-icon="inline-start"
644
+ aria-hidden="true"
645
+ />
646
+ </Button>
647
+ {/snippet}
648
+ </Tooltip.Trigger>
649
+ <Tooltip.Content>Clear all filters</Tooltip.Content>
650
+ </Tooltip.Root>
651
+ </Tooltip.Provider>
652
+ </div>
653
+ {/if}
654
+
655
+ {#if results.length === 0}
656
+ <Empty.Root class="registry-directory__empty">
657
+ <Empty.Header>
658
+ <Empty.Media variant="icon"
659
+ ><PackageIcon aria-hidden="true" /></Empty.Media
660
+ >
661
+ <Empty.Title role="heading" aria-level={2}>
662
+ No plugins match
663
+ </Empty.Title>
664
+ <Empty.Description>
665
+ Try changing the search or plugin filters.
666
+ </Empty.Description>
667
+ </Empty.Header>
668
+ <Empty.Content>
669
+ <Button type="button" variant="outline" onclick={resetFilters}
670
+ >Reset filters</Button
671
+ >
672
+ </Empty.Content>
673
+ </Empty.Root>
674
+ {:else}
675
+ <div class="registry-directory__list" data-view={directoryState.view}>
676
+ {#each results as plugin (plugin.id)}
677
+ <RegistryPluginCard
678
+ {plugin}
679
+ variant={directoryState.view === "grid" ? "card" : "list"}
680
+ onOpen={onOpenPlugin}
681
+ />
682
+ {/each}
683
+ </div>
684
+ {/if}
685
+ </section>
686
+ </AppShell.Body.Content>
687
+ </div>
688
+ </AppShell.Body>
689
+
690
+ <style>
691
+ :global(.registry-directory) {
692
+ --ui-shell-body-sidebar-width: 14rem;
693
+ }
694
+
695
+ .registry-directory__filters {
696
+ display: grid;
697
+ align-content: start;
698
+ min-height: 100%;
699
+ padding: 0 0.65rem 2rem;
700
+ }
701
+
702
+ :global(.registry-directory__filters-region[data-side="left"]) {
703
+ border-inline-end-color: transparent;
704
+ background: var(--background);
705
+ }
706
+
707
+ .registry-directory__filters-header {
708
+ position: sticky;
709
+ z-index: 1;
710
+ inset-block-start: 0;
711
+ display: flex;
712
+ min-height: var(--ui-shell-toolbar-height);
713
+ align-items: center;
714
+ justify-content: space-between;
715
+ padding-inline: 0.5rem;
716
+ background: var(--background);
717
+ }
718
+
719
+ .registry-directory__filters-header strong {
720
+ font-size: var(--community-text-secondary);
721
+ }
722
+
723
+ .registry-directory__filters-header button {
724
+ border: 0;
725
+ padding: 0.25rem;
726
+ color: var(--muted-foreground);
727
+ background: transparent;
728
+ font-size: var(--community-text-secondary);
729
+ cursor: pointer;
730
+ }
731
+
732
+ .registry-directory__filters-header button:hover,
733
+ .registry-directory__filters-header button:focus-visible {
734
+ color: var(--foreground);
735
+ }
736
+
737
+ .registry-directory__filter-groups {
738
+ display: grid;
739
+ align-content: start;
740
+ gap: 0.35rem;
741
+ }
742
+
743
+ :global(.registry-directory__group) {
744
+ display: grid;
745
+ padding-block-end: 0.2rem;
746
+ }
747
+
748
+ .registry-directory__filter-groups h2 {
749
+ margin: 0;
750
+ }
751
+
752
+ :global(.registry-directory__group-trigger) {
753
+ display: flex;
754
+ width: 100%;
755
+ align-items: center;
756
+ justify-content: space-between;
757
+ border: 0;
758
+ border-radius: var(--radius);
759
+ padding: 0.5rem;
760
+ color: var(--muted-foreground);
761
+ background: transparent;
762
+ font-size: var(--community-text-secondary);
763
+ font-weight: 700;
764
+ letter-spacing: 0.08em;
765
+ text-align: start;
766
+ text-transform: uppercase;
767
+ cursor: pointer;
768
+ }
769
+
770
+ :global(.registry-directory__group-trigger:hover),
771
+ :global(.registry-directory__group-trigger:focus-visible) {
772
+ color: var(--foreground);
773
+ background: color-mix(in srgb, var(--accent) 60%, transparent);
774
+ outline: none;
775
+ }
776
+
777
+ :global(.registry-directory__group-trigger svg) {
778
+ width: 0.85rem;
779
+ height: 0.85rem;
780
+ transition: transform 120ms ease;
781
+ }
782
+
783
+ :global(.registry-directory__group-trigger[data-state="closed"] svg) {
784
+ transform: rotate(-90deg);
785
+ }
786
+
787
+ :global(.registry-directory__group-content) {
788
+ display: grid;
789
+ gap: 0.2rem;
790
+ }
791
+
792
+ .registry-directory__filter,
793
+ .registry-directory__show-more {
794
+ display: flex;
795
+ align-items: center;
796
+ justify-content: space-between;
797
+ gap: 0.5rem;
798
+ border: 0;
799
+ border-radius: var(--radius);
800
+ padding: 0.42rem 0.5rem;
801
+ color: var(--foreground);
802
+ background: transparent;
803
+ text-align: start;
804
+ cursor: pointer;
805
+ }
806
+
807
+ .registry-directory__filter:hover,
808
+ .registry-directory__filter:focus-visible,
809
+ .registry-directory__filter.selected {
810
+ background: color-mix(in srgb, var(--accent) 86%, transparent);
811
+ outline: none;
812
+ }
813
+
814
+ .registry-directory__filter.selected {
815
+ font-weight: 650;
816
+ }
817
+
818
+ .registry-directory__filter > span:first-child {
819
+ display: inline-flex;
820
+ align-items: center;
821
+ gap: 0.45rem;
822
+ }
823
+
824
+ .registry-directory__filter > span:last-child {
825
+ color: var(--muted-foreground);
826
+ font-size: var(--community-text-secondary);
827
+ }
828
+
829
+ .registry-directory__filter-icon {
830
+ display: inline-grid;
831
+ width: 1rem;
832
+ height: 1rem;
833
+ flex: none;
834
+ place-items: center;
835
+ }
836
+
837
+ .registry-directory__filter-icon :global(svg) {
838
+ width: 0.9rem;
839
+ height: 0.9rem;
840
+ }
841
+
842
+ .registry-directory__filter-icon[data-filter-icon="check"] {
843
+ border-radius: 999px;
844
+ color: var(--primary-foreground);
845
+ background: var(--primary);
846
+ }
847
+
848
+ .registry-directory__filter-icon[data-filter-icon="check"] :global(svg) {
849
+ width: 0.7rem;
850
+ height: 0.7rem;
851
+ }
852
+
853
+ .registry-directory__show-more {
854
+ color: var(--muted-foreground);
855
+ font-size: var(--community-text-secondary);
856
+ }
857
+
858
+ .registry-directory__resize-handle {
859
+ position: relative;
860
+ z-index: 3;
861
+ order: 0;
862
+ width: 1px;
863
+ flex: none;
864
+ margin-inline-start: -1px;
865
+ padding: 0;
866
+ border: 0;
867
+ background: transparent;
868
+ cursor: col-resize;
869
+ touch-action: none;
870
+ }
871
+
872
+ .registry-directory__resize-handle > span {
873
+ position: absolute;
874
+ inset-block: 0;
875
+ inset-inline-start: -0.25rem;
876
+ width: 0.5rem;
877
+ }
878
+
879
+ .registry-directory__resize-handle > span::after {
880
+ position: absolute;
881
+ inset-block: 0;
882
+ inset-inline-start: calc(50% - 0.5px);
883
+ width: 1px;
884
+ background: transparent;
885
+ content: "";
886
+ transition: background-color 120ms ease;
887
+ }
888
+
889
+ .registry-directory__resize-handle:hover > span::after,
890
+ .registry-directory__resize-handle:focus-visible > span::after,
891
+ .registry-directory__resize-handle[data-resizing] > span::after {
892
+ background: var(--ring);
893
+ }
894
+
895
+ .registry-directory__resize-handle:focus-visible {
896
+ outline: none;
897
+ }
898
+
899
+ .registry-directory__main {
900
+ display: flex;
901
+ flex: 1;
902
+ order: 1;
903
+ flex-direction: column;
904
+ min-width: 0;
905
+ min-height: 0;
906
+ container: registry-results / inline-size;
907
+ }
908
+ .registry-directory__results {
909
+ display: grid;
910
+ align-content: start;
911
+ gap: 1rem;
912
+ width: min(100%, 78rem);
913
+ min-width: 0;
914
+ min-height: 100%;
915
+ margin-inline: auto;
916
+ padding: 1rem clamp(1rem, 4cqi, 3rem) clamp(1rem, 4cqi, 3rem);
917
+ }
918
+
919
+ :global(
920
+ .registry-directory__results-region
921
+ [data-ui-part="scroll-area-viewport"]
922
+ > div
923
+ ) {
924
+ min-height: 100%;
925
+ }
926
+
927
+ .registry-directory__heading {
928
+ flex-shrink: 0;
929
+ margin: 0.5rem;
930
+ display: flex;
931
+ flex-wrap: nowrap;
932
+ align-items: center;
933
+ justify-content: space-between;
934
+ gap: 1rem;
935
+ }
936
+
937
+ .registry-directory__path {
938
+ display: flex;
939
+ flex: 1;
940
+ align-items: center;
941
+ gap: 0.25rem;
942
+ min-width: 0;
943
+ }
944
+
945
+ .registry-directory__search {
946
+ display: grid;
947
+ gap: 0.4rem;
948
+ min-width: 0;
949
+ }
950
+
951
+ .registry-directory__search p {
952
+ margin: 0;
953
+ color: var(--muted-foreground);
954
+ }
955
+
956
+ :global(.registry-directory__clear-all[data-ui-component="button"]) {
957
+ border-radius: 50%;
958
+ flex: none;
959
+ }
960
+
961
+ .registry-directory__tools,
962
+ .registry-directory__view {
963
+ display: flex;
964
+ align-items: center;
965
+ gap: 0.35rem;
966
+ }
967
+
968
+ .registry-directory__tools {
969
+ flex-wrap: nowrap;
970
+ justify-content: flex-end;
971
+ margin-inline-start: auto;
972
+ min-width: 0;
973
+ }
974
+
975
+ :global(.registry-directory__sort-trigger) {
976
+ width: 11rem;
977
+ max-width: 100%;
978
+ justify-content: space-between;
979
+ }
980
+
981
+ .registry-directory__heading[data-compact] .registry-directory__tools {
982
+ margin-inline-start: 0;
983
+ flex: none;
984
+ }
985
+
986
+ .registry-directory__heading[data-compact]
987
+ :global(.registry-directory__sort-trigger) {
988
+ width: 11rem;
989
+ }
990
+
991
+ .registry-directory__sort-label {
992
+ min-width: 0;
993
+ overflow: hidden;
994
+ text-overflow: ellipsis;
995
+ white-space: nowrap;
996
+ }
997
+
998
+ .registry-directory__active-filters {
999
+ display: flex;
1000
+ flex-wrap: wrap;
1001
+ gap: 0.4rem;
1002
+ }
1003
+
1004
+ .registry-directory__list {
1005
+ display: grid;
1006
+ gap: 0.75rem;
1007
+ }
1008
+
1009
+ .registry-directory__list[data-view="grid"] {
1010
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1011
+ }
1012
+
1013
+ :global(.registry-directory__empty) {
1014
+ border: 1px dashed var(--border);
1015
+ }
1016
+
1017
+ @container registry-results (max-width: 48rem) {
1018
+ .registry-directory__list[data-view="grid"] {
1019
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1020
+ }
1021
+ }
1022
+
1023
+ @container registry-results (max-width: 32rem) {
1024
+ .registry-directory__heading {
1025
+ gap: 0.5rem;
1026
+ }
1027
+
1028
+ .registry-directory__view {
1029
+ display: none;
1030
+ }
1031
+
1032
+ :global(.registry-directory__sort-trigger) {
1033
+ width: 5rem;
1034
+ }
1035
+
1036
+ .registry-directory__heading[data-compact]
1037
+ :global(.registry-directory__sort-trigger) {
1038
+ width: 5rem;
1039
+ }
1040
+
1041
+ .registry-directory__path :global([data-ui-part="breadcrumb"]) {
1042
+ min-width: 0;
1043
+ }
1044
+
1045
+ .registry-directory__path :global([data-ui-part="breadcrumb-list"]) {
1046
+ flex-wrap: nowrap;
1047
+ gap: 0.25rem;
1048
+ }
1049
+
1050
+ .registry-directory__path :global([data-ui-part="breadcrumb-item"]) {
1051
+ min-width: 0;
1052
+ }
1053
+
1054
+ .registry-directory__path
1055
+ :global([data-ui-part="breadcrumb-item"]:last-child) {
1056
+ flex: none;
1057
+ }
1058
+
1059
+ .registry-directory__path :global([data-ui-part="breadcrumb-link"]) {
1060
+ overflow: hidden;
1061
+ text-overflow: ellipsis;
1062
+ white-space: nowrap;
1063
+ }
1064
+
1065
+ .registry-directory__list[data-view="grid"] {
1066
+ grid-template-columns: minmax(0, 1fr);
1067
+ }
1068
+ }
1069
+ </style>