Now formatted correctly with eslint
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
<div class="h-full flex flex-col bg-card">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border flex items-center gap-2">
|
||||
<ng-icon name="lucideShield" class="w-5 h-5 text-primary" />
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +20,10 @@
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
@@ -29,7 +35,10 @@
|
||||
[class.border-primary]="activeTab() === 'members'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'members'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Members
|
||||
</button>
|
||||
<button
|
||||
@@ -41,7 +50,10 @@
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
@@ -53,7 +65,10 @@
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideShield" class="w-4 h-4 inline mr-1" />
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-4 h-4 inline mr-1"
|
||||
/>
|
||||
Perms
|
||||
</button>
|
||||
</div>
|
||||
@@ -67,7 +82,11 @@
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label for="room-name-input" class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
||||
<label
|
||||
for="room-name-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Room Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="room-name-input"
|
||||
@@ -78,7 +97,11 @@
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label for="room-description-input" class="block text-sm text-muted-foreground mb-1">Description</label>
|
||||
<label
|
||||
for="room-description-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
id="room-description-input"
|
||||
[(ngModel)]="roomDescription"
|
||||
@@ -103,16 +126,26 @@
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label for="max-users-input" class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
||||
<label
|
||||
for="max-users-input"
|
||||
class="block text-sm text-muted-foreground mb-1"
|
||||
>Max Users (0 = unlimited)</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="max-users-input"
|
||||
@@ -128,7 +161,10 @@
|
||||
(click)="saveSettings()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
@@ -140,7 +176,10 @@
|
||||
(click)="confirmDeleteRoom()"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
@@ -151,13 +190,14 @@
|
||||
<h3 class="text-sm font-medium text-foreground">Server Members</h3>
|
||||
|
||||
@if (membersFiltered().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No other members online
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
||||
<app-user-avatar
|
||||
[name]="user.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ user.displayName }}</p>
|
||||
@@ -188,7 +228,10 @@
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -196,7 +239,10 @@
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -210,9 +256,7 @@
|
||||
<h3 class="text-sm font-medium text-foreground">Banned Users</h3>
|
||||
|
||||
@if (bannedUsers().length === 0) {
|
||||
<p class="text-sm text-muted-foreground text-center py-8">
|
||||
No banned users
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground text-center py-8">No banned users</p>
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
@@ -224,14 +268,10 @@
|
||||
{{ ban.displayName || 'Unknown User' }}
|
||||
</p>
|
||||
@if (ban.reason) {
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
Reason: {{ ban.reason }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
@@ -241,7 +281,10 @@
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -363,7 +406,10 @@
|
||||
(click)="savePermissions()"
|
||||
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -35,7 +39,13 @@ type AdminTab = 'settings' | 'members' | 'bans' | 'permissions';
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
@@ -181,7 +191,8 @@ export class AdminPanelComponent {
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Members tab: get all users except self
|
||||
@@ -194,7 +205,9 @@ export class AdminPanelComponent {
|
||||
|
||||
/** Change a member's role and broadcast the update to all peers. */
|
||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||
role }));
|
||||
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideLogIn" class="w-5 h-5 text-primary" />
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="login-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<label
|
||||
for="login-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
@@ -16,7 +23,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
@@ -25,7 +36,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<label
|
||||
for="login-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html'
|
||||
})
|
||||
@@ -43,7 +51,9 @@ export class LoginComponent {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.login({ username: this.username.trim(), password: this.password, serverId: sid }).subscribe({
|
||||
this.auth.login({ username: this.username.trim(),
|
||||
password: this.password,
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<div class="h-full grid place-items-center bg-background">
|
||||
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideUserPlus" class="w-5 h-5 text-primary" />
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-5 h-5 text-primary"
|
||||
/>
|
||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="register-username" class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<label
|
||||
for="register-username"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Username</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
@@ -16,7 +23,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="register-display-name" class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<label
|
||||
for="register-display-name"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Display Name</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
@@ -25,7 +36,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="register-password" class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<label
|
||||
for="register-password"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
@@ -34,7 +49,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="register-server" class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<label
|
||||
for="register-server"
|
||||
class="block text-xs text-muted-foreground mb-1"
|
||||
>Server App</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, max-statements-per-line */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -16,7 +20,11 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html'
|
||||
})
|
||||
@@ -44,7 +52,10 @@ export class RegisterComponent {
|
||||
this.error.set(null);
|
||||
const sid = this.serverId || this.serversSvc.activeServer()?.id;
|
||||
|
||||
this.auth.register({ username: this.username.trim(), password: this.password, displayName: this.displayName.trim(), serverId: sid }).subscribe({
|
||||
this.auth.register({ username: this.username.trim(),
|
||||
password: this.password,
|
||||
displayName: this.displayName.trim(),
|
||||
serverId: sid }).subscribe({
|
||||
next: (resp) => {
|
||||
if (sid)
|
||||
this.serversSvc.setActiveServer(sid);
|
||||
|
||||
@@ -2,16 +2,33 @@
|
||||
<div class="flex-1"></div>
|
||||
@if (user()) {
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<ng-icon name="lucideUser" class="w-4 h-4 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideUser"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" (click)="goto('login')" class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1">
|
||||
<ng-icon name="lucideLogIn" class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('login')"
|
||||
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLogIn"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Login
|
||||
</button>
|
||||
<button type="button" (click)="goto('register')" class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1">
|
||||
<ng-icon name="lucideUserPlus" class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="goto('register')"
|
||||
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUserPlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Register
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -4,14 +4,22 @@ import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUser, lucideLogIn, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideUser,
|
||||
lucideLogIn,
|
||||
lucideUserPlus })
|
||||
],
|
||||
templateUrl: './user-bar.component.html'
|
||||
})
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-len, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, effect, ElementRef, ViewChild, AfterViewChecked, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
AfterViewChecked,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
ChangeDetectorRef
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -21,7 +33,11 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { selectAllMessages, selectMessagesLoading, selectMessagesSyncing } from '../../../store/messages/messages.selectors';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectMessagesLoading,
|
||||
selectMessagesSyncing
|
||||
} from '../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
@@ -36,12 +52,30 @@ import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import { ChatMarkdownService } from './services/chat-markdown.service';
|
||||
|
||||
const COMMON_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🎉', '🔥', '👀'];
|
||||
const COMMON_EMOJIS = [
|
||||
'👍',
|
||||
'❤️',
|
||||
'😂',
|
||||
'😮',
|
||||
'😢',
|
||||
'🎉',
|
||||
'🔥',
|
||||
'👀'
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-messages',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ContextMenuComponent, UserAvatarComponent, TypingIndicatorComponent, RemarkModule, MermaidComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
TypingIndicatorComponent,
|
||||
RemarkModule,
|
||||
MermaidComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideSend,
|
||||
@@ -105,7 +139,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
});
|
||||
|
||||
/** Paginated view — only the most recent `displayLimit` messages */
|
||||
/** Paginated view - only the most recent `displayLimit` messages */
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
@@ -391,7 +425,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const el = container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.scrollIntoView({ behavior: 'smooth',
|
||||
block: 'center' });
|
||||
|
||||
el.classList.add('bg-primary/10');
|
||||
setTimeout(() => el.classList.remove('bg-primary/10'), 2000);
|
||||
}
|
||||
@@ -406,7 +442,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Add a reaction emoji to a message. */
|
||||
addReaction(messageId: string, emoji: string): void {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||
emoji }));
|
||||
|
||||
this.showEmojiPicker.set(null);
|
||||
}
|
||||
|
||||
@@ -423,9 +461,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
|
||||
if (hasReacted) {
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.removeReaction({ messageId,
|
||||
emoji }));
|
||||
} else {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId, emoji }));
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||
emoji }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +480,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
|
||||
message.reactions.forEach((reaction) => {
|
||||
const existing = groups.get(reaction.emoji) || { count: 0, hasCurrentUser: false };
|
||||
const existing = groups.get(reaction.emoji) || { count: 0,
|
||||
hasCurrentUser: false };
|
||||
|
||||
groups.set(reaction.emoji, {
|
||||
count: existing.count + 1,
|
||||
@@ -458,7 +499,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const time = date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' });
|
||||
// Compare calendar days (midnight-aligned) to avoid NG0100 flicker
|
||||
const toDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
|
||||
@@ -470,7 +512,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
} else if (dayDiff < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time;
|
||||
return date.toLocaleDateString([], { month: 'short',
|
||||
day: 'numeric' }) + ' ' + time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +556,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.initialScrollObserver = new MutationObserver(() => {
|
||||
requestAnimationFrame(snap);
|
||||
});
|
||||
|
||||
this.initialScrollObserver.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
@@ -550,7 +594,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const el = this.messagesContainer.nativeElement;
|
||||
|
||||
try {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
el.scrollTo({ top: el.scrollHeight,
|
||||
behavior: 'smooth' });
|
||||
} catch {
|
||||
// Fallback if smooth not supported
|
||||
el.scrollTop = el.scrollHeight;
|
||||
@@ -591,7 +636,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.stopInitialScrollWatch();
|
||||
}
|
||||
|
||||
// Infinite scroll upwards — load older messages when near the top
|
||||
// Infinite scroll upwards - load older messages when near the top
|
||||
if (el.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
|
||||
this.loadMore();
|
||||
}
|
||||
@@ -640,7 +685,8 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private getSelection(): { start: number; end: number } {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
return { start: el?.selectionStart ?? this.messageContent.length, end: el?.selectionEnd ?? this.messageContent.length };
|
||||
return { start: el?.selectionStart ?? this.messageContent.length,
|
||||
end: el?.selectionEnd ?? this.messageContent.length };
|
||||
}
|
||||
|
||||
private setSelection(start: number, end: number): void {
|
||||
@@ -772,7 +818,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const units = [
|
||||
'B',
|
||||
'KB',
|
||||
'MB',
|
||||
'GB'
|
||||
];
|
||||
|
||||
let size = bytes;
|
||||
let i = 0;
|
||||
@@ -787,7 +838,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
if (!bps || bps <= 0)
|
||||
return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
const units = [
|
||||
'B/s',
|
||||
'KB/s',
|
||||
'MB/s',
|
||||
'GB/s'
|
||||
];
|
||||
|
||||
let speed = bps;
|
||||
let i = 0;
|
||||
@@ -855,7 +911,9 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.imageContextMenu.set({ x: event.clientX, y: event.clientY, attachment: att });
|
||||
this.imageContextMenu.set({ x: event.clientX,
|
||||
y: event.clientY,
|
||||
attachment: att });
|
||||
}
|
||||
|
||||
/** Close the image context menu. */
|
||||
@@ -876,9 +934,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
// Convert to PNG for clipboard compatibility
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ 'image/png': pngBlob })
|
||||
]);
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export class ChatMarkdownService {
|
||||
const newText = `${before}${token}${selected}${token}${after}`;
|
||||
const cursor = before.length + token.length + selected.length + token.length;
|
||||
|
||||
return { text: newText, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text: newText,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
|
||||
@@ -34,7 +36,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
|
||||
@@ -49,7 +53,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${block}${after}`;
|
||||
const cursor = before.length + block.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -62,7 +68,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${newSelected}${after}`;
|
||||
const cursor = before.length + newSelected.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -74,7 +82,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${fenced}${after}`;
|
||||
const cursor = before.length + fenced.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
applyLink(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -87,7 +97,9 @@ export class ChatMarkdownService {
|
||||
const cursorStart = before.length + link.length - 1;
|
||||
|
||||
// Position inside the URL placeholder
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyImage(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -99,7 +111,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${img}${after}`;
|
||||
const cursorStart = before.length + img.length - 1;
|
||||
|
||||
return { text, selectionStart: cursorStart - 8, selectionEnd: cursorStart - 1 };
|
||||
return { text,
|
||||
selectionStart: cursorStart - 8,
|
||||
selectionEnd: cursorStart - 1 };
|
||||
}
|
||||
|
||||
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
|
||||
@@ -110,7 +124,9 @@ export class ChatMarkdownService {
|
||||
const text = `${before}${hr}${after}`;
|
||||
const cursor = before.length + hr.length;
|
||||
|
||||
return { text, selectionStart: cursor, selectionEnd: cursor };
|
||||
return { text,
|
||||
selectionStart: cursor,
|
||||
selectionEnd: cursor };
|
||||
}
|
||||
|
||||
appendImageMarkdown(content: string): string {
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, @typescript-eslint/no-explicit-any */
|
||||
import { Component, inject, signal, DestroyRef } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
DestroyRef
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { merge, interval, filter, map, tap } from 'rxjs';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
filter,
|
||||
map,
|
||||
tap
|
||||
} from 'rxjs';
|
||||
|
||||
const TYPING_TTL = 3_000;
|
||||
const PURGE_INTERVAL = 1_000;
|
||||
|
||||
@@ -1,161 +1,202 @@
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground">Members</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||
@if (voiceUsers().length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (v of voiceUsers(); track v.id) {
|
||||
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{{ v.displayName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground">Members</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
|
||||
@if (voiceUsers().length > 0) {
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@for (v of voiceUsers(); track v.id) {
|
||||
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{{ v.displayName }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
@for (user of onlineUsers(); track user.id) {
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
@for (user of onlineUsers(); track user.id) {
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Avatar with online indicator -->
|
||||
<div class="relative">
|
||||
<app-user-avatar [name]="user.displayName" size="sm" />
|
||||
<span class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
|
||||
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
|
||||
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-sm text-foreground truncate">
|
||||
{{ user.displayName }}
|
||||
</span>
|
||||
@if (user.isAdmin) {
|
||||
<ng-icon name="lucideShield" class="w-3 h-3 text-primary" />
|
||||
}
|
||||
@if (user.isRoomOwner) {
|
||||
<ng-icon name="lucideCrown" class="w-3 h-3 text-yellow-500" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
<div class="flex items-center gap-1">
|
||||
@if (user.voiceState?.isSpeaking) {
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-green-500 animate-pulse" />
|
||||
} @else if (user.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
} @else if (user.voiceState?.isConnected) {
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
|
||||
@if (user.screenShareState?.isSharing) {
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4 text-primary" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="muteUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
@if (user.voiceState?.isMutedByAdmin) {
|
||||
<ng-icon name="lucideVolume2" class="w-4 h-4" />
|
||||
<span>Unmute</span>
|
||||
} @else {
|
||||
<ng-icon name="lucideVolumeX" class="w-4 h-4" />
|
||||
<span>Mute</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<span>Ban</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (onlineUsers().length === 0) {
|
||||
<div class="text-center py-8 text-muted-foreground text-sm">
|
||||
No users online
|
||||
</div>
|
||||
}
|
||||
<!-- User Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium text-sm text-foreground truncate">
|
||||
{{ user.displayName }}
|
||||
</span>
|
||||
@if (user.isAdmin) {
|
||||
<ng-icon
|
||||
name="lucideShield"
|
||||
class="w-3 h-3 text-primary"
|
||||
/>
|
||||
}
|
||||
@if (user.isRoomOwner) {
|
||||
<ng-icon
|
||||
name="lucideCrown"
|
||||
class="w-3 h-3 text-yellow-500"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ban Dialog -->
|
||||
@if (showBanDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Ban User"
|
||||
confirmLabel="Ban User"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="confirmBan()"
|
||||
(cancelled)="closeBanDialog()"
|
||||
>
|
||||
<p class="mb-4">
|
||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span>?
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="ban-reason-input" class="block text-sm font-medium text-foreground mb-1">Reason (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
id="ban-reason-input"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
<!-- Voice/Screen Status -->
|
||||
<div class="flex items-center gap-1">
|
||||
@if (user.voiceState?.isSpeaking) {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-4 h-4 text-green-500 animate-pulse"
|
||||
/>
|
||||
</div>
|
||||
} @else if (user.voiceState?.isMuted) {
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
} @else if (user.voiceState?.isConnected) {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
|
||||
<div>
|
||||
<label for="ban-duration-select" class="block text-sm font-medium text-foreground mb-1">Duration</label>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
id="ban-duration-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@if (user.screenShareState?.isSharing) {
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-4 h-4 text-primary"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
>
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="muteUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
|
||||
>
|
||||
@if (user.voiceState?.isMutedByAdmin) {
|
||||
<ng-icon
|
||||
name="lucideVolume2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Unmute</span>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideVolumeX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Mute</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
(click)="kickUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
<option value="86400000">1 day</option>
|
||||
<option value="604800000">1 week</option>
|
||||
<option value="2592000000">30 days</option>
|
||||
<option value="0">Permanent</option>
|
||||
</select>
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Kick</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="banUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Ban</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (onlineUsers().length === 0) {
|
||||
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Ban Dialog -->
|
||||
@if (showBanDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Ban User"
|
||||
confirmLabel="Ban User"
|
||||
variant="danger"
|
||||
[widthClass]="'w-96 max-w-[90vw]'"
|
||||
(confirmed)="confirmBan()"
|
||||
(cancelled)="closeBanDialog()"
|
||||
>
|
||||
<p class="mb-4">
|
||||
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
|
||||
>?
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="ban-reason-input"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Reason (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="banReason"
|
||||
placeholder="Enter ban reason..."
|
||||
id="ban-reason-input"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="ban-duration-select"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Duration</label
|
||||
>
|
||||
<select
|
||||
[(ngModel)]="banDuration"
|
||||
id="ban-duration-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="3600000">1 hour</option>
|
||||
<option value="86400000">1 day</option>
|
||||
<option value="604800000">1 week</option>
|
||||
<option value="2592000000">30 days</option>
|
||||
<option value="0">Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, computed } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -29,7 +34,13 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
|
||||
@@ -29,7 +29,10 @@
|
||||
<!-- No Room Selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon name="lucideHash" class="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-30"
|
||||
/>
|
||||
<h2 class="text-xl font-medium mb-2">No room selected</h2>
|
||||
<p class="text-sm">Select or create a room to start chatting</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
||||
>
|
||||
<ng-icon name="lucideHash" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Channels</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -25,11 +28,12 @@
|
||||
[class.text-muted-foreground]="activeTab() !== 'users'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'users'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>Users</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{
|
||||
onlineUsers().length
|
||||
}}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-primary/15 text-primary">{{ onlineUsers().length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,16 +44,17 @@
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
Text Channels
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('text')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Create Text Channel"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -89,16 +94,17 @@
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
Voice Channels
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
|
||||
@if (canManageChannels()) {
|
||||
<button
|
||||
(click)="createChannel('voice')"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Create Voice Channel"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -116,7 +122,10 @@
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
@if (renamingChannelId() === ch.id) {
|
||||
<input
|
||||
#renameInput
|
||||
@@ -155,17 +164,13 @@
|
||||
: 'ring-2 ring-green-500/40'
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{
|
||||
u.displayName
|
||||
}}</span>
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
<!-- Ping latency indicator -->
|
||||
@if (u.id !== currentUser()?.id) {
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
[class]="getPingColorClass(u)"
|
||||
[title]="
|
||||
getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'
|
||||
"
|
||||
[title]="getPeerLatency(u) !== null ? getPeerLatency(u) + ' ms' : 'Measuring...'"
|
||||
></span>
|
||||
}
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
@@ -177,7 +182,10 @@
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -196,9 +204,7 @@
|
||||
<!-- Current User (You) -->
|
||||
@if (currentUser()) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
||||
You
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">You</h4>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||
<div class="relative">
|
||||
<app-user-avatar
|
||||
@@ -206,27 +212,26 @@
|
||||
[avatarUrl]="currentUser()?.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
||||
></span>
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (currentUser()?.voiceState?.isConnected) {
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (
|
||||
currentUser()?.screenShareState?.isSharing ||
|
||||
(currentUser()?.id && isUserSharing(currentUser()!.id))
|
||||
) {
|
||||
<span
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse"
|
||||
>
|
||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
||||
@if (currentUser()?.screenShareState?.isSharing || (currentUser()?.id && isUserSharing(currentUser()!.id))) {
|
||||
<span class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium flex items-center gap-1 animate-pulse">
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</span>
|
||||
}
|
||||
@@ -239,9 +244,7 @@
|
||||
<!-- Other Online Users -->
|
||||
@if (onlineUsersFiltered().length > 0) {
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">
|
||||
Online — {{ onlineUsersFiltered().length }}
|
||||
</h4>
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Online - {{ onlineUsersFiltered().length }}</h4>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
<div
|
||||
@@ -254,34 +257,26 @@
|
||||
[avatarUrl]="user.avatarUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"
|
||||
></span>
|
||||
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500 ring-2 ring-card"></span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
|
||||
@if (user.role === 'host') {
|
||||
<span
|
||||
class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium"
|
||||
>Owner</span
|
||||
>
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span
|
||||
class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium"
|
||||
>Admin</span
|
||||
>
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span
|
||||
class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium"
|
||||
>Mod</span
|
||||
>
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (user.voiceState?.isConnected) {
|
||||
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<ng-icon name="lucideMic" class="w-2.5 h-2.5" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@@ -290,7 +285,10 @@
|
||||
(click)="viewStream(user.id); $event.stopPropagation()"
|
||||
class="text-[10px] bg-red-500 text-white px-1.5 py-0.5 rounded-sm font-medium hover:bg-red-600 transition-colors flex items-center gap-1 animate-pulse"
|
||||
>
|
||||
<ng-icon name="lucideMonitor" class="w-2.5 h-2.5" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@@ -327,42 +325,81 @@
|
||||
(closed)="closeChannelMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
<button (click)="resyncMessages()" class="context-menu-item">Resync Messages</button>
|
||||
<button
|
||||
(click)="resyncMessages()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Resync Messages
|
||||
</button>
|
||||
@if (canManageChannels()) {
|
||||
<div class="context-menu-divider"></div>
|
||||
<button (click)="startRename()" class="context-menu-item">Rename Channel</button>
|
||||
<button (click)="deleteChannel()" class="context-menu-item-danger">Delete Channel</button>
|
||||
<button
|
||||
(click)="startRename()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Rename Channel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteChannel()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Delete Channel
|
||||
</button>
|
||||
}
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
<!-- User context menu (kick / role management) -->
|
||||
@if (showUserMenu()) {
|
||||
<app-context-menu [x]="userMenuX()" [y]="userMenuY()" (closed)="closeUserMenu()">
|
||||
<app-context-menu
|
||||
[x]="userMenuX()"
|
||||
[y]="userMenuY()"
|
||||
(closed)="closeUserMenu()"
|
||||
>
|
||||
@if (isAdmin()) {
|
||||
@if (contextMenuUser()?.role === 'member') {
|
||||
<button (click)="changeUserRole('moderator')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('moderator')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Moderator
|
||||
</button>
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'moderator') {
|
||||
<button (click)="changeUserRole('admin')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('admin')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Promote to Admin
|
||||
</button>
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
@if (contextMenuUser()?.role === 'admin') {
|
||||
<button (click)="changeUserRole('member')" class="context-menu-item">
|
||||
<button
|
||||
(click)="changeUserRole('member')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Demote to Member
|
||||
</button>
|
||||
}
|
||||
<div class="context-menu-divider"></div>
|
||||
<button (click)="kickUserAction()" class="context-menu-item-danger">Kick User</button>
|
||||
<button
|
||||
(click)="kickUserAction()"
|
||||
class="context-menu-item-danger"
|
||||
>
|
||||
Kick User
|
||||
</button>
|
||||
} @else {
|
||||
<div class="context-menu-empty">No actions available</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -32,8 +36,17 @@ import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceActivityService } from '../../../core/services/voice-activity.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
import { ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
import { Channel, User } from '../../../core/models';
|
||||
import {
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
} from '../../../shared';
|
||||
import {
|
||||
Channel,
|
||||
ChatEvent,
|
||||
Room,
|
||||
User
|
||||
} from '../../../core/models';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
@@ -41,15 +54,7 @@ type TabView = 'channels' | 'users';
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
VoiceControlsComponent,
|
||||
ContextMenuComponent,
|
||||
UserAvatarComponent,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
imports: [CommonModule, FormsModule, NgIcon, VoiceControlsComponent, ContextMenuComponent, UserAvatarComponent, ConfirmDialogComponent],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMessageSquare,
|
||||
@@ -110,9 +115,7 @@ export class RoomsSidePanelComponent {
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) => user.id !== currentId && user.oderId !== currentOderId
|
||||
);
|
||||
return this.onlineUsers().filter((user) => user.id !== currentId && user.oderId !== currentOderId);
|
||||
}
|
||||
|
||||
/** Check whether the current user has permission to manage channels. */
|
||||
@@ -218,12 +221,14 @@ export class RoomsSidePanelComponent {
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (peers.length === 0) {
|
||||
// No connected peers — sync will time out
|
||||
// No connected peers - sync will time out
|
||||
}
|
||||
|
||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, { type: 'chat-inventory-request', roomId: room.id } as any);
|
||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||
} catch (_error) {
|
||||
// Failed to send inventory request to this peer
|
||||
}
|
||||
@@ -327,6 +332,9 @@ export class RoomsSidePanelComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room)
|
||||
return;
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
@@ -334,7 +342,7 @@ export class RoomsSidePanelComponent {
|
||||
// clear it so the user can join.
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
if (!this.webrtc.isVoiceConnected()) {
|
||||
// Stale state – clear it so the user can proceed
|
||||
// Stale state - clear it so the user can proceed
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -356,67 +364,76 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
|
||||
// If switching channels within the same server, just update the room
|
||||
const isSwitchingChannels =
|
||||
current?.voiceState?.isConnected &&
|
||||
current.voiceState.serverId === room?.id &&
|
||||
current.voiceState.roomId !== roomId;
|
||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||
// Enable microphone and broadcast voice-state
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||
|
||||
enableVoicePromise
|
||||
.then(() => {
|
||||
if (current?.id && room) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Start voice heartbeat to broadcast presence every 5 seconds
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room?.id);
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId: roomId,
|
||||
serverId: room?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Update voice session for floating controls
|
||||
if (room) {
|
||||
// Find label from channel list
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId: roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((_error) => {
|
||||
// Failed to join voice room
|
||||
});
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
this.updateVoiceStateStore(roomId, room, current);
|
||||
this.startVoiceHeartbeat(roomId, room);
|
||||
this.broadcastVoiceConnected(roomId, room, current);
|
||||
this.startVoiceSession(roomId, room);
|
||||
}
|
||||
|
||||
private updateVoiceStateStore(roomId: string, room: Room, current: User | null): void {
|
||||
if (!current?.id)
|
||||
return;
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private startVoiceHeartbeat(roomId: string, room: Room): void {
|
||||
this.webrtc.startVoiceHeartbeat(roomId, room.id);
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startVoiceSession(roomId: string, room: Room): void {
|
||||
const voiceChannel = this.voiceChannels().find((channel) => channel.id === roomId);
|
||||
const voiceRoomName = voiceChannel ? `🔊 ${voiceChannel.name}` : roomId;
|
||||
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`
|
||||
});
|
||||
}
|
||||
|
||||
/** Leave a voice channel and broadcast the disconnect state. */
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
@@ -470,12 +487,8 @@ export class RoomsSidePanelComponent {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return users.filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
return users.filter((user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id)
|
||||
.length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
@@ -500,9 +513,7 @@ export class RoomsSidePanelComponent {
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
const user = this.onlineUsers().find(
|
||||
(onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId
|
||||
);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId);
|
||||
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
return false;
|
||||
@@ -518,10 +529,7 @@ export class RoomsSidePanelComponent {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return this.onlineUsers().filter(
|
||||
(user) =>
|
||||
!!user.voiceState?.isConnected &&
|
||||
user.voiceState?.roomId === roomId &&
|
||||
user.voiceState?.serverId === room?.id
|
||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,11 +538,7 @@ export class RoomsSidePanelComponent {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
);
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
}
|
||||
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
@@ -558,8 +562,8 @@ export class RoomsSidePanelComponent {
|
||||
/**
|
||||
* Return a Tailwind `bg-*` class representing the latency quality.
|
||||
* - green : < 100 ms
|
||||
* - yellow : 100–199 ms
|
||||
* - orange : 200–349 ms
|
||||
* - yellow : 100-199 ms
|
||||
* - orange : 200-349 ms
|
||||
* - red : >= 350 ms
|
||||
* - gray : no data yet
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,10 @@
|
||||
class="p-2 bg-secondary hover:bg-secondary/80 rounded-lg border border-border transition-colors"
|
||||
title="Settings"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +55,10 @@
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +71,10 @@
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ng-icon name="lucideSearch" class="w-12 h-12 mb-4 opacity-50" />
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="w-12 h-12 mb-4 opacity-50"
|
||||
/>
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
@@ -84,9 +93,15 @@
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (server.isPrivate) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideGlobe" class="w-4 h-4 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (server.description) {
|
||||
@@ -101,13 +116,14 @@
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
Hosted by {{ server.hostName }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -145,7 +161,11 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="create-server-name" class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<label
|
||||
for="create-server-name"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Server Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
@@ -156,7 +176,11 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create-server-description" class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<label
|
||||
for="create-server-description"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
@@ -167,7 +191,11 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="create-server-topic" class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<label
|
||||
for="create-server-topic"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Topic (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
@@ -184,12 +212,20 @@
|
||||
id="private"
|
||||
class="w-4 h-4 rounded border-border bg-secondary"
|
||||
/>
|
||||
<label for="private" class="text-sm text-foreground">Private server</label>
|
||||
<label
|
||||
for="private"
|
||||
class="text-sm text-foreground"
|
||||
>Private server</label
|
||||
>
|
||||
</div>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label for="create-server-password" class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<label
|
||||
for="create-server-password"
|
||||
class="block text-sm font-medium text-foreground mb-1"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideSearch,
|
||||
@@ -147,16 +156,20 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
this.joinServer({
|
||||
this.joinServer(this.toServerInfo(room));
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room): ServerInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown',
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers || 50,
|
||||
maxUsers: room.maxUsers ?? 50,
|
||||
isPrivate: !!room.password,
|
||||
createdAt: room.createdAt
|
||||
} as any);
|
||||
};
|
||||
}
|
||||
|
||||
private resetCreateForm(): void {
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
|
||||
<!-- Create button -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
title="Create Server"
|
||||
(click)="createServer()"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||
[title]="room.name"
|
||||
(click)="joinSavedRoom(room)"
|
||||
(contextmenu)="openContextMenu($event, room)"
|
||||
>
|
||||
@if (room.icon) {
|
||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
||||
<img
|
||||
[ngSrc]="room.icon"
|
||||
[alt]="room.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<span class="text-sm font-semibold text-muted-foreground">{{ initial(room.name) }}</span>
|
||||
@@ -31,11 +40,28 @@
|
||||
|
||||
<!-- Context menu -->
|
||||
@if (showMenu()) {
|
||||
<app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-44'">
|
||||
<app-context-menu
|
||||
[x]="menuX()"
|
||||
[y]="menuY()"
|
||||
(closed)="closeMenu()"
|
||||
[width]="'w-44'"
|
||||
>
|
||||
@if (isCurrentContextRoom()) {
|
||||
<button (click)="leaveServer()" class="context-menu-item">Leave Server</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<button (click)="openForgetConfirm()" class="context-menu-item">Forget Server</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="openForgetConfirm()"
|
||||
class="context-menu-item"
|
||||
>
|
||||
Forget Server
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@@ -48,6 +74,8 @@
|
||||
(cancelled)="cancelForget()"
|
||||
[widthClass]="'w-[280px]'"
|
||||
>
|
||||
<p>Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.</p>
|
||||
<p>
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -16,7 +20,7 @@ import { ContextMenuComponent, ConfirmDialogComponent } from '../../shared';
|
||||
@Component({
|
||||
selector: 'app-servers-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent],
|
||||
imports: [CommonModule, NgIcon, ContextMenuComponent, ConfirmDialogComponent, NgOptimizedImage],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
@@ -92,14 +96,16 @@ export class ServersRailComponent {
|
||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||
} else {
|
||||
// First time joining this server
|
||||
this.store.dispatch(RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
}));
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
serverInfo: {
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
hostName: room.hostId || 'Unknown'
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +114,7 @@ export class ServersRailComponent {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
||||
this.menuX.set(Math.max((evt.clientX + 8), 72));
|
||||
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||
this.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
@@ -161,5 +167,4 @@ export class ServersRailComponent {
|
||||
cancelForget(): void {
|
||||
this.showConfirm.set(false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
} @else {
|
||||
@for (ban of bannedUsers(); track ban.oderId) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-destructive/20 flex items-center justify-center text-destructive font-semibold text-sm">
|
||||
{{ ban.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -18,19 +16,21 @@
|
||||
<p class="text-xs text-muted-foreground truncate">Reason: {{ ban.reason }}</p>
|
||||
}
|
||||
@if (ban.expiresAt) {
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Expires: {{ formatExpiry(ban.expiresAt) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">Expires: {{ formatExpiry(ban.expiresAt) }}</p>
|
||||
} @else {
|
||||
<p class="text-xs text-destructive">Permanent</p>
|
||||
}
|
||||
</div>
|
||||
@if (isAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="unbanUser(ban)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -38,7 +38,5 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -40,7 +44,8 @@ export class BansSettingsComponent {
|
||||
return (
|
||||
date.toLocaleDateString() +
|
||||
' ' +
|
||||
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
date.toLocaleTimeString([], { hour: '2-digit',
|
||||
minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,24 +5,21 @@
|
||||
} @else {
|
||||
@for (user of membersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||
<app-user-avatar [name]="user.displayName || '?'" size="sm" />
|
||||
<app-user-avatar
|
||||
[name]="user.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium text-foreground truncate">
|
||||
{{ user.displayName }}
|
||||
</p>
|
||||
@if (user.role === 'host') {
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded"
|
||||
>Owner</span
|
||||
>
|
||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||
} @else if (user.role === 'admin') {
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded"
|
||||
>Admin</span
|
||||
>
|
||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||
} @else if (user.role === 'moderator') {
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded"
|
||||
>Mod</span
|
||||
>
|
||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,14 +39,20 @@
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Kick"
|
||||
>
|
||||
<ng-icon name="lucideUserX" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUserX"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
(click)="banMember(user)"
|
||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="Ban"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideBan"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -58,7 +61,5 @@
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -15,7 +19,12 @@ import { UserAvatarComponent } from '../../../../shared';
|
||||
@Component({
|
||||
selector: 'app-members-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideUserX,
|
||||
@@ -43,7 +52,9 @@ export class MembersSettingsComponent {
|
||||
}
|
||||
|
||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
||||
role }));
|
||||
|
||||
this.webrtcService.broadcastMessage({
|
||||
type: 'role-change',
|
||||
targetUserId: user.id,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
|
||||
</div>
|
||||
<button
|
||||
@@ -11,14 +14,16 @@
|
||||
[disabled]="isTesting()"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 text-xs bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ng-icon name="lucideRefreshCw" class="w-3.5 h-3.5" [class.animate-spin]="isTesting()" />
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="w-3.5 h-3.5"
|
||||
[class.animate-spin]="isTesting()"
|
||||
/>
|
||||
Test All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
Server directories to search for rooms. The active server is used for creating new rooms.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mb-3">Server directories to search for rooms. The active server is used for creating new rooms.</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-2 mb-3">
|
||||
@@ -41,10 +46,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span
|
||||
class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full"
|
||||
>Active</span
|
||||
>
|
||||
<span class="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@@ -105,7 +107,10 @@
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
class="px-3 py-1.5 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
@@ -117,7 +122,10 @@
|
||||
<!-- Connection Settings -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Connection</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -18,7 +22,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideGlobe,
|
||||
@@ -65,6 +73,7 @@ export class NetworkSettingsComponent {
|
||||
name: this.newServerName.trim(),
|
||||
url: this.newServerUrl.trim().replace(/\/$/, '')
|
||||
});
|
||||
|
||||
this.newServerName = '';
|
||||
this.newServerUrl = '';
|
||||
const servers = this.servers();
|
||||
@@ -108,6 +117,7 @@ export class NetworkSettingsComponent {
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@if (server()) {
|
||||
<div class="space-y-4 max-w-xl">
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-1">
|
||||
You are viewing this server's permissions. Only the server owner can make changes.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mb-1">You are viewing this server's permissions. Only the server owner can make changes.</p>
|
||||
}
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
@@ -118,13 +116,14 @@
|
||||
[class.bg-green-600]="saveSuccess() === 'permissions'"
|
||||
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ saveSuccess() === 'permissions' ? 'Saved!' : 'Save Permissions' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -12,7 +17,11 @@ import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@Component({
|
||||
selector: 'app-permissions-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck
|
||||
@@ -75,6 +84,7 @@ export class PermissionsSettingsComponent {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.showSaveSuccess('permissions');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
|
||||
@if (!isAdmin()) {
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
You are viewing this server's settings as a non-admin. Only the server owner can make
|
||||
changes.
|
||||
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
|
||||
</p>
|
||||
}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="room-name" class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
|
||||
<label
|
||||
for="room-name"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Room Name</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
@@ -22,7 +25,11 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="room-description" class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
|
||||
<label
|
||||
for="room-description"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
[(ngModel)]="roomDescription"
|
||||
[readOnly]="!isAdmin()"
|
||||
@@ -49,9 +56,15 @@
|
||||
[class.text-muted-foreground]="!isPrivate()"
|
||||
>
|
||||
@if (isPrivate()) {
|
||||
<ng-icon name="lucideLock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideUnlock" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideUnlock"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +78,10 @@
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label for="room-max-users" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="room-max-users"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Max Users (0 = unlimited)
|
||||
</label>
|
||||
<input
|
||||
@@ -90,7 +106,10 @@
|
||||
[class.bg-green-600]="saveSuccess() === 'server'"
|
||||
[class.hover:bg-green-600]="saveSuccess() === 'server'"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
|
||||
</button>
|
||||
|
||||
@@ -102,7 +121,10 @@
|
||||
type="button"
|
||||
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
@@ -123,7 +145,5 @@
|
||||
</app-confirm-dialog>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">
|
||||
Select a server from the sidebar to manage
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-40 text-muted-foreground text-sm">Select a server from the sidebar to manage</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, input, signal, computed } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
computed
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideCheck, lucideTrash2, lucideLock, lucideUnlock } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideTrash2,
|
||||
lucideLock,
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../core/models';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@@ -14,7 +25,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideCheck,
|
||||
@@ -78,6 +94,7 @@ export class ServerSettingsComponent {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.showSaveSuccess('server');
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,7 @@
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<!-- Global section -->
|
||||
<p
|
||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
||||
>
|
||||
General
|
||||
</p>
|
||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">General</p>
|
||||
@for (page of globalPages; track page.id) {
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
@@ -51,7 +47,10 @@
|
||||
[class.text-foreground]="activePage() !== page.id"
|
||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||
>
|
||||
<ng-icon [name]="page.icon" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
[name]="page.icon"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ page.label }}
|
||||
</button>
|
||||
}
|
||||
@@ -59,11 +58,7 @@
|
||||
<!-- Server section -->
|
||||
@if (savedRooms().length > 0) {
|
||||
<div class="mt-3 pt-3 border-t border-border">
|
||||
<p
|
||||
class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider"
|
||||
>
|
||||
Server
|
||||
</p>
|
||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
||||
|
||||
<!-- Server selector -->
|
||||
<div class="px-3 pb-2">
|
||||
@@ -91,7 +86,10 @@
|
||||
[class.text-foreground]="activePage() !== page.id"
|
||||
[class.hover:bg-secondary]="activePage() !== page.id"
|
||||
>
|
||||
<ng-icon [name]="page.icon" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
[name]="page.icon"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ page.label }}
|
||||
</button>
|
||||
}
|
||||
@@ -104,9 +102,7 @@
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0"
|
||||
>
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('network') {
|
||||
@@ -134,7 +130,10 @@
|
||||
type="button"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,9 +24,7 @@ import {
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectCurrentUser
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room } from '../../../core/models';
|
||||
|
||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||
@@ -80,14 +78,25 @@ export class SettingsModalComponent {
|
||||
|
||||
// --- Side-nav items ---
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'network', label: 'Network', icon: 'lucideGlobe' },
|
||||
{ id: 'voice', label: 'Voice & Audio', icon: 'lucideAudioLines' }
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
icon: 'lucideGlobe' }, { id: 'voice',
|
||||
label: 'Voice & Audio',
|
||||
icon: 'lucideAudioLines' }
|
||||
];
|
||||
readonly serverPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'server', label: 'Server', icon: 'lucideSettings' },
|
||||
{ id: 'members', label: 'Members', icon: 'lucideUsers' },
|
||||
{ id: 'bans', label: 'Bans', icon: 'lucideBan' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: 'lucideShield' }
|
||||
{ id: 'server',
|
||||
label: 'Server',
|
||||
icon: 'lucideSettings' },
|
||||
{ id: 'members',
|
||||
label: 'Members',
|
||||
icon: 'lucideUsers' },
|
||||
{ id: 'bans',
|
||||
label: 'Bans',
|
||||
icon: 'lucideBan' },
|
||||
{ id: 'permissions',
|
||||
label: 'Permissions',
|
||||
icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
// ===== SERVER SELECTOR =====
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
<!-- Devices -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon name="lucideMic" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Devices</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="input-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
|
||||
<label
|
||||
for="input-device-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Microphone</label
|
||||
>
|
||||
<select
|
||||
(change)="onInputDeviceChange($event)"
|
||||
id="input-device-select"
|
||||
@@ -24,7 +31,11 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="output-device-select" class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
|
||||
<label
|
||||
for="output-device-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Speaker</label
|
||||
>
|
||||
<select
|
||||
(change)="onOutputDeviceChange($event)"
|
||||
id="output-device-select"
|
||||
@@ -46,12 +57,18 @@
|
||||
<!-- Volume -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Volume</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="input-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="input-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Input Volume: {{ inputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -65,7 +82,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="output-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="output-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Output Volume: {{ outputVolume() }}%
|
||||
</label>
|
||||
<input
|
||||
@@ -79,7 +99,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="notification-volume-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="notification-volume-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Notification Volume: {{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -102,9 +125,7 @@
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">
|
||||
Controls join, leave & notification sounds
|
||||
</p>
|
||||
<p class="text-[10px] text-muted-foreground/60 mt-1">Controls join, leave & notification sounds</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -112,24 +133,49 @@
|
||||
<!-- Quality & Processing -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideAudioLines"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Quality & Processing</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label for="latency-profile-select" class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
|
||||
<label
|
||||
for="latency-profile-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>Latency Profile</label
|
||||
>
|
||||
<select
|
||||
(change)="onLatencyProfileChange($event)"
|
||||
id="latency-profile-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="low" [selected]="latencyProfile() === 'low'">Low (fast)</option>
|
||||
<option value="balanced" [selected]="latencyProfile() === 'balanced'">Balanced</option>
|
||||
<option value="high" [selected]="latencyProfile() === 'high'">High (quality)</option>
|
||||
<option
|
||||
value="low"
|
||||
[selected]="latencyProfile() === 'low'"
|
||||
>
|
||||
Low (fast)
|
||||
</option>
|
||||
<option
|
||||
value="balanced"
|
||||
[selected]="latencyProfile() === 'balanced'"
|
||||
>
|
||||
Balanced
|
||||
</option>
|
||||
<option
|
||||
value="high"
|
||||
[selected]="latencyProfile() === 'high'"
|
||||
>
|
||||
High (quality)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="audio-bitrate-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="audio-bitrate-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Audio Bitrate: {{ audioBitrate() }} kbps
|
||||
</label>
|
||||
<input
|
||||
@@ -187,7 +233,10 @@
|
||||
<!-- Voice Leveling -->
|
||||
<section>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ng-icon name="lucideActivity" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideActivity"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h4 class="text-sm font-semibold text-foreground">Voice Leveling</h4>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -212,12 +261,15 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Advanced controls — visible only when enabled -->
|
||||
<!-- Advanced controls - visible only when enabled -->
|
||||
@if (voiceLeveling.enabled()) {
|
||||
<div class="space-y-3 pl-1 border-l-2 border-primary/20 ml-1">
|
||||
<!-- Target Loudness -->
|
||||
<div class="pl-3">
|
||||
<label for="target-loudness-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="target-loudness-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Target Loudness: {{ voiceLeveling.targetDbfs() }} dBFS
|
||||
</label>
|
||||
<input
|
||||
@@ -238,19 +290,32 @@
|
||||
|
||||
<!-- AGC Strength -->
|
||||
<div class="pl-3">
|
||||
<label for="agc-strength-select" class="block text-xs font-medium text-muted-foreground mb-1">AGC Strength</label>
|
||||
<label
|
||||
for="agc-strength-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>AGC Strength</label
|
||||
>
|
||||
<select
|
||||
(change)="onStrengthChange($event)"
|
||||
id="agc-strength-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="low" [selected]="voiceLeveling.strength() === 'low'">
|
||||
<option
|
||||
value="low"
|
||||
[selected]="voiceLeveling.strength() === 'low'"
|
||||
>
|
||||
Low (gentle)
|
||||
</option>
|
||||
<option value="medium" [selected]="voiceLeveling.strength() === 'medium'">
|
||||
<option
|
||||
value="medium"
|
||||
[selected]="voiceLeveling.strength() === 'medium'"
|
||||
>
|
||||
Medium
|
||||
</option>
|
||||
<option value="high" [selected]="voiceLeveling.strength() === 'high'">
|
||||
<option
|
||||
value="high"
|
||||
[selected]="voiceLeveling.strength() === 'high'"
|
||||
>
|
||||
High (aggressive)
|
||||
</option>
|
||||
</select>
|
||||
@@ -258,7 +323,10 @@
|
||||
|
||||
<!-- Max Gain Boost -->
|
||||
<div class="pl-3">
|
||||
<label for="max-gain-slider" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="max-gain-slider"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Max Gain Boost: {{ voiceLeveling.maxGainDb() }} dB
|
||||
</label>
|
||||
<input
|
||||
@@ -279,7 +347,10 @@
|
||||
|
||||
<!-- Response Speed -->
|
||||
<div class="pl-3">
|
||||
<label for="response-speed-select" class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
<label
|
||||
for="response-speed-select"
|
||||
class="block text-xs font-medium text-muted-foreground mb-1"
|
||||
>
|
||||
Response Speed
|
||||
</label>
|
||||
<select
|
||||
@@ -287,11 +358,22 @@
|
||||
id="response-speed-select"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="slow" [selected]="voiceLeveling.speed() === 'slow'">
|
||||
<option
|
||||
value="slow"
|
||||
[selected]="voiceLeveling.speed() === 'slow'"
|
||||
>
|
||||
Slow (natural)
|
||||
</option>
|
||||
<option value="medium" [selected]="voiceLeveling.speed() === 'medium'">Medium</option>
|
||||
<option value="fast" [selected]="voiceLeveling.speed() === 'fast'">
|
||||
<option
|
||||
value="medium"
|
||||
[selected]="voiceLeveling.speed() === 'medium'"
|
||||
>
|
||||
Medium
|
||||
</option>
|
||||
<option
|
||||
value="fast"
|
||||
[selected]="voiceLeveling.speed() === 'fast'"
|
||||
>
|
||||
Fast (aggressive)
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMic, lucideHeadphones, lucideAudioLines, lucideActivity } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideMic,
|
||||
lucideHeadphones,
|
||||
lucideAudioLines,
|
||||
lucideActivity
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { VoiceLevelingService } from '../../../../core/services/voice-leveling.service';
|
||||
import {
|
||||
NotificationAudioService,
|
||||
AppSound
|
||||
} from '../../../../core/services/notification-audio.service';
|
||||
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -21,7 +27,11 @@ interface AudioDevice {
|
||||
@Component({
|
||||
selector: 'app-voice-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
@@ -63,12 +73,15 @@ export class VoiceSettingsComponent {
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Go back"
|
||||
>
|
||||
<ng-icon name="lucideArrowLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-6 h-6 text-primary"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +21,10 @@
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<ng-icon name="lucideGlobe" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -23,14 +32,18 @@
|
||||
[disabled]="isTesting()"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-sm bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ng-icon name="lucideRefreshCw" class="w-4 h-4" [class.animate-spin]="isTesting()" />
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="w-4 h-4"
|
||||
[class.animate-spin]="isTesting()"
|
||||
/>
|
||||
Test All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Add multiple server directories to search for rooms across different networks. The active
|
||||
server will be used for creating and registering new rooms.
|
||||
Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new
|
||||
rooms.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
@@ -58,9 +71,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground truncate">{{ server.name }}</span>
|
||||
@if (server.isActive) {
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full"
|
||||
>Active</span
|
||||
>
|
||||
<span class="text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">Active</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@@ -123,7 +134,10 @@
|
||||
[disabled]="!newServerName || !newServerUrl"
|
||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed self-end"
|
||||
>
|
||||
<ng-icon name="lucidePlus" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
@@ -135,7 +149,10 @@
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideServer"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||
</div>
|
||||
|
||||
@@ -143,9 +160,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Auto-reconnect</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Automatically reconnect when connection is lost
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Automatically reconnect when connection is lost</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -163,9 +178,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Search all servers</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Search across all configured server directories
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Search across all configured server directories</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
@@ -185,7 +198,10 @@
|
||||
<!-- Voice Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideAudioLines" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideAudioLines"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<h2 class="text-lg font-semibold text-foreground">Voice Settings</h2>
|
||||
</div>
|
||||
|
||||
@@ -195,9 +211,7 @@
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Notification volume</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Volume for join, leave, and notification sounds
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Volume for join, leave, and notification sounds</p>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-muted-foreground tabular-nums w-10 text-right">
|
||||
{{ audioService.notificationVolume() * 100 | number: '1.0-0' }}%
|
||||
@@ -226,9 +240,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Noise reduction</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Use RNNoise to suppress background noise from your microphone
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">Use RNNoise to suppress background noise from your microphone</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -25,7 +30,11 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideServer,
|
||||
@@ -142,6 +151,7 @@ export class SettingsComponent implements OnInit {
|
||||
searchAllServers: this.searchAllServers
|
||||
})
|
||||
);
|
||||
|
||||
this.serverDirectory.setSearchAllServers(this.searchAllServers);
|
||||
}
|
||||
|
||||
@@ -190,8 +200,10 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_VOICE_SETTINGS,
|
||||
JSON.stringify({ ...existing, noiseReduction: this.noiseReduction })
|
||||
JSON.stringify({ ...existing,
|
||||
noiseReduction: this.noiseReduction })
|
||||
);
|
||||
|
||||
await this.webrtcService.toggleNoiseReduction(this.noiseReduction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
<div
|
||||
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
||||
style="-webkit-app-region: drag;"
|
||||
style="-webkit-app-region: drag"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 relative"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (inRoom()) {
|
||||
<button type="button" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="onBack()"
|
||||
class="p-2 hover:bg-secondary rounded"
|
||||
title="Back"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideChevronLeft"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
@if (inRoom()) {
|
||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
@if (roomDescription()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
<button type="button" (click)="toggleMenu()" class="ml-2 p-2 hover:bg-secondary rounded" title="Menu">
|
||||
<ng-icon name="lucideMenu" class="w-5 h-5 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="ml-2 p-2 hover:bg-secondary rounded"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
@@ -48,7 +70,10 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (!isAuthed()) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,14 +85,38 @@
|
||||
</button>
|
||||
}
|
||||
@if (isElectron()) {
|
||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Minimize" (click)="minimize()">
|
||||
<ng-icon name="lucideMinus" class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Minimize"
|
||||
(click)="minimize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMinus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-secondary rounded" title="Maximize" (click)="maximize()">
|
||||
<ng-icon name="lucideSquare" class="w-4 h-4" />
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Maximize"
|
||||
(click)="maximize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSquare"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button type="button" class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded" title="Close" (click)="close()">
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-destructive" />
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
|
||||
title="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -82,6 +131,6 @@
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close menu overlay"
|
||||
style="-webkit-app-region: no-drag;"
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Component, inject, computed, signal } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu } from '@ng-icons/lucide';
|
||||
import {
|
||||
lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu
|
||||
} from '@ng-icons/lucide';
|
||||
import { Router } from '@angular/router';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
@@ -17,7 +29,14 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
/**
|
||||
@@ -102,7 +121,7 @@ export class TitleBarComponent {
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server – this broadcasts "user_left" to all
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
|
||||
title="Back to {{ voiceSession()?.serverName }}"
|
||||
>
|
||||
<ng-icon name="lucideArrowLeft" class="w-3.5 h-3.5" />
|
||||
<ng-icon
|
||||
name="lucideArrowLeft"
|
||||
class="w-3.5 h-3.5"
|
||||
/>
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
@@ -40,7 +43,10 @@
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
[name]="isMuted() ? 'lucideMicOff' : 'lucideMic'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -49,7 +55,10 @@
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -58,7 +67,10 @@
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
[name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -67,7 +79,10 @@
|
||||
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
title="Disconnect"
|
||||
>
|
||||
<ng-icon name="lucidePhoneOff" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -163,7 +170,11 @@ export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
if (user?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: user.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
voiceState: { isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<ng-icon name="lucideMonitor" class="w-4 h-4" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
@if (activeScreenSharer()) {
|
||||
<span class="text-sm font-medium">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
|
||||
} @else {
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
}
|
||||
@@ -46,9 +47,15 @@
|
||||
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
@if (isFullscreen()) {
|
||||
<ng-icon name="lucideMinimize" class="w-4 h-4 text-white" />
|
||||
<ng-icon
|
||||
name="lucideMinimize"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
||||
<ng-icon
|
||||
name="lucideMaximize"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
@if (isLocalShare()) {
|
||||
@@ -58,7 +65,10 @@
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop sharing"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
@@ -67,7 +77,10 @@
|
||||
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
|
||||
title="Stop watching"
|
||||
>
|
||||
<ng-icon name="lucideX" class="w-4 h-4 text-white" />
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-white"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@@ -78,7 +91,10 @@
|
||||
@if (!hasStream()) {
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<ng-icon name="lucideMonitor" class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
/>
|
||||
<p>Waiting for screen share...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
import { Component, inject, signal, ElementRef, ViewChild, OnDestroy, effect } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
OnDestroy,
|
||||
effect
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
@@ -235,6 +243,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
.catch(() => {});
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.hasStream.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ export class VoicePlaybackService {
|
||||
audio.srcObject = null;
|
||||
audio.remove();
|
||||
});
|
||||
|
||||
this.remoteAudioElements.clear();
|
||||
this.rawRemoteStreams.clear();
|
||||
this.pendingRemoteStreams.clear();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="bg-card border-border p-4">
|
||||
<!-- Connection Error Banner -->
|
||||
@if (showConnectionError()) {
|
||||
<div
|
||||
class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||
<span class="text-xs text-destructive">{{
|
||||
connectionErrorMessage() || 'Connection error'
|
||||
}}</span>
|
||||
<button type="button" (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
(click)="retryConnection()"
|
||||
class="ml-auto text-xs text-destructive hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<app-user-avatar [name]="currentUser()?.displayName || '?'" size="sm" />
|
||||
<app-user-avatar
|
||||
[name]="currentUser()?.displayName || '?'"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
@@ -31,8 +34,15 @@
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" (click)="toggleSettings()" class="p-2 hover:bg-secondary rounded-lg transition-colors">
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleSettings()"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -40,25 +50,52 @@
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button type="button" (click)="toggleMute()" [class]="getMuteButtonClass()">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMicOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button type="button" (click)="toggleDeafen()" [class]="getDeafenButtonClass()">
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHeadphones"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button type="button" (click)="toggleScreenShare()" [class]="getScreenShareButtonClass()">
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMonitorOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
} @else {
|
||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucideMonitor"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
|
||||
@@ -68,7 +105,10 @@
|
||||
(click)="disconnect()"
|
||||
class="w-10 h-10 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
<ng-icon name="lucidePhoneOff" class="w-5 h-5" />
|
||||
<ng-icon
|
||||
name="lucidePhoneOff"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,11 @@ interface AudioDevice {
|
||||
@Component({
|
||||
selector: 'app-voice-controls',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, UserAvatarComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
@@ -164,12 +168,15 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.inputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
|
||||
this.outputDevices.set(
|
||||
devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({ deviceId: device.deviceId, label: device.label }))
|
||||
.map((device) => ({ deviceId: device.deviceId,
|
||||
label: device.label }))
|
||||
);
|
||||
} catch (_error) {}
|
||||
}
|
||||
@@ -502,7 +509,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
this.webrtcService.setLatencyProfile(this.latencyProfile());
|
||||
this.applyOutputDevice();
|
||||
// Always sync the desired noise-reduction preference (even before
|
||||
// a mic stream exists — the flag will be honoured on connect).
|
||||
// a mic stream exists - the flag will be honoured on connect).
|
||||
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user