Add new settngs modal

This commit is contained in:
2026-03-03 02:55:08 +01:00
parent d684fc5632
commit cf91d77502
24 changed files with 1781 additions and 316 deletions

View File

@@ -5,15 +5,6 @@
<span class="text-muted-foreground text-lg">#</span>
<span class="font-medium text-foreground text-sm">{{ activeChannelName }}</span>
<div class="flex-1"></div>
@if (isAdmin()) {
<button
(click)="toggleAdminPanel()"
class="p-1.5 rounded hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Server Settings"
>
<ng-icon name="lucideSettings" class="w-4 h-4" />
</button>
}
</div>
<!-- Main Content -->
@@ -29,19 +20,6 @@
</div>
</main>
<!-- Admin Panel (slide-over) -->
@if (showAdminPanel() && isAdmin()) {
<aside class="w-80 flex-shrink-0 border-l border-border overflow-y-auto">
<div class="flex items-center justify-between px-4 py-2 border-b border-border bg-card">
<span class="text-sm font-medium text-foreground">Server Settings</span>
<button (click)="toggleAdminPanel()" class="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground">
<ng-icon name="lucideX" class="w-4 h-4" />
</button>
</div>
<app-admin-panel />
</aside>
}
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />

View File

@@ -13,12 +13,15 @@ import {
} from '@ng-icons/lucide';
import { ChatMessagesComponent } from '../../chat/chat-messages/chat-messages.component';
import { UserListComponent } from '../../chat/user-list/user-list.component';
import { ScreenShareViewerComponent } from '../../voice/screen-share-viewer/screen-share-viewer.component';
import { AdminPanelComponent } from '../../admin/admin-panel/admin-panel.component';
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
import { selectCurrentRoom, selectActiveChannelId, selectTextChannels } from '../../../store/rooms/rooms.selectors';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
@@ -32,7 +35,6 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
ChatMessagesComponent,
ScreenShareViewerComponent,
RoomsSidePanelComponent,
AdminPanelComponent,
],
viewProviders: [
provideIcons({
@@ -52,6 +54,7 @@ type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
export class ChatRoomComponent {
private store = inject(Store);
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
showMenu = signal(false);
showAdminPanel = signal(false);
@@ -63,12 +66,15 @@ export class ChatRoomComponent {
/** Returns the display name of the currently active text channel. */
get activeChannelName(): string {
const id = this.activeChannelId();
const activeChannel = this.textChannels().find(channel => channel.id === id);
const activeChannel = this.textChannels().find((channel) => channel.id === id);
return activeChannel ? activeChannel.name : id;
}
/** Toggle the admin panel sidebar visibility. */
/** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() {
this.showAdminPanel.update((current) => !current);
const room = this.currentRoom();
if (room) {
this.settingsModal.open('server', room.id);
}
}
}

View File

@@ -23,13 +23,21 @@ import {
} from '../../store/rooms/rooms.selectors';
import { Room } from '../../core/models';
import { ServerInfo } from '../../core/models';
import { SettingsModalService } from '../../core/services/settings-modal.service';
@Component({
selector: 'app-server-search',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
provideIcons({
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
}),
],
templateUrl: './server-search.component.html',
})
@@ -40,6 +48,7 @@ import { ServerInfo } from '../../core/models';
export class ServerSearchComponent implements OnInit {
private store = inject(Store);
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private searchSubject = new Subject<string>();
searchQuery = '';
@@ -63,11 +72,9 @@ export class ServerSearchComponent implements OnInit {
this.store.dispatch(RoomsActions.loadRooms());
// Setup debounced search
this.searchSubject
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
this.store.dispatch(RoomsActions.searchServers({ query }));
});
}
/** Emit a search query to the debounced search subject. */
@@ -82,14 +89,16 @@ export class ServerSearchComponent implements OnInit {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.hostName,
}
}));
this.store.dispatch(
RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.hostName,
},
}),
);
}
/** Open the create-server dialog. */
@@ -119,15 +128,15 @@ export class ServerSearchComponent implements OnInit {
topic: this.newServerTopic() || undefined,
isPrivate: this.newServerPrivate(),
password: this.newServerPrivate() ? this.newServerPassword() : undefined,
})
}),
);
this.closeCreateDialog();
}
/** Navigate to the application settings page. */
/** Open the unified settings modal to the Network page. */
openSettings(): void {
this.router.navigate(['/settings']);
this.settingsModal.open('network');
}
/** Join a previously saved room by converting it to a ServerInfo payload. */

