511 lines
21 KiB
HTML
511 lines
21 KiB
HTML
<ng-template
|
|
#serverCard
|
|
let-server
|
|
>
|
|
<div
|
|
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
|
|
[class.border-border]="!isServerMarkedBanned(server)"
|
|
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
|
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
|
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
|
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
|
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
|
[title]="isJoinedServer(server) ? 'Double-click to open ' + server.name : 'Double-click to join ' + server.name"
|
|
(dblclick)="openServerCard(server)"
|
|
>
|
|
<div class="flex min-w-0 items-start gap-3">
|
|
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
|
@if (server.icon) {
|
|
<div
|
|
aria-hidden="true"
|
|
class="h-full w-full bg-cover bg-center bg-no-repeat"
|
|
[style.backgroundImage]="'url(' + server.icon + ')'"
|
|
></div>
|
|
} @else {
|
|
{{ server.name[0] || '?' }}
|
|
}
|
|
</div>
|
|
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3
|
|
class="truncate text-sm font-semibold transition-colors"
|
|
[class.text-foreground]="!isServerMarkedBanned(server)"
|
|
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
|
[class.text-destructive]="isServerMarkedBanned(server)"
|
|
>
|
|
{{ server.name }}
|
|
</h3>
|
|
|
|
@if (isServerMarkedBanned(server)) {
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive">
|
|
<ng-icon
|
|
name="lucideLock"
|
|
class="h-3 w-3"
|
|
/>
|
|
Banned
|
|
</span>
|
|
} @else if (server.isPrivate) {
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
<ng-icon
|
|
name="lucideLock"
|
|
class="h-3 w-3"
|
|
/>
|
|
Private
|
|
</span>
|
|
} @else if (server.hasPassword) {
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
<ng-icon
|
|
name="lucideLock"
|
|
class="h-3 w-3"
|
|
/>
|
|
Password
|
|
</span>
|
|
} @else {
|
|
<ng-icon
|
|
name="lucideGlobe"
|
|
class="h-4 w-4 text-muted-foreground"
|
|
/>
|
|
}
|
|
</div>
|
|
|
|
@if (server.description) {
|
|
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">{{ server.description }}</p>
|
|
}
|
|
|
|
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
<span class="inline-flex items-center gap-1">
|
|
<ng-icon
|
|
name="lucideUsers"
|
|
class="h-3.5 w-3.5"
|
|
/>
|
|
{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}
|
|
</span>
|
|
@if (server.topic) {
|
|
<span class="truncate">{{ server.topic }}</span>
|
|
}
|
|
<span class="truncate">Owner: {{ getServerOwnerLabel(server) }}</span>
|
|
<span class="truncate">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative shrink-0">
|
|
@if (isJoinedServer(server)) {
|
|
<div class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500">
|
|
<span class="px-2.5 py-1.5">Joined</span>
|
|
<button
|
|
type="button"
|
|
class="grid h-8 w-8 place-items-center border-l border-emerald-500/20 transition-colors hover:bg-emerald-500/15"
|
|
[attr.aria-label]="'Server actions for ' + server.name"
|
|
(click)="toggleJoinedServerMenu($event, server)"
|
|
>
|
|
<ng-icon
|
|
name="lucideChevronDown"
|
|
class="h-4 w-4"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
@if (joinedServerMenuId() === server.id) {
|
|
<div class="absolute right-0 top-full z-20 mt-1 w-36 rounded-md border border-border bg-card py-1 shadow-lg">
|
|
<button
|
|
type="button"
|
|
class="w-full px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
(click)="openLeaveDialog($event, server)"
|
|
>
|
|
Leave
|
|
</button>
|
|
</div>
|
|
}
|
|
} @else {
|
|
<button
|
|
type="button"
|
|
class="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
|
[attr.aria-label]="'Join ' + server.name"
|
|
(click)="joinServer(server)"
|
|
>
|
|
<span class="sr-only">{{ server.name }}</span>
|
|
Join
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ng-template>
|
|
|
|
<div class="flex h-full min-h-0 flex-col">
|
|
<div class="border-b border-border px-3 py-3">
|
|
<div class="relative">
|
|
<ng-icon
|
|
name="lucideSearch"
|
|
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<input
|
|
type="text"
|
|
aria-label="Search servers"
|
|
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
[placeholder]="searchPlaceholder"
|
|
[(ngModel)]="searchQuery"
|
|
(ngModelChange)="onSearchChange($event)"
|
|
/>
|
|
</div>
|
|
|
|
@if (showMyServers && savedRooms().length > 0) {
|
|
<div class="mt-2 flex items-center gap-2 overflow-x-auto pb-1">
|
|
<span class="shrink-0 text-xs font-medium text-muted-foreground">My Servers</span>
|
|
@for (room of savedRooms(); track room.id) {
|
|
<button
|
|
type="button"
|
|
class="shrink-0 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
|
(click)="joinSavedRoom(room)"
|
|
>
|
|
{{ room.name }}
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
@if (isSearchMode) {
|
|
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
|
|
<div>
|
|
<h3 class="text-sm font-semibold text-foreground">Search results</h3>
|
|
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
|
|
</div>
|
|
</div>
|
|
|
|
@if (isSearching()) {
|
|
<div class="flex items-center justify-center py-8">
|
|
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
|
</div>
|
|
} @else if (searchResults().length === 0) {
|
|
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
|
|
<ng-icon
|
|
name="lucideSearch"
|
|
class="mb-3 h-10 w-10 opacity-50"
|
|
/>
|
|
<p class="text-sm font-medium">No servers found</p>
|
|
</div>
|
|
} @else {
|
|
<div class="space-y-2 p-3">
|
|
@for (server of searchResults(); track server.id) {
|
|
<ng-container *ngTemplateOutlet="serverCard; context: { $implicit: server }" />
|
|
}
|
|
</div>
|
|
}
|
|
} @else if (showEmptyState) {
|
|
<div class="flex flex-col items-center justify-center px-6 py-16 text-center text-muted-foreground">
|
|
<ng-icon
|
|
name="lucideSearch"
|
|
class="mb-4 h-12 w-12 opacity-40"
|
|
/>
|
|
<p class="text-base font-semibold text-foreground">{{ emptyStateTitle }}</p>
|
|
<p class="mt-1 max-w-sm text-sm">{{ emptyStateMessage }}</p>
|
|
</div>
|
|
} @else {
|
|
<div class="space-y-6 p-3">
|
|
@for (section of visibleSections; track section.id) {
|
|
<section>
|
|
<div class="mb-2 px-1">
|
|
<h3 class="text-sm font-semibold text-foreground">{{ section.title }}</h3>
|
|
@if (section.subtitle) {
|
|
<p class="text-xs text-muted-foreground">{{ section.subtitle }}</p>
|
|
}
|
|
</div>
|
|
<div class="space-y-2">
|
|
@for (server of section.servers; track server.id) {
|
|
<ng-container *ngTemplateOutlet="serverCard; context: { $implicit: server }" />
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (joinErrorMessage() || error()) {
|
|
<div class="border-t border-destructive bg-destructive/10 p-4">
|
|
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
@if (leaveDialogRoom()) {
|
|
<app-leave-server-dialog
|
|
[room]="leaveDialogRoom()!"
|
|
[currentUser]="currentUser() ?? null"
|
|
(confirmed)="confirmLeaveServer($event)"
|
|
(cancelled)="closeLeaveDialog()"
|
|
/>
|
|
}
|
|
|
|
@if (showBannedDialog()) {
|
|
<app-confirm-dialog
|
|
title="Banned"
|
|
confirmLabel="OK"
|
|
cancelLabel="Close"
|
|
variant="danger"
|
|
[widthClass]="'w-96 max-w-[90vw]'"
|
|
(confirmed)="closeBannedDialog()"
|
|
(cancelled)="closeBannedDialog()"
|
|
>
|
|
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
|
</app-confirm-dialog>
|
|
}
|
|
|
|
@if (showPasswordDialog() && passwordPromptServer()) {
|
|
<app-confirm-dialog
|
|
title="Password required"
|
|
confirmLabel="Join server"
|
|
cancelLabel="Cancel"
|
|
[widthClass]="'w-[420px] max-w-[92vw]'"
|
|
(confirmed)="confirmPasswordJoin()"
|
|
(cancelled)="closePasswordDialog()"
|
|
>
|
|
<div class="space-y-3">
|
|
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
|
|
|
|
<div>
|
|
<label
|
|
for="join-server-password"
|
|
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
Server password
|
|
</label>
|
|
<input
|
|
id="join-server-password"
|
|
type="password"
|
|
[(ngModel)]="joinPassword"
|
|
placeholder="Enter password"
|
|
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
|
|
@if (joinPasswordError()) {
|
|
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
|
|
}
|
|
</div>
|
|
</app-confirm-dialog>
|
|
}
|
|
|
|
@if (pluginConsentDialog(); as dialog) {
|
|
<div
|
|
class="fixed inset-0 z-50 bg-black/50"
|
|
role="presentation"
|
|
></div>
|
|
<section
|
|
class="fixed left-1/2 top-1/2 z-[51] 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="join-plugin-consent-title"
|
|
>
|
|
<header class="border-b border-border p-4">
|
|
<p class="text-sm text-muted-foreground">Plugin downloads</p>
|
|
<h2
|
|
id="join-plugin-consent-title"
|
|
class="mt-1 text-lg font-semibold"
|
|
>
|
|
{{ dialog.server.name }} uses plugins
|
|
</h2>
|
|
</header>
|
|
|
|
<div class="grid min-h-0 gap-4 overflow-auto p-4">
|
|
@if (dialog.required.length > 0) {
|
|
<section class="grid gap-2">
|
|
<h3 class="text-sm font-semibold">Required before joining</h3>
|
|
@for (requirement of dialog.required; track requirement.pluginId) {
|
|
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</p>
|
|
@if (requirement.reason) {
|
|
<p class="mt-1 text-xs text-muted-foreground">{{ requirement.reason }}</p>
|
|
}
|
|
</div>
|
|
<span class="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary">Required</span>
|
|
</div>
|
|
|
|
@if (requirement.manifest?.capabilities; as capabilities) {
|
|
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
|
|
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
|
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
@for (capability of capabilities; track capability) {
|
|
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
@if (getPluginSourceUrl(requirement)) {
|
|
<button
|
|
type="button"
|
|
(click)="openPluginSource(requirement)"
|
|
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-xs font-semibold transition-colors hover:bg-secondary"
|
|
>
|
|
<ng-icon
|
|
name="lucideExternalLink"
|
|
class="h-3.5 w-3.5"
|
|
/>
|
|
Source
|
|
</button>
|
|
}
|
|
|
|
@if (hasPluginReadme(requirement)) {
|
|
<button
|
|
type="button"
|
|
(click)="openPluginConsentReadme(requirement)"
|
|
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
|
|
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-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
|
>
|
|
<ng-icon
|
|
name="lucideFileText"
|
|
class="h-3.5 w-3.5"
|
|
/>
|
|
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (dialog.optional.length > 0) {
|
|
<section class="grid gap-2">
|
|
<h3 class="text-sm font-semibold">Optional plugins</h3>
|
|
@for (requirement of dialog.optional; track requirement.pluginId) {
|
|
<div class="grid gap-3 rounded-lg border border-border bg-background/50 px-3 py-2">
|
|
<label class="flex items-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
class="mt-1 h-4 w-4 rounded border-border bg-secondary"
|
|
[checked]="selectedOptionalPluginIds().has(requirement.pluginId)"
|
|
[disabled]="pluginConsentBusy()"
|
|
(change)="toggleOptionalPluginInstall(requirement.pluginId, $any($event.target).checked)"
|
|
/>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-sm font-semibold">{{ requirement.manifest?.title || requirement.pluginId }}</span>
|
|
@if (requirement.reason) {
|
|
<span class="mt-1 block text-xs text-muted-foreground">{{ requirement.reason }}</span>
|
|
}
|
|
</span>
|
|
</label>
|
|
|
|
@if (requirement.manifest?.capabilities; as capabilities) {
|
|
<details class="rounded-md border border-border bg-secondary/40 px-2 py-1.5 text-xs text-muted-foreground">
|
|
<summary class="cursor-pointer font-semibold text-foreground">Capabilities</summary>
|
|
<div class="mt-2 flex flex-wrap gap-1.5">
|
|
@for (capability of capabilities; track capability) {
|
|
<span class="rounded-full bg-background px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ capability }}</span>
|
|
}
|
|
</div>
|
|
</details>
|
|
}
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
@if (getPluginSourceUrl(requirement)) {
|
|
<button
|
|
type="button"
|
|
(click)="openPluginSource(requirement)"
|
|
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-xs font-semibold transition-colors hover:bg-secondary"
|
|
>
|
|
<ng-icon
|
|
name="lucideExternalLink"
|
|
class="h-3.5 w-3.5"
|
|
/>
|
|
Source
|
|
</button>
|
|
}
|
|
|
|
@if (hasPluginReadme(requirement)) {
|
|
<button
|
|
type="button"
|
|
(click)="openPluginConsentReadme(requirement)"
|
|
[disabled]="pluginConsentReadmeLoadingId() === requirement.pluginId"
|
|
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-xs font-semibold transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-55"
|
|
>
|
|
<ng-icon
|
|
name="lucideFileText"
|
|
class="h-3.5 w-3.5"
|
|
/>
|
|
{{ pluginConsentReadmeLoadingId() === requirement.pluginId ? 'Loading' : 'Readme' }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (pluginConsentReadmeError()) {
|
|
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentReadmeError() }}</p>
|
|
}
|
|
|
|
@if (pluginConsentError()) {
|
|
<p class="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">{{ pluginConsentError() }}</p>
|
|
}
|
|
</div>
|
|
|
|
<footer class="flex justify-end gap-2 border-t border-border p-4">
|
|
<button
|
|
type="button"
|
|
(click)="closePluginConsentDialog()"
|
|
[disabled]="pluginConsentBusy()"
|
|
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 join
|
|
</button>
|
|
<button
|
|
type="button"
|
|
(click)="confirmPluginConsent()"
|
|
[disabled]="pluginConsentBusy()"
|
|
class="inline-flex min-h-8 items-center justify-center 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"
|
|
>
|
|
{{ pluginConsentBusy() ? 'Downloading' : dialog.required.length > 0 ? 'Accept and join' : 'Join' }}
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
|
|
@if (pluginConsentReadme(); as readme) {
|
|
<div
|
|
class="fixed inset-0 z-[52] bg-black/60"
|
|
role="presentation"
|
|
(click)="closePluginConsentReadme()"
|
|
></div>
|
|
<section
|
|
class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,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="join-plugin-readme-title"
|
|
>
|
|
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
|
|
<div class="min-w-0">
|
|
<p class="text-sm text-muted-foreground">Plugin readme</p>
|
|
<h2
|
|
id="join-plugin-readme-title"
|
|
class="mt-1 truncate text-lg font-semibold"
|
|
>
|
|
{{ readme.title }}
|
|
</h2>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
(click)="closePluginConsentReadme()"
|
|
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="Close readme"
|
|
>
|
|
X
|
|
</button>
|
|
</header>
|
|
|
|
<div
|
|
class="min-h-0 overflow-auto p-4 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>
|
|
</section>
|
|
}
|
|
}
|