feat: signal server tag

This commit is contained in:
2026-06-05 06:16:02 +02:00
parent 6865147e8f
commit bf4e6891d1
69 changed files with 2808 additions and 1269 deletions

View File

@@ -1,408 +1,418 @@
<ng-template #pageContent>
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
} @else {
Welcome to MetoYou
}
</h1>
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
</header>
<div>
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
aria-label="Search people, servers, and invites"
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search for people, servers, or paste an invite..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/>
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex">
Ctrl K
</kbd>
</div>
@if (!isSearchMode() && recentSearches().length > 0) {
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
@for (term of recentSearches(); track term) {
<span class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground">
<button
type="button"
class="max-w-[10rem] truncate hover:text-primary"
(click)="applyRecentSearch(term)"
>
{{ term }}
</button>
<button
type="button"
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
[attr.aria-label]="'Remove ' + term"
(click)="removeRecentSearch(term)"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
</span>
<div class="h-full min-h-0 overflow-y-auto bg-background text-foreground">
<div class="mx-auto w-full max-w-5xl space-y-8 p-4 sm:p-6 lg:py-8">
<header class="space-y-1">
<h1 class="text-2xl font-semibold text-foreground">
@if (currentUser()) {
Welcome back, {{ currentUser()!.displayName || 'there' }}
} @else {
Welcome to MetoYou
}
<button
type="button"
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
(click)="clearRecentSearches()"
>
Clear
</button>
</div>
}
</div>
</h1>
<p class="text-sm text-muted-foreground">Find people, discover servers, or start your own community.</p>
</header>
@if (isSearchMode()) {
<section class="space-y-5">
@if (inviteResult(); as invite) {
<div>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
<div>
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
aria-label="Search people, servers, and invites"
class="h-12 w-full rounded-xl border border-border bg-secondary py-2 pl-11 pr-20 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search for people, servers, or paste an invite..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="submitSearch()"
/>
<kbd
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"
>
Ctrl K
</kbd>
</div>
@if (!isSearchMode() && recentSearches().length > 0) {
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs font-medium text-muted-foreground">Recent:</span>
@for (term of recentSearches(); track term) {
<span
class="group inline-flex items-center gap-1 rounded-full border border-border bg-secondary py-1 pl-3 pr-1 text-xs text-foreground"
>
<button
type="button"
class="max-w-[10rem] truncate hover:text-primary"
(click)="applyRecentSearch(term)"
>
{{ term }}
</button>
<button
type="button"
class="grid h-4 w-4 place-items-center rounded-full text-muted-foreground hover:bg-card hover:text-foreground"
[attr.aria-label]="'Remove ' + term"
(click)="removeRecentSearch(term)"
>
<ng-icon
name="lucideX"
class="h-3 w-3"
/>
</button>
</span>
}
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openInvite()"
class="text-xs font-medium text-muted-foreground hover:text-foreground hover:underline"
(click)="clearRecentSearches()"
>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ng-icon
name="lucideTicket"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
Clear
</button>
</div>
}
</div>
@if (topServerResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>View all</a
@if (isSearchMode()) {
<section class="space-y-5">
@if (inviteResult(); as invite) {
<div>
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invite</h2>
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openInvite()"
>
</div>
<div class="space-y-2">
@for (server of topServerResults(); track server.id) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openServer(server)"
>
<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 {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
name="lucideTicket"
class="h-5 w-5"
/>
</button>
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">Open invite</p>
<p class="truncate text-xs text-muted-foreground">{{ invite }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
</div>
}
@if (topServerResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (server of topServerResults(); track server.id) {
<button
type="button"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openServer(server)"
>
<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 {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
</div>
</div>
}
@if (topPeopleResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>
</div>
<div class="space-y-2">
@for (person of topPeopleResults(); track person.id) {
<a
routerLink="/people"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</a>
}
</div>
</div>
}
@if (hasNoQuickResults() && !isSearching()) {
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
No people, servers, or invites match
<span class="font-medium text-foreground">{{ searchQuery() }}</span
>.
</div>
}
</section>
} @else {
<!-- Primary actions -->
<section class="grid gap-3 sm:grid-cols-3">
<a
routerLink="/people"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
<ng-icon
name="lucideUsers"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find People</p>
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/servers"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
<ng-icon
name="lucideCompass"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find Servers</p>
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/create-server"
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
<ng-icon
name="lucidePlus"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Create Server</p>
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
/>
</a>
</section>
@if (isNewUser()) {
<section class="rounded-xl border border-border bg-card p-6 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideServer"
class="h-6 w-6 text-muted-foreground"
/>
</div>
<h2 class="text-base font-semibold text-foreground">Get started</h2>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
</p>
</section>
}
@if (topPeopleResults().length > 0) {
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">People</h2>
<!-- People + Popular servers -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>View all</a
>See all</a
>
</div>
<div class="space-y-2">
@for (person of topPeopleResults(); track person.id) {
<a
routerLink="/people"
class="flex w-full items-center gap-3 rounded-lg border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
@if (peopleYouMightKnow().length > 0) {
<div class="space-y-1">
@for (person of peopleYouMightKnow(); track person.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
size="md"
[status]="person.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
</div>
<app-friend-button [user]="person" />
</a>
}
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
}
</div>
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (popularServers().length > 0) {
<div class="space-y-1">
@for (server of popularServers(); track server.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<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 {
{{ serverInitial(server) }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<button
type="button"
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openServer(server)"
>
Join
</button>
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
}
</div>
}
@if (hasNoQuickResults() && !isSearching()) {
<div class="rounded-lg border border-border bg-card px-4 py-8 text-center text-sm text-muted-foreground">
No people, servers, or invites match
<span class="font-medium text-foreground">{{ searchQuery() }}</span
>.
</div>
}
</section>
} @else {
<!-- Primary actions -->
<section class="grid gap-3 sm:grid-cols-3">
<a
routerLink="/people"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-purple-500/15 text-purple-400">
<ng-icon
name="lucideUsers"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find People</p>
<p class="mt-0.5 text-xs text-muted-foreground">Connect with friends.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/servers"
class="group flex items-center gap-3 rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-card/80"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-blue-500/15 text-blue-400">
<ng-icon
name="lucideCompass"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Find Servers</p>
<p class="mt-0.5 text-xs text-muted-foreground">Browse communities.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</a>
<a
routerLink="/create-server"
class="group flex items-center gap-3 rounded-xl border border-emerald-500/40 bg-emerald-500/10 p-4 transition-colors hover:bg-emerald-500/15"
>
<div class="grid h-11 w-11 shrink-0 place-items-center rounded-lg bg-emerald-500/20 text-emerald-400">
<ng-icon
name="lucidePlus"
class="h-6 w-6"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">Create Server</p>
<p class="mt-0.5 text-xs text-muted-foreground">Start your own.</p>
</div>
<ng-icon
name="lucideArrowRight"
class="h-4 w-4 text-emerald-400 transition-transform group-hover:translate-x-0.5"
/>
</a>
</section>
@if (isNewUser()) {
<section class="rounded-xl border border-border bg-card p-6 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideServer"
class="h-6 w-6 text-muted-foreground"
/>
</div>
<h2 class="text-base font-semibold text-foreground">Get started</h2>
<p class="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">
You have not joined any servers yet. Find a community to join, or create your own to invite friends.
</p>
</section>
}
<!-- People + Popular servers -->
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">People you might know</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (peopleYouMightKnow().length > 0) {
<div class="space-y-1">
@for (person of peopleYouMightKnow(); track person.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<!-- Your friends -->
@if (friends().length > 0) {
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>Manage</a
>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<app-user-avatar
[name]="personLabel(person)"
[avatarUrl]="person.avatarUrl"
[name]="personLabel(friend)"
[avatarUrl]="friend.avatarUrl"
size="md"
[status]="person.status"
[status]="friend.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(person) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(person) ? 'Online' : 'Offline' }}</p>
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="person" />
<app-friend-button [user]="friend" />
</div>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No people to suggest yet.</p>
}
</div>
</section>
}
<div class="rounded-xl border border-border bg-card/40 p-4">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Popular Servers</h2>
<a
routerLink="/servers"
class="text-xs font-medium text-primary hover:underline"
>See all</a
>
</div>
@if (popularServers().length > 0) {
<div class="space-y-1">
@for (server of popularServers(); track server.id) {
<div class="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-secondary/60">
<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) {
<!-- Recently active servers -->
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
type="button"
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openSavedRoom(room)"
>
<div
class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground"
>
@if (room.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + server.icon + ')'"
[style.backgroundImage]="'url(' + room.icon + ')'"
></div>
} @else {
{{ serverInitial(server) }}
{{ room.name[0]?.toUpperCase() || '?' }}
}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ server.name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ serverMetaLabel(server) }}</p>
</div>
<button
type="button"
class="shrink-0 rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
(click)="openServer(server)"
>
Join
</button>
</div>
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
</button>
}
</div>
} @else {
<p class="py-6 text-center text-sm text-muted-foreground">No popular servers right now.</p>
}
</div>
</section>
<!-- Your friends -->
@if (friends().length > 0) {
<section>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-foreground">Your Friends</h2>
<a
routerLink="/people"
class="text-xs font-medium text-primary hover:underline"
>Manage</a
>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
@for (friend of friends(); track friend.id) {
<div class="flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<app-user-avatar
[name]="personLabel(friend)"
[avatarUrl]="friend.avatarUrl"
size="md"
[status]="friend.status"
[showStatusBadge]="true"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{{ personLabel(friend) }}</p>
<p class="text-xs text-muted-foreground">{{ isOnline(friend) ? 'Online' : 'Offline' }}</p>
</div>
<app-friend-button [user]="friend" />
</div>
}
</div>
</section>
</section>
}
}
<!-- Recently active servers -->
@if (recentlyActiveServers().length > 0) {
<section>
<h2 class="mb-3 text-sm font-semibold text-foreground">Recently Active Servers</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
@for (room of recentlyActiveServers(); track room.id) {
<button
type="button"
class="flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50 hover:bg-card/80"
(click)="openSavedRoom(room)"
>
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-secondary text-base font-semibold text-foreground">
@if (room.icon) {
<div
aria-hidden="true"
class="h-full w-full bg-cover bg-center bg-no-repeat"
[style.backgroundImage]="'url(' + room.icon + ')'"
></div>
} @else {
{{ room.name[0]?.toUpperCase() || '?' }}
}
</div>
<p class="w-full truncate text-sm font-medium text-foreground">{{ room.name }}</p>
<p class="text-xs text-muted-foreground">{{ room.userCount }} members</p>
</button>
}
</div>
</section>
}
}
</div>
</div>
</div>
</ng-template>
@if (isMobile()) {

View File

@@ -1,6 +1,10 @@
<article
class="flex min-w-0 flex-col items-center justify-center overflow-hidden rounded-xl text-center"
[ngClass]="compact() ? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4' : 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'"
[ngClass]="
compact()
? 'min-h-[9.5rem] w-[12rem] shrink-0 p-3 sm:w-[14rem] sm:p-4'
: 'min-h-[14rem] w-full p-3 sm:min-h-[17rem] sm:p-[clamp(1.25rem,4vw,2rem)]'
"
>
<div
class="relative h-[var(--participant-avatar-size)] w-[var(--participant-avatar-size)] rounded-full ring-2 transition-all duration-150 sm:h-[var(--participant-avatar-size-sm)] sm:w-[var(--participant-avatar-size-sm)]"

View File

@@ -19,215 +19,215 @@
}
<ng-template #privateCallSurface>
<section
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
>
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
<section
class="grid h-full min-h-0 bg-background lg:grid-cols-[minmax(0,1fr)_var(--private-call-chat-width)]"
[style.--private-call-chat-width]="chatWidthPx() + 'px'"
>
<main class="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.10),transparent_34rem)]">
<header class="flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-3 backdrop-blur sm:px-5">
<div class="flex min-w-0 items-center gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-emerald-500/10 text-emerald-500">
<ng-icon
name="lucidePhone"
class="h-5 w-5"
/>
</div>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
<p class="truncate text-xs text-muted-foreground">
@if (session()) {
{{ participantUsers().length }} participants
} @else {
Call not found
}
</p>
</div>
</div>
<div class="min-w-0">
<h1 class="truncate text-base font-semibold text-foreground">Private Call</h1>
<p class="truncate text-xs text-muted-foreground">
@if (session()) {
{{ participantUsers().length }} participants
} @else {
Call not found
@if (session()) {
<div class="flex items-center gap-2">
@if (isMobile()) {
<button
type="button"
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
(click)="minimizeCall()"
aria-label="Minimize call"
title="Minimize call"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
/>
</button>
}
</p>
</div>
</div>
@if (session()) {
<div class="flex items-center gap-2">
@if (isMobile()) {
<select
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
[ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call"
>
<option value="">Add user</option>
@for (user of inviteCandidates(); track userKey(user)) {
<option [value]="userKey(user)">{{ user.displayName }}</option>
}
</select>
<button
type="button"
class="grid h-10 w-10 place-items-center rounded-full bg-secondary text-foreground transition-colors hover:bg-secondary/80"
(click)="minimizeCall()"
aria-label="Minimize call"
title="Minimize call"
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
[disabled]="!inviteUserId()"
(click)="inviteSelectedUser()"
aria-label="Add user"
title="Add user"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
name="lucideUserPlus"
class="h-4 w-4"
/>
</button>
}
<select
class="hidden h-9 max-w-44 rounded-md border border-border bg-secondary px-2 text-sm text-foreground sm:block"
[ngModel]="inviteUserId()"
(ngModelChange)="inviteUserId.set($event)"
aria-label="Add user to call"
>
<option value="">Add user</option>
@for (user of inviteCandidates(); track userKey(user)) {
<option [value]="userKey(user)">{{ user.displayName }}</option>
}
</select>
<button
type="button"
class="hidden h-9 w-9 place-items-center rounded-md bg-secondary text-foreground transition-colors hover:bg-secondary/80 disabled:opacity-50 sm:grid"
[disabled]="!inviteUserId()"
(click)="inviteSelectedUser()"
aria-label="Add user"
title="Add user"
>
<ng-icon
name="lucideUserPlus"
class="h-4 w-4"
/>
</button>
</div>
}
</header>
</div>
}
</header>
@if (session()) {
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
<div class="relative min-h-0 flex-1 overflow-hidden">
@if (activeShares().length > 0) {
@if (focusedShare()) {
@if (hasMultipleShares()) {
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
<button
type="button"
data-testid="private-call-show-all-streams"
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
</div>
}
<app-voice-workspace-stream-tile
[item]="focusedShare()!"
[featured]="true"
[focused]="true"
data-testid="private-call-focused-stream"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
} @else if (hasMultipleShares()) {
<div
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
data-testid="private-call-stream-grid"
>
@for (share of activeShares(); track share.id) {
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
<app-voice-workspace-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
@if (session()) {
<div class="flex min-h-0 flex-1 flex-col overflow-hidden px-3 py-3 sm:px-5 sm:py-4">
<div class="relative min-h-0 flex-1 overflow-hidden">
@if (activeShares().length > 0) {
@if (focusedShare()) {
@if (hasMultipleShares()) {
<div class="absolute right-3 top-3 z-10 sm:right-4 sm:top-4">
<button
type="button"
data-testid="private-call-show-all-streams"
class="inline-flex h-10 items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 text-xs font-medium text-white/80 backdrop-blur transition hover:bg-black/65 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
</div>
}
<app-voice-workspace-stream-tile
[item]="focusedShare()!"
[featured]="true"
[focused]="true"
data-testid="private-call-focused-stream"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
} @else if (hasMultipleShares()) {
<div
class="grid h-full min-h-0 auto-rows-[minmax(12rem,1fr)] grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:gap-4 sm:p-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
data-testid="private-call-stream-grid"
>
@for (share of activeShares(); track share.id) {
<div class="min-h-0 overflow-hidden rounded-2xl bg-black">
<app-voice-workspace-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
}
} @else {
<div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
<div
class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
>
@for (user of participantUsers(); track trackUserKey($index, user)) {
<app-private-call-participant-card
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
/>
}
</div>
</div>
}
} @else {
<div class="flex h-full min-h-0 items-center justify-center p-1 sm:p-5">
<div
class="grid w-full max-w-7xl grid-cols-[repeat(auto-fit,minmax(min(11rem,100%),1fr))] items-stretch justify-center gap-3 sm:grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))] sm:gap-5 lg:gap-7"
>
</div>
@if (activeShares().length > 0) {
<div class="shrink-0 pt-4">
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
@for (user of participantUsers(); track trackUserKey($index, user)) {
<app-private-call-participant-card
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[compact]="true"
/>
}
@if (hasMultipleShares()) {
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
<article
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
>
<div class="min-h-0 flex-1">
<app-voice-workspace-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
{{ streamLabel(share) }}
</div>
</article>
}
}
</div>
</div>
}
</div>
@if (activeShares().length > 0) {
<div class="shrink-0 pt-4">
<div class="flex w-full items-stretch gap-3 overflow-x-auto pb-1">
@for (user of participantUsers(); track trackUserKey($index, user)) {
<app-private-call-participant-card
[user]="user"
[connected]="isParticipantConnected(user)"
[speaking]="isSpeaking(user)"
[issueLabel]="participantIssueLabel(user)"
[compact]="true"
/>
}
@if (hasMultipleShares()) {
@for (share of focusedShare() ? thumbnailShares() : activeShares(); track share.id) {
<article
class="flex min-h-[8.75rem] w-[11rem] shrink-0 flex-col overflow-hidden rounded-2xl border border-border/80 bg-black shadow-sm sm:w-[12.5rem]"
>
<div class="min-h-0 flex-1">
<app-voice-workspace-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
<div class="shrink-0 bg-black/80 px-3 py-2 text-xs font-semibold text-white/75">
{{ streamLabel(share) }}
</div>
</article>
}
}
</div>
<div class="shrink-0 pt-3">
<app-private-call-controls
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(leaveRequested)="leave()"
/>
</div>
}
<div class="shrink-0 pt-3">
<app-private-call-controls
class="mx-auto block w-full max-w-5xl"
[connected]="isConnected()"
[muted]="isMuted()"
[cameraEnabled]="isCameraEnabled()"
[screenSharing]="isScreenSharing()"
(joinRequested)="join()"
(muteToggled)="toggleMute()"
(cameraToggled)="toggleCamera()"
(screenShareToggled)="toggleScreenShare()"
(leaveRequested)="leave()"
/>
</div>
</div>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
}
</main>
} @else {
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">No active call for this route.</div>
}
</main>
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
<div
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
role="separator"
aria-orientation="vertical"
title="Resize chat"
data-testid="private-call-chat-resizer"
(mousedown)="startChatResize($event)"
>
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
</div>
<app-dm-chat
[conversationId]="session()?.conversationId ?? null"
[showCallButton]="false"
/>
</aside>
</section>
<aside class="relative hidden min-h-0 border-l border-border bg-card lg:block">
<div
class="group absolute inset-y-0 left-0 z-10 w-3 -translate-x-1/2 cursor-col-resize bg-transparent"
role="separator"
aria-orientation="vertical"
title="Resize chat"
data-testid="private-call-chat-resizer"
(mousedown)="startChatResize($event)"
>
<div class="mx-auto h-full w-px bg-border transition group-hover:bg-primary"></div>
</div>
<app-dm-chat
[conversationId]="session()?.conversationId ?? null"
[showCallButton]="false"
/>
</aside>
</section>
</ng-template>
@if (showScreenShareQualityDialog()) {

View File

@@ -124,7 +124,9 @@
@if (immersive() && item().kind === 'screen' && !isFullscreen()) {
<div class="absolute inset-x-3 bottom-3 z-20 sm:inset-x-5 sm:bottom-5">
<div class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4">
<div
class="mx-auto flex w-full max-w-3xl flex-wrap items-center justify-center gap-2 rounded-2xl border border-white/10 bg-black/55 px-3 py-3 text-white/80 shadow-2xl backdrop-blur-lg sm:gap-3 sm:px-4"
>
@if (canControlStreamAudio()) {
<div class="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-white/10 px-2.5 py-2 sm:max-w-md">
<button

View File

@@ -148,9 +148,8 @@
<div class="rounded-lg border border-border bg-secondary/20 p-4 space-y-3">
<p class="text-xs text-muted-foreground">
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide
apps that get mistakenly identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively
against the executable name without its extension.
MetoYou prefers the currently focused window when detecting your game. Add process names here to permanently hide apps that get mistakenly
identified as games (e.g. "spotify", "obs64"). Entries are matched case-insensitively against the executable name without its extension.
</p>
<div class="flex items-center gap-2">