View File

@@ -0,0 +1,44 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (bannedUsers().length === 0) {
<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">
<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">
<p class="text-sm font-medium text-foreground truncate">
{{ ban.displayName || 'Unknown User' }}
</p>
@if (ban.reason) {
<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>
} @else {
<p class="text-xs text-destructive">Permanent</p>
}
</div>
@if (isAdmin()) {
<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" />
</button>
}
</div>
}
}
</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>
}

View File

@@ -0,0 +1,44 @@
import { Component, inject, input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectBannedUsers } from '../../../../store/users/users.selectors';
@Component({
selector: 'app-bans-settings',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideX,
}),
],
templateUrl: './bans-settings.component.html',
})
export class BansSettingsComponent {
private store = inject(Store);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
bannedUsers = this.store.selectSignal(selectBannedUsers);
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
}
formatExpiry(timestamp: number): string {
const date = new Date(timestamp);
return (
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
}
}

View File

@@ -0,0 +1,64 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (membersFiltered().length === 0) {
<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" />
<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
>
} @else if (user.role === 'admin') {
<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
>
}
</div>
</div>
@if (user.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
(click)="kickMember(user)"
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" />
</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" />
</button>
</div>
}
</div>
}
}
</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>
}

View File

@@ -0,0 +1,69 @@
import { Component, inject, input } 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 { lucideUserX, lucideBan } from '@ng-icons/lucide';
import { Room, User } from '../../../../core/models';
import { UsersActions } from '../../../../store/users/users.actions';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { UserAvatarComponent } from '../../../../shared';
@Component({
selector: 'app-members-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, UserAvatarComponent],
viewProviders: [
provideIcons({
lucideUserX,
lucideBan,
}),
],
templateUrl: './members-settings.component.html',
})
export class MembersSettingsComponent {
private store = inject(Store);
private webrtcService = inject(WebRTCService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
membersFiltered(): User[] {
const me = this.currentUser();
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
this.webrtcService.broadcastMessage({
type: 'role-change',
targetUserId: user.id,
role,
});
}
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id,
});
}
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'ban',
targetUserId: user.id,
bannedBy: this.currentUser()?.id,
});
}
}

View File

