feat: plugins v1.7
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||
<div
|
||||
appThemeNode="roomPanelHeader"
|
||||
class="border-b border-border px-3 py-3"
|
||||
class="shrink-0 border-b border-border px-3 py-3"
|
||||
>
|
||||
@if (panelMode() === 'channels') {
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -252,7 +252,10 @@
|
||||
</section>
|
||||
|
||||
@if (pluginChannelSections().length > 0 || pluginSidePanels().length > 0) {
|
||||
<section class="border-t border-border px-2 py-3" data-testid="plugin-room-side-panel">
|
||||
<section
|
||||
class="border-t border-border px-2 py-3"
|
||||
data-testid="plugin-room-side-panel"
|
||||
>
|
||||
<div class="mb-2 px-1">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Plugins</h4>
|
||||
</div>
|
||||
@@ -262,7 +265,8 @@
|
||||
@for (record of pluginChannelSections(); track record.id) {
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground/70 transition-colors hover:bg-secondary/60 hover:text-foreground"
|
||||
[title]="record.pluginId">
|
||||
[title]="record.pluginId"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="record.contribution.type === 'video' ? 'lucideVideo' : 'lucideHash'"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
@@ -531,7 +535,7 @@
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
|
||||
@if (localUserHasDesync()) {
|
||||
<div class="mx-2 mb-1 flex items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
||||
<div class="mx-2 mb-1 flex shrink-0 items-center gap-2 rounded-md bg-amber-500/15 px-3 py-2 text-xs text-amber-400">
|
||||
<ng-icon
|
||||
name="lucideAlertTriangle"
|
||||
class="w-4 h-4 shrink-0"
|
||||
@@ -540,7 +544,7 @@
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="border-t border-border px-2 py-3"
|
||||
class="shrink-0 border-t border-border px-2 py-3"
|
||||
[class.invisible]="showFloatingControls()"
|
||||
>
|
||||
<app-voice-controls />
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-lg border border-border bg-card/60 p-5">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h4 class="text-base font-semibold text-foreground">Local HTTP API</h4>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Expose your client to local automation tools and scripts. Authentication is verified against your signaling server,
|
||||
and access is off by default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (!isElectron) {
|
||||
<section class="rounded-lg border border-border bg-secondary/30 p-5">
|
||||
<p class="text-sm text-muted-foreground">The local API is only available in the packaged Electron desktop app.</p>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Server</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Enable to start a local HTTP server. By default it only listens on the loopback interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
[checked]="settings().enabled"
|
||||
[disabled]="busy()"
|
||||
(change)="toggleEnabled($event)"
|
||||
/>
|
||||
<span class="text-sm text-foreground">
|
||||
<span class="font-medium">Run local API server</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">Start the HTTP server on this machine.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
[checked]="settings().exposeOnLan"
|
||||
[disabled]="busy() || !settings().enabled"
|
||||
(change)="toggleExposeOnLan($event)"
|
||||
/>
|
||||
<span class="text-sm text-foreground">
|
||||
<span class="font-medium">Allow connections from your network</span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">
|
||||
Bind to all interfaces (0.0.0.0). Other devices on your LAN will be able to reach the API. Only enable this on
|
||||
networks you trust.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-[200px_1fr_auto] md:items-end">
|
||||
<label class="space-y-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">Port</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
[value]="portText()"
|
||||
[disabled]="busy()"
|
||||
(input)="onPortInput($event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Change the listening port if 17878 is in use. Press save to apply.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="!hasPendingPortChange() || busy()"
|
||||
(click)="savePort()"
|
||||
>
|
||||
Save port
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Authentication</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Bearer tokens are issued only after a username/password is verified against one of the signaling servers below.
|
||||
Add the full URL (including <code>https://</code>) of every signaling server you trust.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows="4"
|
||||
spellcheck="false"
|
||||
placeholder="https://signaling.example.com"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 font-mono text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
[value]="allowedServersText()"
|
||||
[disabled]="busy()"
|
||||
(input)="onAllowedServersInput($event)"
|
||||
></textarea>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="!hasPendingAllowedServersChanges() || busy()"
|
||||
(click)="saveAllowedServers()"
|
||||
>
|
||||
Save allowed servers
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-lg border border-border bg-card/60 p-5">
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-foreground">Documentation</h5>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Browse the API in a privacy-respecting locally hosted Scalar reference. No telemetry, no AI, no remote network calls.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-primary"
|
||||
[checked]="settings().scalarEnabled"
|
||||
[disabled]="busy() || !settings().enabled"
|
||||
(change)="toggleScalar($event)"
|
||||
/>
|
||||
<span class="text-sm text-foreground">
|
||||
<span class="font-medium">Serve Scalar documentation at <code>/docs</code></span>
|
||||
<span class="mt-1 block text-xs text-muted-foreground">
|
||||
Loads from local app resources only. The OpenAPI document is always available at <code>/api/openapi.json</code>.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-primary/40 bg-primary/10 px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-primary/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="status().status !== 'running' || !settings().scalarEnabled"
|
||||
(click)="openDocs()"
|
||||
>
|
||||
Open API docs in browser
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
[disabled]="!status().baseUrl"
|
||||
(click)="copyBaseUrl()"
|
||||
>
|
||||
Copy base URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (status().baseUrl) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Listening at <code class="rounded bg-secondary px-2 py-1">{{ status().baseUrl }}</code>
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (errorMessage(); as message) {
|
||||
<p class="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{{ message }}</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import type {
|
||||
DesktopSettingsPatch,
|
||||
DesktopSettingsSnapshot,
|
||||
LocalApiSettings,
|
||||
LocalApiSnapshot
|
||||
} from '../../../../core/platform/electron/electron-api.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-local-api-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './local-api-settings.component.html'
|
||||
})
|
||||
export class LocalApiSettingsComponent implements OnInit, OnDestroy {
|
||||
private readonly bridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly isElectron = this.bridge.isAvailable;
|
||||
|
||||
readonly settings = signal<LocalApiSettings>({
|
||||
enabled: false,
|
||||
port: 17878,
|
||||
exposeOnLan: false,
|
||||
scalarEnabled: false,
|
||||
allowedSignalingServers: []
|
||||
});
|
||||
|
||||
readonly status = signal<LocalApiSnapshot>({
|
||||
status: 'stopped',
|
||||
host: null,
|
||||
port: null,
|
||||
baseUrl: null,
|
||||
error: null,
|
||||
exposeOnLan: false,
|
||||
scalarEnabled: false
|
||||
});
|
||||
|
||||
readonly allowedServersText = signal('');
|
||||
readonly hasPendingAllowedServersChanges = signal(false);
|
||||
readonly portText = signal('17878');
|
||||
readonly hasPendingPortChange = signal(false);
|
||||
readonly busy = signal(false);
|
||||
readonly errorMessage = signal<string | null>(null);
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
const snapshot = this.status();
|
||||
|
||||
switch (snapshot.status) {
|
||||
case 'running':
|
||||
return `Running at ${snapshot.baseUrl ?? 'unknown'}`;
|
||||
case 'starting':
|
||||
return 'Starting…';
|
||||
case 'error':
|
||||
return `Error: ${snapshot.error ?? 'unknown error'}`;
|
||||
case 'stopped':
|
||||
default:
|
||||
return 'Stopped';
|
||||
}
|
||||
});
|
||||
|
||||
private statusPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
if (!this.isElectron)
|
||||
return;
|
||||
|
||||
await this.refresh();
|
||||
|
||||
this.statusPollHandle = setInterval(() => {
|
||||
void this.refreshStatus();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.statusPollHandle) {
|
||||
clearInterval(this.statusPollHandle);
|
||||
this.statusPollHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const api = this.bridge.getApi();
|
||||
|
||||
if (!api)
|
||||
return;
|
||||
|
||||
const desktop: DesktopSettingsSnapshot = await api.getDesktopSettings();
|
||||
|
||||
this.settings.set({
|
||||
enabled: desktop.localApi.enabled,
|
||||
port: desktop.localApi.port,
|
||||
exposeOnLan: desktop.localApi.exposeOnLan,
|
||||
scalarEnabled: desktop.localApi.scalarEnabled,
|
||||
allowedSignalingServers: [...desktop.localApi.allowedSignalingServers]
|
||||
});
|
||||
|
||||
this.allowedServersText.set(desktop.localApi.allowedSignalingServers.join('\n'));
|
||||
this.hasPendingAllowedServersChanges.set(false);
|
||||
this.portText.set(String(desktop.localApi.port));
|
||||
this.hasPendingPortChange.set(false);
|
||||
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
async refreshStatus(): Promise<void> {
|
||||
const api = this.bridge.getApi();
|
||||
|
||||
if (!api)
|
||||
return;
|
||||
|
||||
const snapshot = await api.getLocalApiStatus();
|
||||
|
||||
if (snapshot)
|
||||
this.status.set(snapshot);
|
||||
}
|
||||
|
||||
async toggleEnabled(event: Event): Promise<void> {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
|
||||
await this.savePatch({ enabled: checkbox.checked });
|
||||
}
|
||||
|
||||
async toggleExposeOnLan(event: Event): Promise<void> {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
|
||||
await this.savePatch({ exposeOnLan: checkbox.checked });
|
||||
}
|
||||
|
||||
async toggleScalar(event: Event): Promise<void> {
|
||||
const checkbox = event.target as HTMLInputElement;
|
||||
|
||||
await this.savePatch({ scalarEnabled: checkbox.checked });
|
||||
}
|
||||
|
||||
onPortInput(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
|
||||
this.portText.set(input.value);
|
||||
this.hasPendingPortChange.set(true);
|
||||
}
|
||||
|
||||
async savePort(): Promise<void> {
|
||||
const parsed = Number(this.portText());
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||
this.errorMessage.set('Port must be an integer between 1 and 65535');
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage.set(null);
|
||||
await this.savePatch({ port: Math.floor(parsed) });
|
||||
this.hasPendingPortChange.set(false);
|
||||
}
|
||||
|
||||
onAllowedServersInput(event: Event): void {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
|
||||
this.allowedServersText.set(textarea.value);
|
||||
this.hasPendingAllowedServersChanges.set(true);
|
||||
}
|
||||
|
||||
async saveAllowedServers(): Promise<void> {
|
||||
const list = this.allowedServersText()
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
await this.savePatch({ allowedSignalingServers: list });
|
||||
this.hasPendingAllowedServersChanges.set(false);
|
||||
}
|
||||
|
||||
async openDocs(): Promise<void> {
|
||||
const api = this.bridge.getApi();
|
||||
|
||||
if (!api)
|
||||
return;
|
||||
|
||||
const result = await api.openLocalApiDocs();
|
||||
|
||||
if (result && !result.opened) {
|
||||
this.errorMessage.set(result.reason ?? 'Could not open documentation');
|
||||
} else {
|
||||
this.errorMessage.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async copyBaseUrl(): Promise<void> {
|
||||
const baseUrl = this.status().baseUrl;
|
||||
|
||||
if (!baseUrl)
|
||||
return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(baseUrl);
|
||||
} catch {
|
||||
// ignore clipboard errors
|
||||
}
|
||||
}
|
||||
|
||||
private async savePatch(localApiPatch: Partial<LocalApiSettings>): Promise<void> {
|
||||
if (this.busy())
|
||||
return;
|
||||
|
||||
const api = this.bridge.getApi();
|
||||
|
||||
if (!api)
|
||||
return;
|
||||
|
||||
this.busy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
try {
|
||||
const patch: DesktopSettingsPatch = { localApi: localApiPatch };
|
||||
const updated = await api.setDesktopSettings(patch);
|
||||
|
||||
this.settings.set({
|
||||
enabled: updated.localApi.enabled,
|
||||
port: updated.localApi.port,
|
||||
exposeOnLan: updated.localApi.exposeOnLan,
|
||||
scalarEnabled: updated.localApi.scalarEnabled,
|
||||
allowedSignalingServers: [...updated.localApi.allowedSignalingServers]
|
||||
});
|
||||
|
||||
this.portText.set(String(updated.localApi.port));
|
||||
this.allowedServersText.set(updated.localApi.allowedSignalingServers.join('\n'));
|
||||
|
||||
await this.refreshStatus();
|
||||
} catch (error) {
|
||||
this.errorMessage.set((error as Error).message ?? 'Failed to update settings');
|
||||
} finally {
|
||||
this.busy.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,9 @@
|
||||
@case ('updates') {
|
||||
Updates
|
||||
}
|
||||
@case ('localApi') {
|
||||
Local API
|
||||
}
|
||||
@case ('data') {
|
||||
Data
|
||||
}
|
||||
@@ -203,6 +206,7 @@
|
||||
<app-plugin-manager
|
||||
scope="client"
|
||||
(closed)="navigate('general')"
|
||||
(storeOpened)="closeForExternalNavigation()"
|
||||
/>
|
||||
}
|
||||
@case ('network') {
|
||||
@@ -294,6 +298,9 @@
|
||||
@case ('updates') {
|
||||
<app-updates-settings />
|
||||
}
|
||||
@case ('localApi') {
|
||||
<app-local-api-settings />
|
||||
}
|
||||
@case ('data') {
|
||||
@defer {
|
||||
<app-data-settings />
|
||||
@@ -317,12 +324,14 @@
|
||||
<app-plugin-manager
|
||||
scope="server"
|
||||
(closed)="navigate('server')"
|
||||
(storeOpened)="closeForExternalNavigation()"
|
||||
/>
|
||||
} @else {
|
||||
<section class="rounded-lg border border-border bg-card p-5">
|
||||
<h4 class="text-sm font-semibold text-foreground">Open this server to manage plugins</h4>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Server plugin installs and activation are shown for the currently open chat server. Select or open {{ selectedServer()?.name || 'this server' }} in the app, then return here.
|
||||
Server plugin installs and activation are shown for the currently open chat server. Select or open
|
||||
{{ selectedServer()?.name || 'this server' }} in the app, then return here.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
lucidePalette,
|
||||
lucidePackage,
|
||||
lucideSettings,
|
||||
lucideTerminal,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield
|
||||
@@ -46,6 +47,7 @@ import { BansSettingsComponent } from './bans-settings/bans-settings.component';
|
||||
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
|
||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||
import { LocalApiSettingsComponent } from './local-api-settings/local-api-settings.component';
|
||||
import { DataSettingsComponent } from './data-settings/data-settings.component';
|
||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||
import {
|
||||
@@ -67,6 +69,7 @@ import {
|
||||
PluginManagerComponent,
|
||||
VoiceSettingsComponent,
|
||||
UpdatesSettingsComponent,
|
||||
LocalApiSettingsComponent,
|
||||
DataSettingsComponent,
|
||||
DebuggingSettingsComponent,
|
||||
ServerSettingsComponent,
|
||||
@@ -86,6 +89,7 @@ import {
|
||||
lucidePalette,
|
||||
lucidePackage,
|
||||
lucideSettings,
|
||||
lucideTerminal,
|
||||
lucideUsers,
|
||||
lucideBan,
|
||||
lucideShield
|
||||
@@ -127,6 +131,7 @@ export class SettingsModalComponent {
|
||||
{ id: 'notifications', label: 'Notifications', icon: 'lucideBell' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' },
|
||||
{ id: 'updates', label: 'Updates', icon: 'lucideDownload' },
|
||||
{ id: 'localApi', label: 'Local API', icon: 'lucideTerminal' },
|
||||
{ id: 'data', label: 'Data', icon: 'lucideDownload' },
|
||||
{ id: 'debugging', label: 'Debugging', icon: 'lucideBug' }
|
||||
];
|
||||
@@ -349,6 +354,12 @@ export class SettingsModalComponent {
|
||||
setTimeout(() => this.modal.close(), 200);
|
||||
}
|
||||
|
||||
closeForExternalNavigation(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
this.animating.set(false);
|
||||
this.modal.close();
|
||||
}
|
||||
|
||||
openThirdPartyLicenses(): void {
|
||||
this.showThirdPartyLicenses.set(true);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
name="lucidePackage"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
Plugins
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
|
||||
Reference in New Issue
Block a user