feat: plugins v1.7
This commit is contained in:
@@ -61,6 +61,7 @@ type PluginManagerTab = 'docs' | 'extensions' | 'installed' | 'logs' | 'requirem
|
||||
})
|
||||
export class PluginManagerComponent {
|
||||
@Output() readonly closed = new EventEmitter<void>();
|
||||
@Output() readonly storeOpened = new EventEmitter<void>();
|
||||
|
||||
readonly scope = input<TojuPluginInstallScope>('client');
|
||||
|
||||
@@ -149,7 +150,7 @@ export class PluginManagerComponent {
|
||||
openStore(): void {
|
||||
const returnUrl = this.router.url.startsWith('/plugin-store') ? '/search' : this.router.url;
|
||||
|
||||
this.closed.emit();
|
||||
this.storeOpened.emit();
|
||||
void this.router.navigate(['/plugin-store'], { queryParams: { returnUrl } });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,255 +1,332 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
|
||||
<main
|
||||
class="plugin-store"
|
||||
class="min-h-[calc(100vh-2.5rem)] bg-background px-3 py-4 text-foreground sm:px-6"
|
||||
data-testid="plugin-store-page"
|
||||
>
|
||||
<header class="plugin-store__topbar">
|
||||
<div class="plugin-store__title-row">
|
||||
<header class="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
(click)="goBack()"
|
||||
class="plugin-store__icon-button"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Back to app"
|
||||
>
|
||||
<ng-icon name="lucideArrowLeft" />
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="plugin-store__brand-icon">
|
||||
<ng-icon name="lucideStore" />
|
||||
<div class="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<ng-icon
|
||||
name="lucideStore"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="plugin-store__title-copy">
|
||||
<h1>Plugin Store</h1>
|
||||
<p>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-xl font-semibold leading-7">Plugin Store</h1>
|
||||
<p class="truncate text-sm text-muted-foreground">
|
||||
{{ installedCount() }} installed for {{ store.installScopeLabel() }} · {{ totalSourcePlugins() }} available · {{ sourceCount() }} sources
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plugin-store__top-actions">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="openManager()"
|
||||
class="plugin-store__secondary-button"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon name="lucideSettings" />
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Manage Plugins
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="refreshSources()"
|
||||
[disabled]="store.isLoading()"
|
||||
class="plugin-store__secondary-button"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
[class.is-spinning]="store.isLoading()"
|
||||
class="h-4 w-4"
|
||||
[class.animate-spin]="store.isLoading()"
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="plugin-store__source-strip">
|
||||
<div class="plugin-store__source-form">
|
||||
<label class="plugin-store__input-shell plugin-store__source-input">
|
||||
<section class="grid gap-3 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row">
|
||||
<label class="relative flex min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newSourceUrl"
|
||||
(keyup.enter)="addSourceUrl()"
|
||||
placeholder="https://example.com/plugins.json or /home/me/plugins/source.json"
|
||||
aria-label="Plugin source manifest URL"
|
||||
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
(click)="addSourceUrl()"
|
||||
[disabled]="!newSourceUrl.trim() || store.isLoading()"
|
||||
class="plugin-store__primary-button"
|
||||
class="inline-flex min-h-9 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<ng-icon name="lucidePlus" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (sourceError()) {
|
||||
<p class="plugin-store__error-text">{{ sourceError() }}</p>
|
||||
<p class="text-sm text-destructive">{{ sourceError() }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="plugin-store__layout">
|
||||
<div
|
||||
class="grid items-start gap-3"
|
||||
[ngClass]="readme() ? 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)_minmax(24rem,38rem)]' : 'xl:grid-cols-[minmax(13rem,17rem)_minmax(0,1fr)]'"
|
||||
>
|
||||
<aside
|
||||
class="plugin-store__rail"
|
||||
aria-label="Plugin sources"
|
||||
class="grid gap-3 xl:sticky xl:top-3"
|
||||
aria-label="Plugin sources and filters"
|
||||
>
|
||||
<section class="plugin-store__panel">
|
||||
<div class="plugin-store__panel-header">
|
||||
<h2>Sources</h2>
|
||||
<span>{{ sourceCount() }}</span>
|
||||
<section class="grid min-w-0 gap-1 rounded-lg border border-border bg-card p-3">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<h2 class="text-xs font-bold uppercase text-foreground">Sources</h2>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ sourceCount() }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="plugin-store__source-filter"
|
||||
[class.is-active]="selectedSourceUrl() === null"
|
||||
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
[class.bg-secondary]="selectedSourceUrl() === null"
|
||||
[class.text-foreground]="selectedSourceUrl() === null"
|
||||
(click)="selectSource(null)"
|
||||
>
|
||||
<span>All sources</span>
|
||||
<strong>{{ totalSourcePlugins() }}</strong>
|
||||
<span class="truncate">All sources</span>
|
||||
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ totalSourcePlugins() }}</strong>
|
||||
</button>
|
||||
|
||||
@for (source of store.sources(); track source.url) {
|
||||
<div
|
||||
class="plugin-store__source-row"
|
||||
[class.has-error]="!!source.error"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="plugin-store__source-filter"
|
||||
[class.is-active]="selectedSourceUrl() === source.url"
|
||||
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
[class.bg-secondary]="selectedSourceUrl() === source.url"
|
||||
[class.text-foreground]="selectedSourceUrl() === source.url"
|
||||
(click)="selectSource(source.url)"
|
||||
>
|
||||
<span>{{ source.title || source.url }}</span>
|
||||
<strong>{{ source.plugins.length }}</strong>
|
||||
<span class="truncate">{{ source.title || source.url }}</span>
|
||||
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ source.plugins.length }}</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeSourceUrl(source.url)"
|
||||
class="plugin-store__icon-button plugin-store__icon-button--danger"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Remove source"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (source.error) {
|
||||
<p class="plugin-store__source-error">{{ source.error }}</p>
|
||||
<p class="px-2 text-xs text-destructive">{{ source.error }}</p>
|
||||
}
|
||||
}
|
||||
|
||||
@for (sourceUrl of pendingSourceUrls(); track sourceUrl) {
|
||||
<div class="plugin-store__source-row">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="plugin-store__source-filter"
|
||||
[class.is-active]="selectedSourceUrl() === sourceUrl"
|
||||
class="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
[class.bg-secondary]="selectedSourceUrl() === sourceUrl"
|
||||
[class.text-foreground]="selectedSourceUrl() === sourceUrl"
|
||||
(click)="selectSource(sourceUrl)"
|
||||
>
|
||||
<span>{{ sourceUrl }}</span>
|
||||
<strong>0</strong>
|
||||
<span class="truncate">{{ sourceUrl }}</span>
|
||||
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">0</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="removeSourceUrl(sourceUrl)"
|
||||
class="plugin-store__icon-button plugin-store__icon-button--danger"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Remove source"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="plugin-store__panel">
|
||||
<div class="plugin-store__panel-header">
|
||||
<h2>Filters</h2>
|
||||
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-xs font-bold uppercase text-foreground">Filters</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="plugin-store__toggle-button"
|
||||
[class.is-active]="showInstalledOnly()"
|
||||
class="flex min-w-0 items-center justify-between gap-2 rounded-md px-2 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
[class.bg-secondary]="showInstalledOnly()"
|
||||
[class.text-foreground]="showInstalledOnly()"
|
||||
(click)="toggleInstalledOnly()"
|
||||
>
|
||||
<span>Installed only</span>
|
||||
<strong>{{ installedCount() }}</strong>
|
||||
<strong class="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">{{ installedCount() }}</strong>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid min-w-0 gap-2 rounded-lg border border-border bg-card p-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-xs font-bold uppercase text-foreground">Install server</h2>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ manageableServers().length }}</span>
|
||||
</div>
|
||||
|
||||
@if (manageableServers().length > 0) {
|
||||
@for (server of manageableServers(); track trackServer($index, server)) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectStoreServer(server.id)"
|
||||
class="group flex min-w-0 items-start gap-2 rounded-md border border-transparent px-2 py-2 text-left transition-colors hover:border-primary/40 hover:bg-secondary"
|
||||
[class.border-primary]="selectedStoreServerId() === server.id"
|
||||
[ngClass]="{ 'bg-primary/10': selectedStoreServerId() === server.id }"
|
||||
>
|
||||
<span
|
||||
class="mt-1 h-2 w-2 shrink-0 rounded-full bg-muted-foreground group-hover:bg-primary"
|
||||
[class.bg-primary]="selectedStoreServerId() === server.id"
|
||||
></span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-semibold text-foreground">{{ server.name }}</span>
|
||||
<span class="block truncate text-xs text-muted-foreground">{{ server.sourceUrl || 'Default endpoint' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
<p class="rounded-md border border-border bg-secondary/40 px-2 py-2 text-xs leading-5 text-muted-foreground">
|
||||
No server is available for plugin installs. Owner or Manage Server access is required.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
class="plugin-store__catalog"
|
||||
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3"
|
||||
aria-label="Available plugins"
|
||||
>
|
||||
<div class="plugin-store__toolbar">
|
||||
<label class="plugin-store__input-shell plugin-store__search">
|
||||
<ng-icon name="lucideSearch" />
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label class="relative flex min-w-0 flex-1 sm:max-w-xl">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="searchTerm.set($event)"
|
||||
placeholder="Search plugins, authors, ids"
|
||||
aria-label="Search plugins"
|
||||
class="min-h-9 w-full rounded-lg border border-border bg-secondary py-2 pl-9 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="plugin-store__count">{{ filteredPlugins().length }} shown</div>
|
||||
<div class="text-sm text-muted-foreground">{{ filteredPlugins().length }} shown</div>
|
||||
</div>
|
||||
|
||||
@if (actionError()) {
|
||||
<p class="plugin-store__error-banner">{{ actionError() }}</p>
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ actionError() }}</p>
|
||||
}
|
||||
|
||||
@if (readmeError()) {
|
||||
<p class="plugin-store__error-banner">{{ readmeError() }}</p>
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ readmeError() }}</p>
|
||||
}
|
||||
|
||||
@if (filteredPlugins().length > 0) {
|
||||
<div class="plugin-store__grid">
|
||||
<div class="grid gap-3">
|
||||
@for (plugin of filteredPlugins(); track trackPlugin($index, plugin)) {
|
||||
<article class="plugin-card">
|
||||
<div class="plugin-card__media">
|
||||
<article class="grid min-w-0 overflow-hidden rounded-lg border border-border bg-background sm:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||
<div class="grid min-h-24 place-items-center bg-secondary text-muted-foreground sm:min-h-full">
|
||||
@if (plugin.imageUrl) {
|
||||
<img
|
||||
[src]="plugin.imageUrl"
|
||||
[alt]="plugin.title"
|
||||
(error)="hideBrokenImage($event)"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucidePackage" />
|
||||
<ng-icon
|
||||
name="lucidePackage"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="plugin-card__body">
|
||||
<div class="plugin-card__header">
|
||||
<div>
|
||||
<h2>{{ plugin.title }}</h2>
|
||||
<p>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
|
||||
<div class="grid min-w-0 gap-2 p-3">
|
||||
<div class="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-base font-semibold leading-6">{{ plugin.title }}</h2>
|
||||
<p class="truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</p>
|
||||
</div>
|
||||
|
||||
@if (store.getInstallState(plugin) === 'updateAvailable') {
|
||||
<span class="plugin-card__badge">Update</span>
|
||||
} @else if (store.getInstallState(plugin) === 'installed') {
|
||||
<span class="plugin-card__badge plugin-card__badge--installed">Installed</span>
|
||||
@if (getPluginInstallState(plugin) === 'updateAvailable') {
|
||||
<span
|
||||
class="self-start whitespace-nowrap rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-primary"
|
||||
>Update</span
|
||||
>
|
||||
}
|
||||
@if (getPluginInstallState(plugin) === 'installed') {
|
||||
<span
|
||||
class="self-start whitespace-nowrap rounded-full bg-emerald-600/10 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-600"
|
||||
>Installed</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="plugin-card__description">{{ plugin.description }}</p>
|
||||
<p class="line-clamp-2 min-h-10 text-sm text-muted-foreground">{{ plugin.description }}</p>
|
||||
|
||||
<div class="plugin-card__meta">
|
||||
<span>{{ plugin.id }}</span>
|
||||
<span>{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.id }}</span>
|
||||
<span class="max-w-full truncate rounded-full bg-secondary px-2 py-1">{{ plugin.sourceTitle || plugin.sourceUrl }}</span>
|
||||
</div>
|
||||
|
||||
<div class="plugin-card__actions">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="runPrimaryAction(plugin)"
|
||||
[disabled]="isPrimaryActionDisabled(plugin)"
|
||||
[title]="serverInstallButtonTitle(plugin)"
|
||||
class="plugin-store__primary-button plugin-card__primary-action"
|
||||
[class.plugin-card__primary-action--danger]="store.getActionLabel(plugin) === 'Uninstall' || store.getActionLabel(plugin) === 'Remove from Server'"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
[ngClass]="{
|
||||
'border-destructive/35':
|
||||
getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server',
|
||||
'bg-destructive/10': getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'
|
||||
}"
|
||||
[class.text-destructive]="getPrimaryActionLabel(plugin) === 'Uninstall' || getPrimaryActionLabel(plugin) === 'Remove from Server'"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="primaryActionIcon(plugin)"
|
||||
[class.is-spinning]="isPluginBusy(plugin)"
|
||||
class="h-4 w-4"
|
||||
[class.animate-spin]="isPluginBusy(plugin)"
|
||||
/>
|
||||
{{ store.getActionLabel(plugin) }}
|
||||
{{ getPrimaryActionLabel(plugin) }}
|
||||
</button>
|
||||
|
||||
@if (plugin.readmeUrl) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="loadReadme(plugin)"
|
||||
class="plugin-store__text-button"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg px-3 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Load readme"
|
||||
>
|
||||
{{ isReadmeLoading(plugin) ? 'Loading' : 'Readme' }}
|
||||
@@ -260,10 +337,13 @@
|
||||
<button
|
||||
type="button"
|
||||
(click)="openExternal(plugin.githubUrl)"
|
||||
class="plugin-store__icon-button"
|
||||
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Open GitHub"
|
||||
>
|
||||
<ng-icon name="lucideExternalLink" />
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -272,43 +352,77 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<section class="plugin-store__empty">
|
||||
<ng-icon name="lucidePackage" />
|
||||
<h2>No plugins found</h2>
|
||||
<p>{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}</p>
|
||||
<section class="grid min-h-56 place-items-center rounded-lg border border-dashed border-border bg-background p-8 text-center">
|
||||
<div class="grid justify-items-center gap-2">
|
||||
<ng-icon
|
||||
name="lucidePackage"
|
||||
class="h-7 w-7 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-base font-semibold">No plugins found</h2>
|
||||
<p class="max-w-md text-sm text-muted-foreground">
|
||||
{{ sourceCount() ? 'Adjust filters or add another source manifest.' : 'Add a plugin source manifest URL to populate the catalog.' }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (readme()) {
|
||||
<aside
|
||||
class="plugin-store__readme"
|
||||
class="grid min-w-0 gap-3 rounded-lg border border-border bg-card p-3 xl:sticky xl:top-3 xl:max-h-[calc(100vh-6rem)]"
|
||||
aria-label="Plugin readme"
|
||||
>
|
||||
<div class="plugin-store__readme-header">
|
||||
<div>
|
||||
<p>Readme</p>
|
||||
<h2>{{ readme()!.title }}</h2>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1 text-xs font-bold uppercase text-primary">Readme</p>
|
||||
<h2 class="truncate text-base font-semibold">{{ readme()!.title }}</h2>
|
||||
@if (selectedReadmePlugin(); as plugin) {
|
||||
<span>{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
|
||||
<span class="block truncate text-sm text-muted-foreground">{{ plugin.author || 'Unknown author' }} · v{{ plugin.version }}</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeReadme()"
|
||||
class="plugin-store__icon-button"
|
||||
title="Close readme"
|
||||
>
|
||||
<ng-icon name="lucideX" />
|
||||
</button>
|
||||
<div class="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleReadmeRawMode()"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
{{ readmeRawMode() ? 'Parsed' : 'Raw' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeReadme()"
|
||||
class="grid h-8 w-8 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Close readme"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre>{{ readme()!.markdown }}</pre>
|
||||
|
||||
@if (readmeRawMode()) {
|
||||
<pre class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/50 p-3 text-sm whitespace-pre-wrap">{{
|
||||
readme()!.markdown
|
||||
}}</pre>
|
||||
} @else {
|
||||
<div
|
||||
class="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg bg-secondary/30 p-3 text-sm leading-6 [&_a]:text-primary [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-background [&_code]:px-1 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:mt-3 [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-3 [&_pre]:mb-3 [&_pre]:overflow-auto [&_pre]:rounded-lg [&_pre]:bg-background [&_pre]:p-3 [&_ul]:list-disc"
|
||||
>
|
||||
<app-chat-message-markdown [content]="readme()!.markdown" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="openExternal(readme()!.url)"
|
||||
class="plugin-store__secondary-button plugin-store__readme-link"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary"
|
||||
>
|
||||
<ng-icon name="lucideExternalLink" />
|
||||
<ng-icon
|
||||
name="lucideExternalLink"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Open source readme
|
||||
</button>
|
||||
</aside>
|
||||
@@ -317,37 +431,46 @@
|
||||
|
||||
@if (serverInstallDialog(); as dialog) {
|
||||
<div
|
||||
class="plugin-store__modal-backdrop"
|
||||
class="fixed inset-0 z-[80] bg-black/60"
|
||||
role="presentation"
|
||||
></div>
|
||||
<section
|
||||
class="plugin-store__install-modal"
|
||||
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="server-plugin-install-title"
|
||||
>
|
||||
<header class="plugin-store__install-header">
|
||||
<div>
|
||||
<p>Server plugin install</p>
|
||||
<h2 id="server-plugin-install-title">{{ dialog.manifest.title }}</h2>
|
||||
<header class="flex items-start justify-between gap-4 border-b border-border p-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm text-muted-foreground">Server plugin install</p>
|
||||
<h2
|
||||
id="server-plugin-install-title"
|
||||
class="mt-1 truncate text-lg font-semibold"
|
||||
>
|
||||
{{ dialog.manifest.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeServerInstallDialog()"
|
||||
class="plugin-store__icon-button"
|
||||
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title="Cancel install"
|
||||
>
|
||||
<ng-icon name="lucideX" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="plugin-store__install-body">
|
||||
<label class="plugin-store__field">
|
||||
<span>Install to server</span>
|
||||
<div class="grid min-h-0 gap-4 overflow-auto p-4">
|
||||
<label class="grid gap-2">
|
||||
<span class="text-sm text-muted-foreground">Install to server</span>
|
||||
<select
|
||||
[value]="dialog.selectedServerId"
|
||||
[disabled]="serverInstallBusy()"
|
||||
(change)="selectServerInstallTarget($any($event.target).value)"
|
||||
class="min-h-9 rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
@for (server of manageableServers(); track trackServer($index, server)) {
|
||||
<option [value]="server.id">{{ server.name }}</option>
|
||||
@@ -355,7 +478,7 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="plugin-store__capability-row">
|
||||
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="serverInstallOptional()"
|
||||
@@ -365,15 +488,15 @@
|
||||
<span>Optional for server members</span>
|
||||
</label>
|
||||
|
||||
<div class="plugin-store__capability-list">
|
||||
<div class="plugin-store__capability-list-header">
|
||||
<h3>Capabilities</h3>
|
||||
<span>{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">Capabilities</h3>
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">{{ dialog.manifest.capabilities?.length ?? 0 }}</span>
|
||||
</div>
|
||||
|
||||
@if ((dialog.manifest.capabilities?.length ?? 0) > 0) {
|
||||
@for (capability of dialog.manifest.capabilities; track trackInstallCapability($index, capability)) {
|
||||
<label class="plugin-store__capability-row">
|
||||
<label class="flex items-center gap-2 rounded-lg border border-border bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="selectedCapabilityIds().has(capability)"
|
||||
@@ -384,21 +507,21 @@
|
||||
</label>
|
||||
}
|
||||
} @else {
|
||||
<p class="plugin-store__muted-text">This plugin requests no capabilities.</p>
|
||||
<p class="text-sm text-muted-foreground">This plugin requests no capabilities.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (serverInstallError()) {
|
||||
<p class="plugin-store__error-banner">{{ serverInstallError() }}</p>
|
||||
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ serverInstallError() }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<footer class="plugin-store__install-actions">
|
||||
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
||||
<button
|
||||
type="button"
|
||||
(click)="closeServerInstallDialog()"
|
||||
[disabled]="serverInstallBusy()"
|
||||
class="plugin-store__secondary-button"
|
||||
class="inline-flex min-h-8 items-center justify-center rounded-lg border border-border bg-card px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -406,11 +529,12 @@
|
||||
type="button"
|
||||
(click)="confirmServerInstall()"
|
||||
[disabled]="serverInstallBusy() || !dialog.selectedServerId"
|
||||
class="plugin-store__primary-button"
|
||||
class="inline-flex min-h-8 items-center justify-center gap-2 rounded-lg border border-primary bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
[class.is-spinning]="serverInstallBusy()"
|
||||
class="h-4 w-4"
|
||||
[class.animate-spin]="serverInstallBusy()"
|
||||
/>
|
||||
Install and Activate
|
||||
</button>
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.plugin-store {
|
||||
min-height: calc(100vh - 2.5rem);
|
||||
padding: 1rem clamp(0.75rem, 1.6vw, 1.5rem);
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.plugin-store__topbar,
|
||||
.plugin-store__title-row,
|
||||
.plugin-store__top-actions,
|
||||
.plugin-store__source-form,
|
||||
.plugin-store__toolbar,
|
||||
.plugin-store__source-row,
|
||||
.plugin-store__source-filter,
|
||||
.plugin-store__toggle-button,
|
||||
.plugin-card__actions,
|
||||
.plugin-card__meta,
|
||||
.plugin-store__panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-store__topbar {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0.875rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.plugin-store__title-row,
|
||||
.plugin-store__top-actions,
|
||||
.plugin-store__source-form,
|
||||
.plugin-store__toolbar,
|
||||
.plugin-card__actions,
|
||||
.plugin-card__meta {
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.plugin-store__title-copy,
|
||||
.plugin-store__title-copy h1,
|
||||
.plugin-store__title-copy p,
|
||||
.plugin-store__source-filter span,
|
||||
.plugin-store__count,
|
||||
.plugin-card__header h2,
|
||||
.plugin-card__header p,
|
||||
.plugin-card__meta span,
|
||||
.plugin-store__readme-header h2,
|
||||
.plugin-store__readme-header span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-store__title-copy h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.8rem;
|
||||
}
|
||||
|
||||
.plugin-store__title-copy p,
|
||||
.plugin-store__count,
|
||||
.plugin-card__header p,
|
||||
.plugin-card__description,
|
||||
.plugin-card__meta,
|
||||
.plugin-store__source-error,
|
||||
.plugin-store__error-text,
|
||||
.plugin-store__readme-header span {
|
||||
margin: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.plugin-store__brand-icon,
|
||||
.plugin-store__icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-store__brand-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.plugin-store__icon-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plugin-store__icon-button:hover,
|
||||
.plugin-store__secondary-button:hover,
|
||||
.plugin-store__text-button:hover,
|
||||
.plugin-store__source-filter:hover,
|
||||
.plugin-store__toggle-button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.plugin-store__icon-button--danger:hover {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
|
||||
.plugin-store__primary-button,
|
||||
.plugin-store__secondary-button,
|
||||
.plugin-store__text-button {
|
||||
display: inline-flex;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.plugin-store__primary-button {
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
ng-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.plugin-store__brand-icon ng-icon,
|
||||
.plugin-store__empty ng-icon,
|
||||
.plugin-card__media ng-icon {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
}
|
||||
|
||||
.plugin-store__source-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.plugin-store__source-form {
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.plugin-store__input-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.plugin-store__input-shell ng-icon {
|
||||
position: absolute;
|
||||
left: 0.7rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.plugin-store__input-shell input {
|
||||
width: 100%;
|
||||
min-height: 2.2rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.plugin-store__search input {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.plugin-store__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(13rem, 17rem) minmax(0, 1fr) minmax(18rem, 24rem);
|
||||
gap: 0.875rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.plugin-store__rail {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-store__panel,
|
||||
.plugin-store__catalog,
|
||||
.plugin-store__readme,
|
||||
.plugin-card,
|
||||
.plugin-store__empty {
|
||||
min-width: 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.plugin-store__panel,
|
||||
.plugin-store__catalog,
|
||||
.plugin-store__readme {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-store__panel {
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.plugin-store__panel-header {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-store__panel-header h2,
|
||||
.plugin-store__readme-header h2,
|
||||
.plugin-card__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-store__panel-header h2 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.plugin-store__panel-header span,
|
||||
.plugin-store__source-filter strong,
|
||||
.plugin-store__toggle-button strong,
|
||||
.plugin-card__meta span {
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.45rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--secondary));
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.plugin-store__source-row {
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-store__source-filter,
|
||||
.plugin-store__toggle-button {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.plugin-store__source-filter.is-active,
|
||||
.plugin-store__toggle-button.is-active {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.plugin-store__source-error,
|
||||
.plugin-store__error-text,
|
||||
.plugin-store__error-banner {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.plugin-store__error-banner {
|
||||
margin: 0;
|
||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.plugin-store__toolbar {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.plugin-store__search {
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.plugin-store__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-card__media {
|
||||
display: grid;
|
||||
min-height: 100%;
|
||||
place-items: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.plugin-card__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.plugin-card__body {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.plugin-card__header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-card__header h2,
|
||||
.plugin-store__readme-header h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.plugin-card__badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.18rem 0.45rem;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-card__badge--installed {
|
||||
color: rgb(5 150 105);
|
||||
background: rgb(5 150 105 / 0.1);
|
||||
}
|
||||
|
||||
.plugin-card__description {
|
||||
display: -webkit-box;
|
||||
min-height: 2.45rem;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.plugin-card__meta,
|
||||
.plugin-card__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-card__primary-action--danger {
|
||||
border-color: hsl(var(--destructive) / 0.35);
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
|
||||
.plugin-store__readme {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
max-height: calc(100vh - 6rem);
|
||||
}
|
||||
|
||||
.plugin-store__readme-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-store__readme-header p {
|
||||
margin: 0 0 0.25rem;
|
||||
color: hsl(var(--primary));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-store__readme pre {
|
||||
max-height: calc(100vh - 14rem);
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
background: hsl(var(--secondary) / 0.5);
|
||||
}
|
||||
|
||||
.plugin-store__modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
.plugin-store__install-modal {
|
||||
position: fixed;
|
||||
z-index: 81;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
max-height: min(42rem, calc(100vh - 2rem));
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--card));
|
||||
box-shadow: 0 1.5rem 4rem rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.plugin-store__install-header,
|
||||
.plugin-store__install-actions,
|
||||
.plugin-store__capability-list-header,
|
||||
.plugin-store__capability-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.plugin-store__install-header {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.plugin-store__install-header p,
|
||||
.plugin-store__install-header h2,
|
||||
.plugin-store__capability-list-header h3,
|
||||
.plugin-store__muted-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plugin-store__install-header p,
|
||||
.plugin-store__field span,
|
||||
.plugin-store__capability-list-header span,
|
||||
.plugin-store__muted-text {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.plugin-store__install-header h2 {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.plugin-store__install-body {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.plugin-store__field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.plugin-store__field select {
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.plugin-store__capability-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.plugin-store__capability-list-header {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-store__capability-list-header h3 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.plugin-store__capability-row {
|
||||
gap: 0.55rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: hsl(var(--background) / 0.5);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.plugin-store__capability-row input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.plugin-store__install-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.plugin-store__empty {
|
||||
display: grid;
|
||||
min-height: 14rem;
|
||||
place-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-store__empty h2,
|
||||
.plugin-store__empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.is-spinning {
|
||||
animation: plugin-store-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes plugin-store-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.plugin-store__layout {
|
||||
grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.plugin-store__readme {
|
||||
grid-column: 1 / -1;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.plugin-store__topbar,
|
||||
.plugin-store__source-strip,
|
||||
.plugin-store__toolbar,
|
||||
.plugin-store__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plugin-store__topbar,
|
||||
.plugin-store__source-form,
|
||||
.plugin-store__toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-store__top-actions,
|
||||
.plugin-card__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plugin-store__rail {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.plugin-store__search {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.plugin-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.plugin-card__media {
|
||||
min-height: 4.5rem;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
||||
import type {
|
||||
PluginCapabilityId,
|
||||
@@ -36,7 +38,12 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
||||
import type { PluginStoreEntry, PluginStoreReadme } from '../../domain/models/plugin-store.models';
|
||||
import type {
|
||||
InstalledStorePlugin,
|
||||
PluginStoreEntry,
|
||||
PluginStoreInstallState,
|
||||
PluginStoreReadme
|
||||
} from '../../domain/models/plugin-store.models';
|
||||
|
||||
interface ServerPluginInstallDialog {
|
||||
manifest: TojuPluginManifest;
|
||||
@@ -50,6 +57,7 @@ interface ServerPluginInstallDialog {
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ChatMessageMarkdownComponent,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -66,7 +74,6 @@ interface ServerPluginInstallDialog {
|
||||
lucideX
|
||||
})
|
||||
],
|
||||
styleUrl: './plugin-store.component.scss',
|
||||
templateUrl: './plugin-store.component.html'
|
||||
})
|
||||
export class PluginStoreComponent implements OnInit {
|
||||
@@ -124,15 +131,23 @@ export class PluginStoreComponent implements OnInit {
|
||||
|
||||
return readme ? this.store.availablePlugins().find((plugin) => plugin.id === readme.pluginId) ?? null : null;
|
||||
});
|
||||
readonly selectedStoreServer = computed(() => {
|
||||
const selectedServerId = this.selectedStoreServerId();
|
||||
|
||||
return selectedServerId ? this.manageableServers().find((server) => server.id === selectedServerId) ?? null : null;
|
||||
});
|
||||
|
||||
newSourceUrl = '';
|
||||
readonly searchTerm = signal('');
|
||||
readonly selectedSourceUrl = signal<string | null>(null);
|
||||
readonly selectedStoreServerId = signal<string | null>(null);
|
||||
readonly selectedServerInstalledPlugins = signal<InstalledStorePlugin[]>([]);
|
||||
readonly showInstalledOnly = signal(false);
|
||||
readonly sourceError = signal<string | null>(null);
|
||||
readonly actionError = signal<string | null>(null);
|
||||
readonly actionBusyPluginId = signal<string | null>(null);
|
||||
readonly readme = signal<PluginStoreReadme | null>(null);
|
||||
readonly readmeRawMode = signal(false);
|
||||
readonly readmeError = signal<string | null>(null);
|
||||
readonly readmeLoadingPluginId = signal<string | null>(null);
|
||||
readonly serverInstallDialog = signal<ServerPluginInstallDialog | null>(null);
|
||||
@@ -147,8 +162,27 @@ export class PluginStoreComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private selectedServerLoadVersion = 0;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const servers = this.manageableServers();
|
||||
const selectedServerId = this.selectedStoreServerId();
|
||||
|
||||
if (servers.length === 0) {
|
||||
this.selectedStoreServerId.set(null);
|
||||
this.selectedServerInstalledPlugins.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedServerId || !servers.some((server) => server.id === selectedServerId)) {
|
||||
this.selectedStoreServerId.set(servers[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadSelectedServerInstalledPlugins(selectedServerId);
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.destroyed = true;
|
||||
});
|
||||
@@ -219,14 +253,17 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
async runPrimaryAction(plugin: PluginStoreEntry): Promise<void> {
|
||||
const action = this.store.getActionLabel(plugin);
|
||||
const action = this.getPrimaryActionLabel(plugin);
|
||||
|
||||
this.actionError.set(null);
|
||||
this.actionBusyPluginId.set(plugin.id);
|
||||
|
||||
try {
|
||||
if (action === 'Uninstall' || action === 'Remove from Server') {
|
||||
if (action === 'Uninstall') {
|
||||
await this.store.uninstallPlugin(plugin.id, plugin.scope);
|
||||
} else if (action === 'Remove from Server') {
|
||||
await this.store.uninstallPlugin(plugin.id, plugin.scope, { serverId: this.selectedStoreServerId() ?? undefined });
|
||||
await this.refreshSelectedServerInstalledPlugins();
|
||||
} else if (this.isServerScopedPlugin(plugin)) {
|
||||
await this.openServerInstallDialog(plugin);
|
||||
} else {
|
||||
@@ -257,6 +294,7 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.readme.set(readme);
|
||||
this.readmeRawMode.set(false);
|
||||
} catch (error) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
@@ -272,16 +310,21 @@ export class PluginStoreComponent implements OnInit {
|
||||
|
||||
closeReadme(): void {
|
||||
this.readme.set(null);
|
||||
this.readmeRawMode.set(false);
|
||||
this.readmeError.set(null);
|
||||
}
|
||||
|
||||
toggleReadmeRawMode(): void {
|
||||
this.readmeRawMode.update((value) => !value);
|
||||
}
|
||||
|
||||
async openServerInstallDialog(plugin: PluginStoreEntry): Promise<void> {
|
||||
this.actionBusyPluginId.set(plugin.id);
|
||||
this.serverInstallError.set(null);
|
||||
|
||||
try {
|
||||
const manifest = await this.store.loadInstallManifest(plugin);
|
||||
const selectedServerId = this.defaultServerInstallTargetId();
|
||||
const selectedServerId = this.selectedStoreServerId();
|
||||
|
||||
if (!selectedServerId) {
|
||||
throw new Error('You need owner or Manage Server access on a chat server before installing server plugins');
|
||||
@@ -315,9 +358,14 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
selectServerInstallTarget(serverId: string): void {
|
||||
this.selectedStoreServerId.set(serverId);
|
||||
this.serverInstallDialog.update((dialog) => dialog ? { ...dialog, selectedServerId: serverId } : dialog);
|
||||
}
|
||||
|
||||
selectStoreServer(serverId: string): void {
|
||||
this.selectedStoreServerId.set(serverId);
|
||||
}
|
||||
|
||||
toggleInstallCapability(capability: PluginCapabilityId, checked: boolean): void {
|
||||
this.selectedCapabilityIds.update((capabilities) => {
|
||||
const nextCapabilities = new Set(capabilities);
|
||||
@@ -358,6 +406,8 @@ export class PluginStoreComponent implements OnInit {
|
||||
serverId: dialog.selectedServerId
|
||||
});
|
||||
|
||||
await this.loadSelectedServerInstalledPlugins(dialog.selectedServerId);
|
||||
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
@@ -414,7 +464,7 @@ export class PluginStoreComponent implements OnInit {
|
||||
isPrimaryActionDisabled(plugin: PluginStoreEntry): boolean {
|
||||
return this.isPluginBusy(plugin)
|
||||
|| !this.canRunPrimaryAction(plugin)
|
||||
|| (!plugin.installUrl && this.store.getInstallState(plugin) !== 'installed');
|
||||
|| (!plugin.installUrl && this.getPluginInstallState(plugin) !== 'installed');
|
||||
}
|
||||
|
||||
canRunPrimaryAction(plugin: PluginStoreEntry): boolean {
|
||||
@@ -425,8 +475,39 @@ export class PluginStoreComponent implements OnInit {
|
||||
return this.manageableServers().length > 0;
|
||||
}
|
||||
|
||||
getPluginInstallState(plugin: PluginStoreEntry): PluginStoreInstallState {
|
||||
if (!this.isServerScopedPlugin(plugin)) {
|
||||
return this.store.getInstallState(plugin);
|
||||
}
|
||||
|
||||
const installedPlugin = this.selectedServerInstalledPlugins()
|
||||
.find((candidate) => candidate.manifest.id === plugin.id);
|
||||
|
||||
if (!installedPlugin) {
|
||||
return 'notInstalled';
|
||||
}
|
||||
|
||||
return comparePluginVersions(plugin.version, installedPlugin.manifest.version) > 0
|
||||
? 'updateAvailable'
|
||||
: 'installed';
|
||||
}
|
||||
|
||||
getPrimaryActionLabel(plugin: PluginStoreEntry): string {
|
||||
if (!this.isServerScopedPlugin(plugin)) {
|
||||
return this.store.getActionLabel(plugin);
|
||||
}
|
||||
|
||||
const state = this.getPluginInstallState(plugin);
|
||||
|
||||
if (state === 'updateAvailable') {
|
||||
return 'Update Server';
|
||||
}
|
||||
|
||||
return state === 'installed' ? 'Remove from Server' : 'Install to Server';
|
||||
}
|
||||
|
||||
primaryActionIcon(plugin: PluginStoreEntry): string {
|
||||
const action = this.store.getActionLabel(plugin);
|
||||
const action = this.getPrimaryActionLabel(plugin);
|
||||
|
||||
if (action === 'Uninstall') {
|
||||
return 'lucideTrash2';
|
||||
@@ -466,7 +547,7 @@ export class PluginStoreComponent implements OnInit {
|
||||
serverInstallButtonTitle(plugin: PluginStoreEntry): string {
|
||||
return this.isServerScopedPlugin(plugin) && this.manageableServers().length === 0
|
||||
? 'Requires owner or Manage Server access on a chat server'
|
||||
: this.store.getActionLabel(plugin);
|
||||
: this.getPrimaryActionLabel(plugin);
|
||||
}
|
||||
|
||||
private matchesSearch(plugin: PluginStoreEntry, searchTerm: string): boolean {
|
||||
@@ -490,13 +571,47 @@ export class PluginStoreComponent implements OnInit {
|
||||
return '/search';
|
||||
}
|
||||
|
||||
private defaultServerInstallTargetId(): string | null {
|
||||
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||
|
||||
return this.manageableServers().find((room) => room.id === currentRoomId)?.id ?? this.manageableServers()[0]?.id ?? null;
|
||||
}
|
||||
|
||||
private canManageServerPlugins(room: Room, user: User): boolean {
|
||||
return resolveLegacyRole(room, user) === 'host' || resolveRoomPermission(room, user, 'manageServer');
|
||||
}
|
||||
|
||||
private async refreshSelectedServerInstalledPlugins(): Promise<void> {
|
||||
const selectedServerId = this.selectedStoreServerId();
|
||||
|
||||
if (selectedServerId) {
|
||||
await this.loadSelectedServerInstalledPlugins(selectedServerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSelectedServerInstalledPlugins(serverId: string): Promise<void> {
|
||||
const loadVersion = ++this.selectedServerLoadVersion;
|
||||
|
||||
try {
|
||||
const installedPlugins = await this.store.loadInstalledPluginsForServer(serverId);
|
||||
|
||||
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
|
||||
this.selectedServerInstalledPlugins.set(installedPlugins);
|
||||
}
|
||||
} catch {
|
||||
if (!this.destroyed && loadVersion === this.selectedServerLoadVersion && this.selectedStoreServerId() === serverId) {
|
||||
this.selectedServerInstalledPlugins.set([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function comparePluginVersions(leftVersion: string, rightVersion: string): number {
|
||||
const leftParts = leftVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||
const rightParts = rightVersion.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
|
||||
if (difference !== 0) {
|
||||
return difference;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user