@@ -0,0 +1,160 @@
<div class="space-y-6 max-w-xl">
<!-- Server Endpoints -->
<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" />
<h4 class="text-sm font-semibold text-foreground">Server Endpoints</h4>
</div>
<button
(click)="testAllServers()"
[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()" />
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>
<!-- Server List -->
<div class="space-y-2 mb-3">
@for (server of servers(); track server.id) {
<div
class="flex items-center gap-3 p-2.5 rounded-lg border transition-colors"
[class.border-primary]="server.isActive"
[class.bg-primary/5]="server.isActive"
[class.border-border]="!server.isActive"
[class.bg-secondary/30]="!server.isActive"
>
<div
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
[class.bg-green-500]="server.status === 'online'"
[class.bg-red-500]="server.status === 'offline'"
[class.bg-yellow-500]="server.status === 'checking'"
[class.bg-muted]="server.status === 'unknown'"
></div>
<div class="flex-1 min-w-0">
<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
>
}
</div>
<p class="text-xs text-muted-foreground truncate">{{ server.url }}</p>
@if (server.latency !== undefined && server.status === 'online') {
<p class="text-[10px] text-muted-foreground">{{ server.latency }}ms</p>
}
</div>
<div class="flex items-center gap-1 flex-shrink-0">
@if (!server.isActive) {
<button
(click)="setActiveServer(server.id)"
class="p-1.5 hover:bg-secondary rounded-lg transition-colors"
title="Set as active"
>
<ng-icon
name="lucideCheck"
class="w-3.5 h-3.5 text-muted-foreground hover:text-primary"
/>
</button>
}
@if (!server.isDefault) {
<button
(click)="removeServer(server.id)"
class="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
title="Remove"
>
<ng-icon
name="lucideTrash2"
class="w-3.5 h-3.5 text-muted-foreground hover:text-destructive"
/>
</button>
}
</div>
</div>
}
</div>
<!-- Add New Server -->
<div class="border-t border-border pt-3">
<h4 class="text-xs font-medium text-foreground mb-2">Add New Server</h4>
<div class="flex gap-2">
<div class="flex-1 space-y-1.5">
<input
type="text"
[(ngModel)]="newServerName"
placeholder="Server name"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<input
type="url"
[(ngModel)]="newServerUrl"
placeholder="Server URL (e.g., http://localhost:3001)"
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<button
(click)="addServer()"
[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" />
</button>
</div>
@if (addError()) {
<p class="text-xs text-destructive mt-1.5">{{ addError() }}</p>
}
</div>
</section>
<!-- Connection Settings -->
<section>
<div class="flex items-center gap-2 mb-3">
<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">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Auto-reconnect</p>
<p class="text-xs text-muted-foreground">Reconnect when connection is lost</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="autoReconnect"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Search all servers</p>
<p class="text-xs text-muted-foreground">Search across all server directories</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[(ngModel)]="searchAllServers"
(change)="saveConnectionSettings()"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,105 @@
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 {
lucideGlobe,
lucideServer,
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck,
} from '@ng-icons/lucide';
import { ServerDirectoryService } from '../../../../core/services/server-directory.service';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
@Component({
selector: 'app-network-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideGlobe,
lucideServer,
lucideRefreshCw,
lucidePlus,
lucideTrash2,
lucideCheck,
}),
],
templateUrl: './network-settings.component.html',
})
export class NetworkSettingsComponent {
private serverDirectory = inject(ServerDirectoryService);
servers = this.serverDirectory.servers;
isTesting = signal(false);
addError = signal<string | null>(null);
newServerName = '';
newServerUrl = '';
autoReconnect = true;
searchAllServers = true;
constructor() {
this.loadConnectionSettings();
}
addServer(): void {
this.addError.set(null);
try {
new URL(this.newServerUrl);
} catch {
this.addError.set('Please enter a valid URL');
return;
}
if (this.servers().some((s) => s.url === this.newServerUrl)) {
this.addError.set('This server URL already exists');
return;
}
this.serverDirectory.addServer({
name: this.newServerName.trim(),
url: this.newServerUrl.trim().replace(/\/$/, ''),
});
this.newServerName = '';
this.newServerUrl = '';
const servers = this.servers();
const newServer = servers[servers.length - 1];
if (newServer) this.serverDirectory.testServer(newServer.id);
}
removeServer(id: string): void {
this.serverDirectory.removeServer(id);
}
setActiveServer(id: string): void {
this.serverDirectory.setActiveServer(id);
}
async testAllServers(): Promise<void> {
this.isTesting.set(true);
await this.serverDirectory.testAllServers();
this.isTesting.set(false);
}
loadConnectionSettings(): void {
const raw = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (raw) {
const parsed = JSON.parse(raw);
this.autoReconnect = parsed.autoReconnect ?? true;
this.searchAllServers = parsed.searchAllServers ?? true;
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}
saveConnectionSettings(): void {
localStorage.setItem(
STORAGE_KEY_CONNECTION_SETTINGS,
JSON.stringify({
autoReconnect: this.autoReconnect,
searchAllServers: this.searchAllServers,
}),
);
this.serverDirectory.setSearchAllServers(this.searchAllServers);
}
}

View File

@@ -0,0 +1,130 @@
@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>
}
<div class="space-y-2.5">
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Voice Chat</p>
<p class="text-xs text-muted-foreground">Users can join voice channels</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowVoice"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow Screen Share</p>
<p class="text-xs text-muted-foreground">Users can share their screen</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowScreenShare"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Allow File Uploads</p>
<p class="text-xs text-muted-foreground">Users can upload files</p>
</div>
<input
type="checkbox"
[(ngModel)]="allowFileUploads"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Slow Mode</p>
<p class="text-xs text-muted-foreground">Limit message frequency</p>
</div>
<select
[(ngModel)]="slowModeInterval"
[disabled]="!isAdmin()"
class="px-3 py-1 bg-secondary rounded border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">Off</option>
<option value="5">5 seconds</option>
<option value="10">10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
</select>
</div>
<!-- Management permissions -->
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow admins to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Manage Rooms</p>
<p class="text-xs text-muted-foreground">Allow moderators to create/modify rooms</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageRooms"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Admins Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to admins</p>
</div>
<input
type="checkbox"
[(ngModel)]="adminsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
<div class="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div>
<p class="text-sm font-medium text-foreground">Moderators Can Change Server Icon</p>
<p class="text-xs text-muted-foreground">Grant icon management to moderators</p>
</div>
<input
type="checkbox"
[(ngModel)]="moderatorsManageIcon"
[disabled]="!isAdmin()"
class="w-4 h-4 accent-primary"
/>
</div>
</div>
@if (isAdmin()) {
<button
(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 text-sm"
[class.bg-green-600]="saveSuccess() === 'permissions'"
[class.hover:bg-green-600]="saveSuccess() === 'permissions'"
>
<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>
}

View File

@@ -0,0 +1,81 @@
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';
import { Store } from '@ngrx/store';
import { lucideCheck } from '@ng-icons/lucide';
import { Room } from '../../../../core/models';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({
selector: 'app-permissions-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideCheck,
}),
],
templateUrl: './permissions-settings.component.html',
})
export class PermissionsSettingsComponent {
private store = inject(Store);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
allowVoice = true;
allowScreenShare = true;
allowFileUploads = true;
slowModeInterval = '0';
adminsManageRooms = false;
moderatorsManageRooms = false;
adminsManageIcon = false;
moderatorsManageIcon = false;
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Load permissions from the server input. Called by parent via effect or on init. */
loadPermissions(room: Room): void {
const perms = room.permissions || {};
this.allowVoice = perms.allowVoice !== false;
this.allowScreenShare = perms.allowScreenShare !== false;
this.allowFileUploads = perms.allowFileUploads !== false;
this.slowModeInterval = String(perms.slowModeInterval ?? 0);
this.adminsManageRooms = !!perms.adminsManageRooms;
this.moderatorsManageRooms = !!perms.moderatorsManageRooms;
this.adminsManageIcon = !!perms.adminsManageIcon;
this.moderatorsManageIcon = !!perms.moderatorsManageIcon;
}
savePermissions(): void {
const room = this.server();
if (!room) return;
this.store.dispatch(
RoomsActions.updateRoomPermissions({
roomId: room.id,
permissions: {
allowVoice: this.allowVoice,
allowScreenShare: this.allowScreenShare,
allowFileUploads: this.allowFileUploads,
slowModeInterval: parseInt(this.slowModeInterval, 10),
adminsManageRooms: this.adminsManageRooms,
moderatorsManageRooms: this.moderatorsManageRooms,
adminsManageIcon: this.adminsManageIcon,
moderatorsManageIcon: this.moderatorsManageIcon,
},
}),
);
this.showSaveSuccess('permissions');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);
if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
}
}

