feat: plugins v1.7

This commit is contained in:
2026-04-29 15:24:56 +02:00
parent eabbc08896
commit d261bac0ed
45 changed files with 5621 additions and 867 deletions

View File

@@ -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 } });
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}