517 lines
22 KiB
HTML
517 lines
22 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]="serverCardTitle(server)"
|
|
(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"
|
|
/>
|
|
{{ 'servers.browser.card.banned' | translate }}
|
|
</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"
|
|
/>
|
|
{{ 'servers.browser.card.private' | translate }}
|
|
</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"
|
|
/>
|
|
{{ 'servers.browser.card.password' | translate }}
|
|
</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">{{ ownerLabel(server) }}</span>
|
|
<span class="truncate">{{ server.sourceName || server.hostName || ('common.labels.unknown' | translate) }}</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">{{ 'servers.browser.card.joined' | translate }}</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]="serverActionsLabel(server)"
|
|
(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)"
|
|
>
|
|
{{ 'servers.browser.card.leave' | translate }}
|
|
</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]="joinServerLabel(server)"
|
|
(click)="joinServer(server)"
|
|
>
|
|
<span class="sr-only">{{ server.name }}</span>
|
|
{{ 'servers.browser.card.join' | translate }}
|
|
</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"
|
|
appAutoFocus
|
|
appSelectOnFocus
|
|
[attr.aria-label]="'servers.browser.search.ariaLabel' | translate"
|
|
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]="resolvedSearchPlaceholder"
|
|
[(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">{{ 'servers.browser.search.myServers' | translate }}</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">{{ 'servers.browser.search.resultsTitle' | translate }}</h3>
|
|
<p class="text-xs text-muted-foreground">{{ 'servers.browser.search.resultsCount' | translate: { count: searchResults().length } }}</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">{{ 'servers.browser.search.noResults' | translate }}</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">{{ resolvedEmptyStateTitle }}</p>
|
|
<p class="mt-1 max-w-sm text-sm">{{ resolvedEmptyStateMessage }}</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]="'servers.browser.bannedDialog.title' | translate"
|
|
[confirmLabel]="'common.actions.ok' | translate"
|
|
[cancelLabel]="'common.actions.close' | translate"
|
|
variant="danger"
|
|
[widthClass]="'w-96 max-w-[90vw]'"
|
|
(confirmed)="closeBannedDialog()"
|
|
(cancelled)="closeBannedDialog()"
|
|
>
|
|
<p>{{ bannedDialogMessage() }}</p>
|
|
</app-confirm-dialog>
|
|
}
|
|
|
|
@if (showPasswordDialog() && passwordPromptServer()) {
|
|
<app-confirm-dialog
|
|
[title]="'servers.browser.passwordDialog.title' | translate"
|
|
[confirmLabel]="'servers.browser.passwordDialog.confirm' | translate"
|
|
[cancelLabel]="'common.actions.cancel' | translate"
|
|
[widthClass]="'w-[420px] max-w-[92vw]'"
|
|
(confirmed)="confirmPasswordJoin()"
|
|
(cancelled)="closePasswordDialog()"
|
|
>
|
|
<div class="space-y-3">
|
|
<p>{{ passwordDialogMessage(passwordPromptServer()!) }}</p>
|
|
|
|
<div>
|
|
<label
|
|
for="join-server-password"
|
|
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
>
|
|
{{ 'servers.browser.passwordDialog.label' | translate }}
|
|
</label>
|
|
<input
|
|
id="join-server-password"
|
|
type="password"
|
|
[(ngModel)]="joinPassword"
|
|
[placeholder]="'servers.browser.passwordDialog.placeholder' | translate"
|
|
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) {
|
|
<app-modal-backdrop
|
|
[zIndex]="50"
|
|
[dismissable]="false"
|
|
/>
|
|
<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">{{ 'servers.plugins.eyebrow' | translate }}</p>
|
|
<h2
|
|
id="join-plugin-consent-title"
|
|
class="mt-1 text-lg font-semibold"
|
|
>
|
|
{{ pluginUsesPluginsLabel(dialog.server.name) }}
|
|
</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">{{ 'servers.plugins.requiredTitle' | translate }}</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">{{
|
|
'servers.plugins.requiredBadge' | translate
|
|
}}</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">{{ 'servers.plugins.capabilities' | translate }}</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"
|
|
/>
|
|
{{ 'servers.plugins.source' | translate }}
|
|
</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"
|
|
/>
|
|
{{ pluginReadmeButtonLabel(requirement.pluginId) }}
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</section>
|
|
}
|
|
|
|
@if (dialog.optional.length > 0) {
|
|
<section class="grid gap-2">
|
|
<h3 class="text-sm font-semibold">{{ 'servers.plugins.optionalTitle' | translate }}</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">{{ 'servers.plugins.capabilities' | translate }}</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"
|
|
/>
|
|
{{ 'servers.plugins.source' | translate }}
|
|
</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"
|
|
/>
|
|
{{ pluginReadmeButtonLabel(requirement.pluginId) }}
|
|
</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"
|
|
>
|
|
{{ 'servers.plugins.cancelJoin' | translate }}
|
|
</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"
|
|
>
|
|
{{ pluginConsentConfirmLabel(dialog.required.length) }}
|
|
</button>
|
|
</footer>
|
|
</section>
|
|
|
|
@if (pluginConsentReadme(); as readme) {
|
|
<app-modal-backdrop
|
|
[zIndex]="52"
|
|
[ariaLabel]="'servers.plugins.closeReadme' | translate"
|
|
(dismissed)="closePluginConsentReadme()"
|
|
/>
|
|
<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">{{ 'servers.plugins.readmeEyebrow' | translate }}</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]="'common.actions.close' | translate"
|
|
>
|
|
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>
|
|
}
|
|
}
|