View File

@@ -0,0 +1,123 @@
@if (serverData()) {
<div class="space-y-5 max-w-xl">
<section>
<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.
</p>
}
<div class="space-y-4">
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Room Name</label>
<input
type="text"
[(ngModel)]="roomName"
[readOnly]="!isAdmin()"
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"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
/>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Description</label>
<textarea
[(ngModel)]="roomDescription"
[readOnly]="!isAdmin()"
rows="3"
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 resize-none"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
></textarea>
</div>
@if (isAdmin()) {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<button
(click)="togglePrivate()"
class="p-2 rounded-lg transition-colors"
[class.bg-primary]="isPrivate()"
[class.text-primary-foreground]="isPrivate()"
[class.bg-secondary]="!isPrivate()"
[class.text-muted-foreground]="!isPrivate()"
>
@if (isPrivate()) {
<ng-icon name="lucideLock" class="w-4 h-4" />
} @else {
<ng-icon name="lucideUnlock" class="w-4 h-4" />
}
</button>
</div>
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Private Room</p>
<p class="text-xs text-muted-foreground">Require approval to join</p>
</div>
<span class="text-sm text-muted-foreground">{{ isPrivate() ? 'Yes' : 'No' }}</span>
</div>
}
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1"
>Max Users (0 = unlimited)</label
>
<input
type="number"
[(ngModel)]="maxUsers"
[readOnly]="!isAdmin()"
min="0"
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"
[class.opacity-60]="!isAdmin()"
[class.cursor-not-allowed]="!isAdmin()"
/>
</div>
</div>
</section>
@if (isAdmin()) {
<button
(click)="saveServerSettings()"
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 text-sm"
[class.bg-green-600]="saveSuccess() === 'server'"
[class.hover:bg-green-600]="saveSuccess() === 'server'"
>
<ng-icon name="lucideCheck" class="w-4 h-4" />
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(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 text-sm"
>
<ng-icon name="lucideTrash2" class="w-4 h-4" />
Delete Room
</button>
</div>
}
</div>
<!-- Delete Confirmation (sub-modal) -->
@if (showDeleteConfirm()) {
<app-confirm-dialog
title="Delete Room"
confirmLabel="Delete Room"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="deleteRoom()"
(cancelled)="showDeleteConfirm.set(false)"
>
<p>Are you sure you want to delete this room? This action cannot be undone.</p>
</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>
}

View File

@@ -0,0 +1,95 @@
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 { Room } from '../../../../core/models';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
@Component({
selector: 'app-server-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent],
viewProviders: [
provideIcons({
lucideCheck,
lucideTrash2,
lucideLock,
lucideUnlock,
}),
],
templateUrl: './server-settings.component.html',
})
export class ServerSettingsComponent {
private store = inject(Store);
private modal = inject(SettingsModalService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
roomName = '';
roomDescription = '';
isPrivate = signal(false);
maxUsers = 0;
showDeleteConfirm = signal(false);
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
/** Reload form fields whenever the server input changes. */
serverData = computed(() => {
const room = this.server();
if (room) {
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.maxUsers = room.maxUsers || 0;
}
return room;
});
togglePrivate(): void {
this.isPrivate.update((v) => !v);
}
saveServerSettings(): void {
const room = this.server();
if (!room) return;
this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id,
changes: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers,
},
}),
);
this.showSaveSuccess('server');
}
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}
deleteRoom(): void {
const room = this.server();
if (!room) return;
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
this.showDeleteConfirm.set(false);
this.modal.navigate('network');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);
if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => this.saveSuccess.set(null), 2000);
}
}

View File

@@ -0,0 +1,167 @@
@if (isOpen()) {
<!-- Backdrop -->
<div
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
[class.opacity-100]="animating()"
[class.opacity-0]="!animating()"
(click)="onBackdropClick()"
></div>
<!-- Modal -->
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
<div
class="pointer-events-auto bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
[class.scale-100]="animating()"
[class.opacity-100]="animating()"
[class.scale-95]="!animating()"
[class.opacity-0]="!animating()"
(click)="$event.stopPropagation()"
>
<!-- Side Navigation -->
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
<div class="p-4 border-b border-border">
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
</div>
<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>
@for (page of globalPages; track page.id) {
<button
(click)="navigate(page.id)"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id"
[class.font-medium]="activePage() === page.id"
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon [name]="page.icon" class="w-4 h-4" />
{{ page.label }}
</button>
}
<!-- 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>
<!-- Server selector -->
<div class="px-3 pb-2">
<select
class="w-full px-2 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-xs focus:outline-none focus:ring-1 focus:ring-primary"
[value]="selectedServerId() || ''"
(change)="onServerSelect($event)"
>
<option value="">Select a server…</option>
@for (room of savedRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select>
</div>
@if (selectedServerId() && isSelectedServerAdmin()) {
@for (page of serverPages; track page.id) {
<button
(click)="navigate(page.id)"
class="w-full flex items-center gap-2.5 px-4 py-2 text-sm transition-colors"
[class.bg-primary/10]="activePage() === page.id"
[class.text-primary]="activePage() === page.id"
[class.font-medium]="activePage() === page.id"
[class.text-foreground]="activePage() !== page.id"
[class.hover:bg-secondary]="activePage() !== page.id"
>
<ng-icon [name]="page.icon" class="w-4 h-4" />
{{ page.label }}
</button>
}
}
</div>
}
</div>
</nav>
<!-- 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"
>
<h3 class="text-lg font-semibold text-foreground">
@switch (activePage()) {
@case ('network') {
Network
}
@case ('voice') {
Voice & Audio
}
@case ('server') {
Server Settings
}
@case ('members') {
Members
}
@case ('bans') {
Bans
}
@case ('permissions') {
Permissions
}
}
</h3>
<button
(click)="close()"
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" />
</button>
</div>
<!-- Scrollable Content Area -->
<div class="flex-1 overflow-y-auto p-6">
@switch (activePage()) {
@case ('network') {
<app-network-settings />
}
@case ('voice') {
<app-voice-settings />
}
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
/>
}
@case ('members') {
<app-members-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
/>
}
@case ('bans') {
<app-bans-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
/>
}
@case ('permissions') {
<app-permissions-settings
#permissionsComp
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
/>
}
}
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,173 @@
import {
Component,
inject,
signal,
computed,
OnInit,
OnDestroy,
effect,
HostListener,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideX,
lucideGlobe,
lucideAudioLines,
lucideSettings,
lucideUsers,
lucideBan,
lucideShield,
} from '@ng-icons/lucide';
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 { Room } from '../../../core/models';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
import { ServerSettingsComponent } from './server-settings/server-settings.component';
import { MembersSettingsComponent } from './members-settings/members-settings.component';
import { BansSettingsComponent } from './bans-settings/bans-settings.component';
import { PermissionsSettingsComponent } from './permissions-settings/permissions-settings.component';
@Component({
selector: 'app-settings-modal',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NetworkSettingsComponent,
VoiceSettingsComponent,
ServerSettingsComponent,
MembersSettingsComponent,
BansSettingsComponent,
PermissionsSettingsComponent,
],
viewProviders: [
provideIcons({
lucideX,
lucideGlobe,
lucideAudioLines,
lucideSettings,
lucideUsers,
lucideBan,
lucideShield,
}),
],
templateUrl: './settings-modal.component.html',
})
export class SettingsModalComponent implements OnInit, OnDestroy {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
// --- Selectors ---
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser);
// --- Modal state ---
isOpen = this.modal.isOpen;
activePage = this.modal.activePage;
// --- Side-nav items ---
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ 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' },
];
// ===== SERVER SELECTOR =====
selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId();
if (!id) return null;
return this.savedRooms().find((r) => r.id === id) ?? null;
});
/** Whether the user can see server-admin tabs. */
showServerTabs = computed(() => {
return this.savedRooms().length > 0 && !!this.selectedServerId();
});
/** Whether the current user is the host/owner of the selected server. */
isSelectedServerAdmin = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
if (!server || !user) return false;
return server.hostId === user.id || server.hostId === user.oderId;
});
// Animation
animating = signal(false);
constructor() {
// Sync selected server when modal opens with a target
effect(() => {
if (this.isOpen()) {
const targetId = this.modal.targetServerId();
if (targetId) {
this.selectedServerId.set(targetId);
} else if (this.currentRoom()) {
this.selectedServerId.set(this.currentRoom()!.id);
}
this.animating.set(true);
}
});
// When selected server changes, reload permissions data
effect(() => {
const server = this.selectedServer();
if (server) {
const permsComp = this.permissionsComponent();
if (permsComp) {
permsComp.loadPermissions(server);
}
}
});
}
ngOnInit(): void {}
ngOnDestroy(): void {}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.isOpen()) {
this.close();
}
}
// ===== MODAL CONTROLS =====
close(): void {
this.animating.set(false);
setTimeout(() => this.modal.close(), 200);
}
navigate(page: SettingsPage): void {
this.modal.navigate(page);
}
onBackdropClick(): void {
this.close();
}
onServerSelect(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedServerId.set(select.value || null);
}
}

View File

@@ -0,0 +1,148 @@
<div class="space-y-6 max-w-xl">
<!-- Devices -->
<section>
<div class="flex items-center gap-2 mb-3">
<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 class="block text-xs font-medium text-muted-foreground mb-1">Microphone</label>
<select
(change)="onInputDeviceChange($event)"
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"
>
@for (device of inputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
</option>
}
</select>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1">Speaker</label>
<select
(change)="onOutputDeviceChange($event)"
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"
>
@for (device of outputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
</option>
}
</select>
</div>
</div>
</section>
<!-- Volume -->
<section>
<div class="flex items-center gap-2 mb-3">
<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 class="block text-xs font-medium text-muted-foreground mb-1">
Input Volume: {{ inputVolume() }}%
</label>
<input
type="range"
[value]="inputVolume()"
(input)="onInputVolumeChange($event)"
min="0"
max="100"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1">
Output Volume: {{ outputVolume() }}%
</label>
<input
type="range"
[value]="outputVolume()"
(input)="onOutputVolumeChange($event)"
min="0"
max="100"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
</section>
<!-- Quality & Processing -->
<section>
<div class="flex items-center gap-2 mb-3">
<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 class="block text-xs font-medium text-muted-foreground mb-1">Latency Profile</label>
<select
(change)="onLatencyProfileChange($event)"
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>
</select>
</div>
<div>
<label class="block text-xs font-medium text-muted-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input
type="range"
[value]="audioBitrate()"
(input)="onAudioBitrateChange($event)"
min="32"
max="256"
step="8"
class="w-full h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Noise reduction</p>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="noiseReduction()"
(change)="onNoiseReductionChange()"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Screen share system audio</p>
<p class="text-xs text-muted-foreground">Include system audio when sharing screen</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -0,0 +1,154 @@
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 } from '@ng-icons/lucide';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../../core/constants';
interface AudioDevice {
deviceId: string;
label: string;
}
@Component({
selector: 'app-voice-settings',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon],
viewProviders: [
provideIcons({
lucideMic,
lucideHeadphones,
lucideAudioLines,
}),
],
templateUrl: './voice-settings.component.html',
})
export class VoiceSettingsComponent {
private webrtcService = inject(WebRTCService);
inputDevices = signal<AudioDevice[]>([]);
outputDevices = signal<AudioDevice[]>([]);
selectedInputDevice = signal<string>('');
selectedOutputDevice = signal<string>('');
inputVolume = signal(100);
outputVolume = signal(100);
audioBitrate = signal(96);
latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false);
noiseReduction = signal(false);
constructor() {
this.loadVoiceSettings();
this.loadAudioDevices();
}
async loadAudioDevices(): Promise<void> {
try {
if (!navigator.mediaDevices?.enumerateDevices) return;
const devices = await navigator.mediaDevices.enumerateDevices();
this.inputDevices.set(
devices
.filter((d) => d.kind === 'audioinput')
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
);
this.outputDevices.set(
devices
.filter((d) => d.kind === 'audiooutput')
.map((d) => ({ deviceId: d.deviceId, label: d.label })),
);
} catch {}
}
loadVoiceSettings(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_VOICE_SETTINGS);
if (!raw) return;
const s = JSON.parse(raw);
if (s.inputDevice) this.selectedInputDevice.set(s.inputDevice);
if (s.outputDevice) this.selectedOutputDevice.set(s.outputDevice);
if (typeof s.inputVolume === 'number') this.inputVolume.set(s.inputVolume);
if (typeof s.outputVolume === 'number') this.outputVolume.set(s.outputVolume);
if (typeof s.audioBitrate === 'number') this.audioBitrate.set(s.audioBitrate);
if (s.latencyProfile) this.latencyProfile.set(s.latencyProfile);
if (typeof s.includeSystemAudio === 'boolean')
this.includeSystemAudio.set(s.includeSystemAudio);
if (typeof s.noiseReduction === 'boolean') this.noiseReduction.set(s.noiseReduction);
} catch {}
if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) {
this.webrtcService.toggleNoiseReduction(this.noiseReduction());
}
}
saveVoiceSettings(): void {
try {
localStorage.setItem(
STORAGE_KEY_VOICE_SETTINGS,
JSON.stringify({
inputDevice: this.selectedInputDevice(),
outputDevice: this.selectedOutputDevice(),
inputVolume: this.inputVolume(),
outputVolume: this.outputVolume(),
audioBitrate: this.audioBitrate(),
latencyProfile: this.latencyProfile(),
includeSystemAudio: this.includeSystemAudio(),
noiseReduction: this.noiseReduction(),
}),
);
} catch {}
}
onInputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedInputDevice.set(select.value);
this.saveVoiceSettings();
}
onOutputDeviceChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedOutputDevice.set(select.value);
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
}
onInputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.inputVolume.set(parseInt(input.value, 10));
this.saveVoiceSettings();
}
onOutputVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.outputVolume.set(parseInt(input.value, 10));
this.webrtcService.setOutputVolume(this.outputVolume() / 100);
this.saveVoiceSettings();
}
onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement;
const profile = select.value as 'low' | 'balanced' | 'high';
this.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile);
this.saveVoiceSettings();
}
onAudioBitrateChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.audioBitrate.set(parseInt(input.value, 10));
this.webrtcService.setAudioBitrate(this.audioBitrate());
this.saveVoiceSettings();
}
onIncludeSystemAudioChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.includeSystemAudio.set(!!input.checked);
this.saveVoiceSettings();
}
async onNoiseReductionChange(): Promise<void> {
this.noiseReduction.update((v) => !v);
await this.webrtcService.toggleNoiseReduction(this.noiseReduction());
this.saveVoiceSettings();
}
}

View File

@@ -71,152 +71,4 @@
</button>
}
</div>
<!-- Settings Modal -->
@if (showSettings()) {
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
(click)="closeSettings()"
>
<div
class="bg-card border border-border rounded-lg p-6 w-full max-w-md m-4"
(click)="$event.stopPropagation()"
>
<h2 class="text-xl font-semibold text-foreground mb-4">Voice Settings</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-1">Microphone</label>
<select
(change)="onInputDeviceChange($event)"
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"
>
@for (device of inputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedInputDevice()"
>
{{ device.label || 'Microphone ' + $index }}
</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Speaker</label>
<select
(change)="onOutputDeviceChange($event)"
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"
>
@for (device of outputDevices(); track device.deviceId) {
<option
[value]="device.deviceId"
[selected]="device.deviceId === selectedOutputDevice()"
>
{{ device.label || 'Speaker ' + $index }}
</option>
}
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Input Volume: {{ inputVolume() }}%
</label>
<input
type="range"
[value]="inputVolume()"
(input)="onInputVolumeChange($event)"
min="0"
max="100"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Output Volume: {{ outputVolume() }}%
</label>
<input
type="range"
[value]="outputVolume()"
(input)="onOutputVolumeChange($event)"
min="0"
max="100"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Latency</label>
<select
(change)="onLatencyProfileChange($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm"
>
<option value="low">Low (fast)</option>
<option value="balanced" selected>Balanced</option>
<option value="high">High (quality)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1"
>Include system audio when sharing screen</label
>
<input
type="checkbox"
[checked]="includeSystemAudio()"
(change)="onIncludeSystemAudioChange($event)"
class="accent-primary"
/>
<p class="text-xs text-muted-foreground">
Off by default; viewers will still hear your mic.
</p>
</div>
<div class="flex items-center justify-between">
<div>
<label class="block text-sm font-medium text-foreground">Noise reduction</label>
<p class="text-xs text-muted-foreground">Suppress background noise using RNNoise</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
[checked]="noiseReduction()"
(change)="onNoiseReductionChange($event)"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
></div>
</label>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">
Audio Bitrate: {{ audioBitrate() }} kbps
</label>
<input
type="range"
[value]="audioBitrate()"
(input)="onAudioBitrateChange($event)"
min="32"
max="256"
step="8"
class="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button
(click)="closeSettings()"
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
>
Close
</button>
</div>
</div>
</div>
}
</div>

View File

@@ -31,6 +31,7 @@ import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { UserAvatarComponent } from '../../../shared';
interface AudioDevice {
@@ -62,6 +63,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
private voiceSessionService = inject(VoiceSessionService);
private voiceActivity = inject(VoiceActivityService);
private store = inject(Store);
private settingsModal = inject(SettingsModalService);
private remoteStreamSubscription: Subscription | null = null;
private remoteAudioElements = new Map<string, HTMLAudioElement>();
private pendingRemoteStreams = new Map<string, MediaStream>();
@@ -432,7 +434,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
}
toggleSettings(): void {
this.showSettings.update((current) => !current);
this.settingsModal.open('voice');
}
closeSettings(): void {