style: Now uses template files
This commit is contained in:
@@ -9,12 +9,12 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login.component').then((m) => m.LoginComponent),
|
||||
import('./features/auth/login/login.component').then((m) => m.LoginComponent),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register.component').then((m) => m.RegisterComponent),
|
||||
import('./features/auth/register/register.component').then((m) => m.RegisterComponent),
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
@@ -26,7 +26,7 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'room/:roomId',
|
||||
loadComponent: () =>
|
||||
import('./features/room/chat-room.component').then((m) => m.ChatRoomComponent),
|
||||
import('./features/room/chat-room/chat-room.component').then((m) => m.ChatRoomComponent),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import * as UsersActions from './store/users/users.actions';
|
||||
import * as RoomsActions from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export class WebRTCService {
|
||||
this._screenStream = null;
|
||||
this._screenStreamSignal.set(null);
|
||||
this._isScreenSharing.set(false);
|
||||
|
||||
|
||||
// Immediately broadcast that we stopped sharing
|
||||
this.broadcastCurrentStates();
|
||||
}
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
import { Component, inject, signal } 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 {
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
} from '../../store/users/users.selectors';
|
||||
import { BanEntry, Room } from '../../core/models';
|
||||
|
||||
type AdminTab = 'settings' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
@if (isAdmin()) {
|
||||
<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" />
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
[class.border-b-2]="activeTab() === 'settings'"
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
[class.border-b-2]="activeTab() === 'bans'"
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
[class.border-b-2]="activeTab() === 'permissions'"
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('settings') {
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Description</label>
|
||||
<textarea
|
||||
[(ngModel)]="roomDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Private Room Toggle -->
|
||||
<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>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="maxUsers"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
(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" />
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<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"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
} @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>
|
||||
<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>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="adminsManageRooms" 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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" 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" 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" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<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"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to delete this room? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="showDeleteConfirm.set(false)"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteRoom()"
|
||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>You don't have admin permissions</p>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AdminPanelComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
// Settings
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
maxUsers = 0;
|
||||
|
||||
// Permissions
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((v) => !v);
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
306
src/app/features/admin/admin-panel/admin-panel.component.html
Normal file
306
src/app/features/admin/admin-panel/admin-panel.component.html
Normal file
@@ -0,0 +1,306 @@
|
||||
@if (isAdmin()) {
|
||||
<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" />
|
||||
<h2 class="font-semibold text-foreground">Admin Panel</h2>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-border">
|
||||
<button
|
||||
(click)="activeTab.set('settings')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'settings'"
|
||||
[class.border-b-2]="activeTab() === 'settings'"
|
||||
[class.border-primary]="activeTab() === 'settings'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'settings'"
|
||||
>
|
||||
<ng-icon name="lucideSettings" class="w-4 h-4 inline mr-1" />
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('bans')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'bans'"
|
||||
[class.border-b-2]="activeTab() === 'bans'"
|
||||
[class.border-primary]="activeTab() === 'bans'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'bans'"
|
||||
>
|
||||
<ng-icon name="lucideBan" class="w-4 h-4 inline mr-1" />
|
||||
Bans
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('permissions')"
|
||||
class="flex-1 px-4 py-2 text-sm font-medium transition-colors"
|
||||
[class.text-primary]="activeTab() === 'permissions'"
|
||||
[class.border-b-2]="activeTab() === 'permissions'"
|
||||
[class.border-primary]="activeTab() === 'permissions'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'permissions'"
|
||||
>
|
||||
<ng-icon name="lucideUsers" class="w-4 h-4 inline mr-1" />
|
||||
Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
@switch (activeTab()) {
|
||||
@case ('settings') {
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Settings</h3>
|
||||
|
||||
<!-- Room Name -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Room Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="roomName"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room Description -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Description</label>
|
||||
<textarea
|
||||
[(ngModel)]="roomDescription"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Private Room Toggle -->
|
||||
<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>
|
||||
|
||||
<!-- Max Users -->
|
||||
<div>
|
||||
<label class="block text-sm text-muted-foreground mb-1">Max Users (0 = unlimited)</label>
|
||||
<input
|
||||
type="number"
|
||||
[(ngModel)]="maxUsers"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
(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" />
|
||||
Save Settings
|
||||
</button>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<h3 class="text-sm font-medium text-destructive mb-4">Danger Zone</h3>
|
||||
<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"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4" />
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('bans') {
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
} @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>
|
||||
<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>
|
||||
}
|
||||
@case ('permissions') {
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-foreground">Room Permissions</h3>
|
||||
|
||||
<!-- Permission Toggles -->
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="adminsManageRooms" 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 chat & voice rooms</p>
|
||||
</div>
|
||||
<input type="checkbox" [(ngModel)]="moderatorsManageRooms" 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" 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" class="w-4 h-4 accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Permissions -->
|
||||
<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"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4" />
|
||||
Save Permissions
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
@if (showDeleteConfirm()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="showDeleteConfirm.set(false)">
|
||||
<div class="bg-card border border-border rounded-lg p-6 w-96 max-w-[90vw]" (click)="$event.stopPropagation()">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-2">Delete Room</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to delete this room? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
(click)="showDeleteConfirm.set(false)"
|
||||
class="px-4 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="deleteRoom()"
|
||||
class="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Delete Room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="h-full flex items-center justify-center text-muted-foreground">
|
||||
<p>You don't have admin permissions</p>
|
||||
</div>
|
||||
}
|
||||
160
src/app/features/admin/admin-panel/admin-panel.component.ts
Normal file
160
src/app/features/admin/admin-panel/admin-panel.component.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Component, inject, signal } 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 {
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import * as RoomsActions from '../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectBannedUsers,
|
||||
selectIsCurrentUserAdmin,
|
||||
selectCurrentUser,
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, Room } from '../../../core/models';
|
||||
|
||||
type AdminTab = 'settings' | 'bans' | 'permissions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideShield,
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideTrash2,
|
||||
lucideCheck,
|
||||
lucideX,
|
||||
lucideLock,
|
||||
lucideUnlock,
|
||||
}),
|
||||
],
|
||||
templateUrl: './admin-panel.component.html',
|
||||
})
|
||||
export class AdminPanelComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
||||
|
||||
activeTab = signal<AdminTab>('settings');
|
||||
showDeleteConfirm = signal(false);
|
||||
|
||||
// Settings
|
||||
roomName = '';
|
||||
roomDescription = '';
|
||||
isPrivate = signal(false);
|
||||
maxUsers = 0;
|
||||
|
||||
// Permissions
|
||||
allowVoice = true;
|
||||
allowScreenShare = true;
|
||||
allowFileUploads = true;
|
||||
slowModeInterval = '0';
|
||||
adminsManageRooms = false;
|
||||
moderatorsManageRooms = false;
|
||||
adminsManageIcon = false;
|
||||
moderatorsManageIcon = false;
|
||||
|
||||
constructor() {
|
||||
// Initialize from current room
|
||||
const room = this.currentRoom();
|
||||
if (room) {
|
||||
this.roomName = room.name;
|
||||
this.roomDescription = room.description || '';
|
||||
this.isPrivate.set(room.isPrivate);
|
||||
this.maxUsers = room.maxUsers || 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
togglePrivate(): void {
|
||||
this.isPrivate.update((v) => !v);
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.updateRoom({
|
||||
roomId: room.id,
|
||||
changes: {
|
||||
name: this.roomName,
|
||||
description: this.roomDescription,
|
||||
isPrivate: this.isPrivate(),
|
||||
maxUsers: this.maxUsers,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
savePermissions(): void {
|
||||
const room = this.currentRoom();
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanUser(ban: BanEntry): void {
|
||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
||||
}
|
||||
|
||||
confirmDeleteRoom(): void {
|
||||
this.showDeleteConfirm.set(true);
|
||||
}
|
||||
|
||||
deleteRoom(): void {
|
||||
const room = this.currentRoom();
|
||||
if (!room) return;
|
||||
|
||||
this.store.dispatch(RoomsActions.deleteRoom({ roomId: room.id }));
|
||||
this.showDeleteConfirm.set(false);
|
||||
}
|
||||
|
||||
formatExpiry(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
template: `
|
||||
<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" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
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({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goRegister() {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
}
|
||||
30
src/app/features/auth/login/login.component.html
Normal file
30
src/app/features/auth/login/login.component.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<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" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Login</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Login</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
No account? <a class="text-primary hover:underline" (click)="goRegister()">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
63
src/app/features/auth/login/login.component.ts
Normal file
63
src/app/features/auth/login/login.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
templateUrl: './login.component.html',
|
||||
})
|
||||
export class LoginComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
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({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Login failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goRegister() {
|
||||
this.router.navigate(['/register']);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { User } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
template: `
|
||||
<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" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class RegisterComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
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({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
34
src/app/features/auth/register/register.component.html
Normal file
34
src/app/features/auth/register/register.component.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<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" />
|
||||
<h1 class="text-lg font-semibold text-foreground">Register</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Username</label>
|
||||
<input [(ngModel)]="username" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Display Name</label>
|
||||
<input [(ngModel)]="displayName" type="text" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Password</label>
|
||||
<input [(ngModel)]="password" type="password" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-muted-foreground mb-1">Server App</label>
|
||||
<select [(ngModel)]="serverId" class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground">
|
||||
<option *ngFor="let s of servers(); trackBy: trackById" [value]="s.id">{{ s.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<p *ngIf="error()" class="text-xs text-destructive">{{ error() }}</p>
|
||||
<button (click)="submit()" class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">Create Account</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
Have an account? <a class="text-primary hover:underline" (click)="goLogin()">Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
64
src/app/features/auth/register/register.component.ts
Normal file
64
src/app/features/auth/register/register.component.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Component, inject, 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 { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
templateUrl: './register.component.html',
|
||||
})
|
||||
export class RegisterComponent {
|
||||
private auth = inject(AuthService);
|
||||
private serversSvc = inject(ServerDirectoryService);
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
serverId: string | undefined = this.serversSvc.activeServer()?.id;
|
||||
error = signal<string | null>(null);
|
||||
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
submit() {
|
||||
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({
|
||||
next: (resp) => {
|
||||
if (sid) this.serversSvc.setActiveServer(sid);
|
||||
const user: User = {
|
||||
id: resp.id,
|
||||
oderId: resp.id,
|
||||
username: resp.username,
|
||||
displayName: resp.displayName,
|
||||
status: 'online',
|
||||
role: 'member',
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
try { localStorage.setItem('metoyou_currentUserId', resp.id); } catch {}
|
||||
this.store.dispatch(UsersActions.setCurrentUser({ user }));
|
||||
this.router.navigate(['/search']);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || 'Registration failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
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 { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
template: `
|
||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
||||
<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" />
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<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 (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>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class UserBarComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
goto(path: 'login' | 'register') {
|
||||
this.router.navigate([`/${path}`]);
|
||||
}
|
||||
}
|
||||
18
src/app/features/auth/user-bar/user-bar.component.html
Normal file
18
src/app/features/auth/user-bar/user-bar.component.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
|
||||
<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" />
|
||||
<span class="text-foreground">{{ user()?.displayName }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<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 (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>
|
||||
}
|
||||
</div>
|
||||
24
src/app/features/auth/user-bar/user-bar.component.ts
Normal file
24
src/app/features/auth/user-bar/user-bar.component.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
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 { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUser, lucideLogIn, lucideUserPlus })],
|
||||
templateUrl: './user-bar.component.html',
|
||||
})
|
||||
export class UserBarComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
user = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
goto(path: 'login' | 'register') {
|
||||
this.router.navigate([`/${path}`]);
|
||||
}
|
||||
}
|
||||
@@ -752,7 +752,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const safeHtml = DOMPurify.sanitize(container.innerHTML);
|
||||
return this.sanitizer.bypassSecurityTrustHtml(safeHtml);
|
||||
}
|
||||
|
||||
|
||||
// Resolve images marked for CSP-safe loading by converting to blob URLs
|
||||
private async loadCspImages(): Promise<void> {
|
||||
const root = this.messagesContainer?.nativeElement;
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../chat/chat-messages.component';
|
||||
import { UserListComponent } from '../chat/user-list.component';
|
||||
import { ScreenShareViewerComponent } from '../voice/screen-share-viewer.component';
|
||||
import { AdminPanelComponent } from '../admin/admin-panel.component';
|
||||
import { RoomsSidePanelComponent } from './rooms-side-panel.component';
|
||||
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectIsCurrentUserAdmin } from '../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="h-full flex flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left rail is global; chat area fills remaining space -->
|
||||
|
||||
<!-- Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Screen Share Viewer -->
|
||||
<app-screen-share-viewer />
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||
<app-rooms-side-panel class="h-full" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls moved to sidebar bottom -->
|
||||
|
||||
<!-- Mobile overlay removed; sidebar remains visible by default -->
|
||||
} @else {
|
||||
<!-- 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" />
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
showMenu = signal(false);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
// Sidebar always visible; panel toggles removed
|
||||
|
||||
// Header moved to TitleBar
|
||||
}
|
||||
37
src/app/features/room/chat-room/chat-room.component.html
Normal file
37
src/app/features/room/chat-room/chat-room.component.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<div class="h-full flex flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<!-- Main Content -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Left rail is global; chat area fills remaining space -->
|
||||
|
||||
<!-- Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Screen Share Viewer -->
|
||||
<app-screen-share-viewer />
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar always visible -->
|
||||
<aside class="w-80 flex-shrink-0 border-l border-border">
|
||||
<app-rooms-side-panel class="h-full" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Voice Controls moved to sidebar bottom -->
|
||||
|
||||
<!-- Mobile overlay removed; sidebar remains visible by default -->
|
||||
} @else {
|
||||
<!-- 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" />
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
59
src/app/features/room/chat-room/chat-room.component.ts
Normal file
59
src/app/features/room/chat-room/chat-room.component.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ChatMessagesComponent } from '../../chat/chat-messages.component';
|
||||
import { UserListComponent } from '../../chat/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 } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
|
||||
type SidebarPanel = 'rooms' | 'users' | 'admin' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessagesComponent,
|
||||
ScreenShareViewerComponent,
|
||||
RoomsSidePanelComponent,
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideHash,
|
||||
lucideSettings,
|
||||
lucideUsers,
|
||||
lucideMenu,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
}),
|
||||
],
|
||||
templateUrl: './chat-room.component.html',
|
||||
})
|
||||
export class ChatRoomComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
showMenu = signal(false);
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
|
||||
// Sidebar always visible; panel toggles removed
|
||||
|
||||
// Header moved to TitleBar
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||
import { VoiceControlsComponent } from '../voice/voice-controls.component';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
|
||||
],
|
||||
template: `
|
||||
<aside class="w-80 bg-card h-full flex flex-col">
|
||||
<!-- Minimalistic header with tabs -->
|
||||
<div class="border-b border-border">
|
||||
<div class="flex items-center">
|
||||
<!-- Tab buttons -->
|
||||
<button
|
||||
(click)="activeTab.set('channels')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'channels'"
|
||||
[class.text-foreground]="activeTab() === 'channels'"
|
||||
[class.border-transparent]="activeTab() !== 'channels'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
||||
>
|
||||
<ng-icon name="lucideHash" class="w-4 h-4" />
|
||||
<span>Channels</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('users')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'users'"
|
||||
[class.text-foreground]="activeTab() === 'users'"
|
||||
[class.border-transparent]="activeTab() !== 'users'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'users'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'users'"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channels View -->
|
||||
@if (activeTab() === 'channels') {
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
|
||||
<div class="space-y-1">
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> general
|
||||
</button>
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> random
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<!-- General Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('general')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔊</span> General
|
||||
</span>
|
||||
@if (voiceOccupancy('general') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- AFK Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔕</span> AFK
|
||||
</span>
|
||||
@if (voiceOccupancy('afk') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Users View -->
|
||||
@if (activeTab() === 'users') {
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<!-- 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>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||
<div class="relative">
|
||||
@if (currentUser()?.avatarUrl) {
|
||||
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
||||
} @else {
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
<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" />
|
||||
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" />
|
||||
LIVE
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
<div class="relative">
|
||||
@if (user.avatarUrl) {
|
||||
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
||||
} @else {
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<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">{{ user.displayName }}</p>
|
||||
<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" />
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
|
||||
<button
|
||||
(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" />
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- No other users message -->
|
||||
@if (onlineUsersFiltered().length === 0) {
|
||||
<div class="text-center py-4 text-muted-foreground">
|
||||
<p class="text-sm">No other users in this server</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (voiceEnabled()) {
|
||||
<div [class.invisible]="showFloatingControls()">
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
`,
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
|
||||
activeTab = signal<TabView>('channels');
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
// Filter out current user from online users list
|
||||
onlineUsersFiltered() {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
|
||||
}
|
||||
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
console.warn('Voice is disabled by room permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
// Connected to voice in a different server - user must disconnect first
|
||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId: roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`,
|
||||
});
|
||||
}
|
||||
}).catch((e) => console.error('Failed to join voice room', roomId, e));
|
||||
}
|
||||
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
// Only leave if currently in this room
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
||||
|
||||
// Stop voice heartbeat
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
|
||||
// Disable voice locally
|
||||
this.webrtc.disableVoice();
|
||||
|
||||
// Update store voice state
|
||||
if (current?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// Broadcast disconnect
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
});
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
// Only count users connected to voice in this specific server and room
|
||||
return users.filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
// Focus viewer on a user's stream if present
|
||||
// Requires WebRTCService to expose a remote streams registry.
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
viewStream(userId: string) {
|
||||
// Focus viewer on a user's stream - dispatches event to screen-share-viewer
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
if (me?.id === userId) {
|
||||
// Local user: use signal
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
// For remote users, check the store state first (authoritative)
|
||||
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
// Store says not sharing - trust this over stream presence
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to checking stream if store state is undefined
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
// Only show users connected to voice in this specific server and room
|
||||
return this.onlineUsers().filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
// Check that voice is connected AND both the server AND room match
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<aside class="w-80 bg-card h-full flex flex-col">
|
||||
<!-- Minimalistic header with tabs -->
|
||||
<div class="border-b border-border">
|
||||
<div class="flex items-center">
|
||||
<!-- Tab buttons -->
|
||||
<button
|
||||
(click)="activeTab.set('channels')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'channels'"
|
||||
[class.text-foreground]="activeTab() === 'channels'"
|
||||
[class.border-transparent]="activeTab() !== 'channels'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'channels'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'channels'"
|
||||
>
|
||||
<ng-icon name="lucideHash" class="w-4 h-4" />
|
||||
<span>Channels</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="activeTab.set('users')"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-3 py-3 text-sm transition-colors border-b-2"
|
||||
[class.border-primary]="activeTab() === 'users'"
|
||||
[class.text-foreground]="activeTab() === 'users'"
|
||||
[class.border-transparent]="activeTab() !== 'users'"
|
||||
[class.text-muted-foreground]="activeTab() !== 'users'"
|
||||
[class.hover:text-foreground]="activeTab() !== 'users'"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channels View -->
|
||||
@if (activeTab() === 'channels') {
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Text Channels -->
|
||||
<div class="p-3">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Text Channels</h4>
|
||||
<div class="space-y-1">
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> general
|
||||
</button>
|
||||
<button class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center gap-2 text-left text-foreground/80 hover:text-foreground">
|
||||
<span class="text-muted-foreground">#</span> random
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Channels -->
|
||||
<div class="p-3 pt-0">
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Voice Channels</h4>
|
||||
@if (!voiceEnabled()) {
|
||||
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
|
||||
}
|
||||
<div class="space-y-1">
|
||||
<!-- General Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('general')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('general')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔊</span> General
|
||||
</span>
|
||||
@if (voiceOccupancy('general') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('general') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('general').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('general'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- AFK Voice -->
|
||||
<div>
|
||||
<button
|
||||
class="w-full px-2 py-2 text-base rounded hover:bg-secondary/60 flex items-center justify-between text-left"
|
||||
(click)="joinVoice('afk')"
|
||||
[class.bg-secondary/40]="isCurrentRoom('afk')"
|
||||
[disabled]="!voiceEnabled()"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-foreground/80">
|
||||
<span>🔕</span> AFK
|
||||
</span>
|
||||
@if (voiceOccupancy('afk') > 0) {
|
||||
<span class="text-sm text-muted-foreground">{{ voiceOccupancy('afk') }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (voiceUsersInRoom('afk').length > 0) {
|
||||
<div class="ml-5 mt-1 space-y-1">
|
||||
@for (u of voiceUsersInRoom('afk'); track u.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
@if (u.avatarUrl) {
|
||||
<img
|
||||
[src]="u.avatarUrl"
|
||||
alt=""
|
||||
class="w-7 h-7 rounded-full ring-2 object-cover"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium ring-2"
|
||||
[class.ring-green-500]="!u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-yellow-500]="u.voiceState?.isMuted && !u.voiceState?.isDeafened"
|
||||
[class.ring-red-500]="u.voiceState?.isDeafened"
|
||||
>
|
||||
{{ u.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<span class="text-sm text-foreground/80 truncate flex-1">{{ u.displayName }}</span>
|
||||
@if (u.screenShareState?.isSharing || isUserSharing(u.id)) {
|
||||
<button
|
||||
(click)="viewStream(u.id); $event.stopPropagation()"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold bg-red-500 text-white rounded animate-pulse hover:bg-red-600 transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
@if (u.voiceState?.isMuted) {
|
||||
<ng-icon name="lucideMicOff" class="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Users View -->
|
||||
@if (activeTab() === 'users') {
|
||||
<div class="flex-1 overflow-auto p-3">
|
||||
<!-- 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>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded bg-secondary/30">
|
||||
<div class="relative">
|
||||
@if (currentUser()?.avatarUrl) {
|
||||
<img [src]="currentUser()?.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
||||
} @else {
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
<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" />
|
||||
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" />
|
||||
LIVE
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<div class="space-y-1">
|
||||
@for (user of onlineUsersFiltered(); track user.id) {
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40">
|
||||
<div class="relative">
|
||||
@if (user.avatarUrl) {
|
||||
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
|
||||
} @else {
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary text-sm font-medium">
|
||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<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">{{ user.displayName }}</p>
|
||||
<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" />
|
||||
In voice
|
||||
</p>
|
||||
}
|
||||
@if (user.screenShareState?.isSharing || isUserSharing(user.id)) {
|
||||
<button
|
||||
(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" />
|
||||
LIVE
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- No other users message -->
|
||||
@if (onlineUsersFiltered().length === 0) {
|
||||
<div class="text-center py-4 text-muted-foreground">
|
||||
<p class="text-sm">No other users in this server</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
|
||||
@if (voiceEnabled()) {
|
||||
<div [class.invisible]="showFloatingControls()">
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers } from '@ng-icons/lucide';
|
||||
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rooms-side-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, VoiceControlsComponent],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMessageSquare, lucideMic, lucideMicOff, lucideChevronLeft, lucideMonitor, lucideHash, lucideUsers })
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html',
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
private voiceSessionService = inject(VoiceSessionService);
|
||||
|
||||
activeTab = signal<TabView>('channels');
|
||||
showFloatingControls = this.voiceSessionService.showFloatingControls;
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
// Filter out current user from online users list
|
||||
onlineUsersFiltered() {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
const currentOderId = current?.oderId;
|
||||
return this.onlineUsers().filter(u => u.id !== currentId && u.oderId !== currentOderId);
|
||||
}
|
||||
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
console.warn('Voice is disabled by room permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
// Connected to voice in a different server - user must disconnect first
|
||||
console.warn('Already connected to voice in another server. Disconnect first before joining.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
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) {
|
||||
const voiceRoomName = roomId === 'general' ? '🔊 General' : roomId === 'afk' ? '🔕 AFK' : roomId;
|
||||
this.voiceSessionService.startSession({
|
||||
serverId: room.id,
|
||||
serverName: room.name,
|
||||
roomId: roomId,
|
||||
roomName: voiceRoomName,
|
||||
serverIcon: room.icon,
|
||||
serverDescription: room.description,
|
||||
serverRoute: `/room/${room.id}`,
|
||||
});
|
||||
}
|
||||
}).catch((e) => console.error('Failed to join voice room', roomId, e));
|
||||
}
|
||||
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
// Only leave if currently in this room
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return;
|
||||
|
||||
// Stop voice heartbeat
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
|
||||
// Disable voice locally
|
||||
this.webrtc.disableVoice();
|
||||
|
||||
// Update store voice state
|
||||
if (current?.id) {
|
||||
this.store.dispatch(UsersActions.updateVoiceState({
|
||||
userId: current.id,
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
}));
|
||||
}
|
||||
|
||||
// Broadcast disconnect
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
displayName: current?.displayName || 'User',
|
||||
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
|
||||
});
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
voiceOccupancy(roomId: string): number {
|
||||
const users = this.onlineUsers();
|
||||
const room = this.currentRoom();
|
||||
// Only count users connected to voice in this specific server and room
|
||||
return users.filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
).length;
|
||||
}
|
||||
|
||||
viewShare(userId: string) {
|
||||
// Focus viewer on a user's stream if present
|
||||
// Requires WebRTCService to expose a remote streams registry.
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
viewStream(userId: string) {
|
||||
// Focus viewer on a user's stream - dispatches event to screen-share-viewer
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
if (me?.id === userId) {
|
||||
// Local user: use signal
|
||||
return this.webrtc.isScreenSharing();
|
||||
}
|
||||
|
||||
// For remote users, check the store state first (authoritative)
|
||||
const user = this.onlineUsers().find(u => u.id === userId || u.oderId === userId);
|
||||
if (user?.screenShareState?.isSharing === false) {
|
||||
// Store says not sharing - trust this over stream presence
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to checking stream if store state is undefined
|
||||
const stream = this.webrtc.getRemoteStream(userId);
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
// Only show users connected to voice in this specific server and room
|
||||
return this.onlineUsers().filter(u =>
|
||||
!!u.voiceState?.isConnected &&
|
||||
u.voiceState?.roomId === roomId &&
|
||||
u.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
// Check that voice is connected AND both the server AND room match
|
||||
return !!(
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
}
|
||||
197
src/app/features/server-search/server-search.component.html
Normal file
197
src/app/features/server-search/server-search.component.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- My Servers -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||
@if (savedRooms().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
>
|
||||
{{ room.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Search Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search servers..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Server Button -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
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" />
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</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" />
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="p-4 space-y-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (server.isPrivate) {
|
||||
<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" />
|
||||
}
|
||||
</div>
|
||||
@if (server.description) {
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
}
|
||||
@if (server.topic) {
|
||||
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||
{{ server.topic }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-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>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
<p class="text-sm text-destructive">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
|
||||
<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">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="newServerPrivate"
|
||||
id="private"
|
||||
class="w-4 h-4 rounded border-border bg-secondary"
|
||||
/>
|
||||
<label for="private" class="text-sm text-foreground">Private server</label>
|
||||
</div>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName()"
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -31,205 +31,7 @@ import { ServerInfo } from '../../core/models';
|
||||
viewProviders: [
|
||||
provideIcons({ lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings }),
|
||||
],
|
||||
template: `
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- My Servers -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||
@if (savedRooms().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
>
|
||||
{{ room.name }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Search Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search servers..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Server Button -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
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" />
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</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" />
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="p-4 space-y-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
@if (server.isPrivate) {
|
||||
<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" />
|
||||
}
|
||||
</div>
|
||||
@if (server.description) {
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
}
|
||||
@if (server.topic) {
|
||||
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||
{{ server.topic }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-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>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
<p class="text-sm text-destructive">{{ error() }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Create Server Dialog -->
|
||||
@if (showCreateDialog()) {
|
||||
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" (click)="closeCreateDialog()">
|
||||
<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">Create Server</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Server Name</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="My Awesome Server"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Description (optional)</label>
|
||||
<textarea
|
||||
[(ngModel)]="newServerDescription"
|
||||
placeholder="What's your server about?"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Topic (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerTopic"
|
||||
placeholder="gaming, music, coding..."
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="newServerPrivate"
|
||||
id="private"
|
||||
class="w-4 h-4 rounded border-border bg-secondary"
|
||||
/>
|
||||
<label for="private" class="text-sm text-foreground">Private server</label>
|
||||
</div>
|
||||
|
||||
@if (newServerPrivate()) {
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="newServerPassword"
|
||||
placeholder="Enter password"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button
|
||||
(click)="closeCreateDialog()"
|
||||
class="flex-1 px-4 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
(click)="createServer()"
|
||||
[disabled]="!newServerName()"
|
||||
class="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
templateUrl: './server-search.component.html',
|
||||
})
|
||||
export class ServerSearchComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
|
||||
57
src/app/features/servers/servers-rail.component.html
Normal file
57
src/app/features/servers/servers-rail.component.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<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
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
|
||||
<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)"
|
||||
>
|
||||
<ng-container *ngIf="room.icon; else noIcon">
|
||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
||||
</ng-container>
|
||||
<ng-template #noIcon>
|
||||
<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>
|
||||
</div>
|
||||
</ng-template>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div *ngIf="showMenu()" class="">
|
||||
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
|
||||
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forget confirmation dialog -->
|
||||
<div *ngIf="showConfirm()">
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
|
||||
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,65 +15,7 @@ import * as RoomsActions from '../../store/rooms/rooms.actions';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
template: `
|
||||
<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
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||
<ng-container *ngFor="let room of savedRooms(); trackBy: trackRoomId">
|
||||
<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)"
|
||||
>
|
||||
<ng-container *ngIf="room.icon; else noIcon">
|
||||
<img [src]="room.icon" [alt]="room.name" class="w-full h-full object-cover" />
|
||||
</ng-container>
|
||||
<ng-template #noIcon>
|
||||
<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>
|
||||
</div>
|
||||
</ng-template>
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div *ngIf="showMenu()" class="">
|
||||
<div class="fixed inset-0 z-40" (click)="closeMenu()"></div>
|
||||
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-md w-44" [style.left.px]="menuX()" [style.top.px]="menuY()">
|
||||
<button *ngIf="isCurrentContextRoom()" (click)="leaveServer()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<button (click)="openForgetConfirm()" class="w-full text-left px-3 py-2 hover:bg-secondary transition-colors text-foreground">Forget Server</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forget confirmation dialog -->
|
||||
<div *ngIf="showConfirm()">
|
||||
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelForget()"></div>
|
||||
<div class="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card border border-border rounded-lg shadow-lg w-[280px]">
|
||||
<div class="p-4">
|
||||
<h4 class="font-semibold text-foreground mb-2">Forget Server?</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Remove <span class="font-medium text-foreground">{{ contextRoom()?.name }}</span> from your My Servers list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 p-3 border-t border-border">
|
||||
<button (click)="cancelForget()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors">Cancel</button>
|
||||
<button (click)="confirmForget()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors">Forget</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './servers-rail.component.html',
|
||||
})
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
168
src/app/features/settings/settings.component.html
Normal file
168
src/app/features/settings/settings.component.html
Normal file
@@ -0,0 +1,168 @@
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
(click)="goBack()"
|
||||
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" />
|
||||
</button>
|
||||
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Server Endpoints Section -->
|
||||
<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" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<button
|
||||
(click)="testAllServers()"
|
||||
[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()" />
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-3 mb-4">
|
||||
@for (server of servers(); track server.id) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 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"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div
|
||||
class="w-3 h-3 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'"
|
||||
[title]="server.status"
|
||||
></div>
|
||||
|
||||
<!-- Server Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
<button
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Set as active"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||
</button>
|
||||
}
|
||||
@if (!server.isDefault) {
|
||||
<button
|
||||
(click)="removeServer(server.id)"
|
||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove server"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New Server -->
|
||||
<div class="border-t border-border pt-4">
|
||||
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="Server name (e.g., My Server)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground 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-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="addServer()"
|
||||
[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" />
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="autoReconnect"
|
||||
(change)="saveConnectionSettings()"
|
||||
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 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>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="searchAllServers"
|
||||
(change)="saveConnectionSettings()"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
lucideArrowLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { ServerDirectoryService, ServerEndpoint } from '../../core/services/server-directory.service';
|
||||
import { ServerDirectoryService } from '../../core/services/server-directory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -34,176 +34,7 @@ import { ServerDirectoryService, ServerEndpoint } from '../../core/services/serv
|
||||
lucideArrowLeft,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
(click)="goBack()"
|
||||
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" />
|
||||
</button>
|
||||
<ng-icon name="lucideSettings" class="w-6 h-6 text-primary" />
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Server Endpoints Section -->
|
||||
<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" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Server Endpoints</h2>
|
||||
</div>
|
||||
<button
|
||||
(click)="testAllServers()"
|
||||
[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()" />
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Server List -->
|
||||
<div class="space-y-3 mb-4">
|
||||
@for (server of servers(); track server.id) {
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 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"
|
||||
>
|
||||
<!-- Status Indicator -->
|
||||
<div
|
||||
class="w-3 h-3 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'"
|
||||
[title]="server.status"
|
||||
></div>
|
||||
|
||||
<!-- Server Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground truncate">{{ server.url }}</p>
|
||||
@if (server.latency !== undefined && server.status === 'online') {
|
||||
<p class="text-xs text-muted-foreground">{{ server.latency }}ms</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if (!server.isActive) {
|
||||
<button
|
||||
(click)="setActiveServer(server.id)"
|
||||
class="p-2 hover:bg-secondary rounded-lg transition-colors"
|
||||
title="Set as active"
|
||||
>
|
||||
<ng-icon name="lucideCheck" class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||
</button>
|
||||
}
|
||||
@if (!server.isDefault) {
|
||||
<button
|
||||
(click)="removeServer(server.id)"
|
||||
class="p-2 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
title="Remove server"
|
||||
>
|
||||
<ng-icon name="lucideTrash2" class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Add New Server -->
|
||||
<div class="border-t border-border pt-4">
|
||||
<h3 class="text-sm font-medium text-foreground mb-3">Add New Server</h3>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newServerName"
|
||||
placeholder="Server name (e.g., My Server)"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground 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-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="addServer()"
|
||||
[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" />
|
||||
</button>
|
||||
</div>
|
||||
@if (addError()) {
|
||||
<p class="text-sm text-destructive mt-2">{{ addError() }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Settings -->
|
||||
<div class="bg-card border border-border rounded-lg p-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<ng-icon name="lucideServer" class="w-5 h-5 text-muted-foreground" />
|
||||
<h2 class="text-lg font-semibold text-foreground">Connection Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="autoReconnect"
|
||||
(change)="saveConnectionSettings()"
|
||||
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 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>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="searchAllServers"
|
||||
(change)="saveConnectionSettings()"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './settings.component.html',
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
|
||||
41
src/app/features/shell/title-bar.component.html
Normal file
41
src/app/features/shell/title-bar.component.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<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;">
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<ng-container *ngIf="inRoom(); else userServer">
|
||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
|
||||
<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 -->
|
||||
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #userServer>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
|
||||
<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 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 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>
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
||||
@@ -15,49 +15,7 @@ import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMinus, lucideSquare, lucideX, lucideChevronLeft, lucideHash, lucideMenu })],
|
||||
template: `
|
||||
<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;">
|
||||
<div class="flex items-center gap-2 min-w-0 relative" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="inRoom()" (click)="onBack()" class="p-2 hover:bg-secondary rounded" title="Back">
|
||||
<ng-icon name="lucideChevronLeft" class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
<ng-container *ngIf="inRoom(); else userServer">
|
||||
<ng-icon name="lucideHash" class="w-5 h-5 text-muted-foreground" />
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
<span *ngIf="roomDescription()" class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">{{ roomDescription() }}</span>
|
||||
<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 -->
|
||||
<div *ngIf="showMenu()" class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
|
||||
<button (click)="leaveServer()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Leave Server</button>
|
||||
<div class="border-t border-border"></div>
|
||||
<button (click)="logout()" class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground">Logout</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #userServer>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span *ngIf="!isConnected()" class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive">Reconnecting…</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2" style="-webkit-app-region: no-drag;">
|
||||
<button *ngIf="!isAuthed()" class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground" (click)="goLogin()" title="Login">Login</button>
|
||||
<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 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 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>
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
<div *ngIf="showMenu()" class="fixed inset-0 z-40" (click)="closeMenu()" style="-webkit-app-region: no-drag;"></div>
|
||||
`,
|
||||
templateUrl: './title-bar.component.html',
|
||||
})
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
@if (showFloatingControls()) {
|
||||
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
|
||||
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
|
||||
<div class="p-2 flex items-center gap-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
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" />
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Voice status indicator -->
|
||||
<div class="flex items-center gap-1 px-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-border"></div>
|
||||
|
||||
<!-- Voice controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
lucideArrowLeft,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-voice-controls',
|
||||
@@ -25,7 +25,6 @@ import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucideMic,
|
||||
lucideMicOff,
|
||||
lucideMonitor,
|
||||
lucideMonitorOff,
|
||||
lucidePhoneOff,
|
||||
@@ -33,78 +32,7 @@ import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
lucideArrowLeft,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
@if (showFloatingControls()) {
|
||||
<!-- Centered relative to rooms-side-panel (w-80 = 320px, so right-40 = 160px from right edge = center) -->
|
||||
<div class="fixed bottom-4 right-40 translate-x-1/2 z-50 bg-card border border-border rounded-xl shadow-lg">
|
||||
<div class="p-2 flex items-center gap-2">
|
||||
<!-- Back to server button -->
|
||||
<button
|
||||
(click)="navigateToServer()"
|
||||
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" />
|
||||
@if (voiceSession()?.serverIcon) {
|
||||
<img
|
||||
[src]="voiceSession()?.serverIcon"
|
||||
class="w-5 h-5 rounded object-cover"
|
||||
alt=""
|
||||
/>
|
||||
} @else {
|
||||
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
|
||||
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Voice status indicator -->
|
||||
<div class="flex items-center gap-1 px-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
|
||||
<span class="text-xs text-muted-foreground max-w-20 truncate">{{ voiceSession()?.roomName || 'Voice' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-6 bg-border"></div>
|
||||
|
||||
<!-- Voice controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
[class]="getCompactButtonClass(isMuted())"
|
||||
title="Toggle Mute"
|
||||
>
|
||||
<ng-icon [name]="isMuted() ? 'lucideMicOff' : 'lucideMic'" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getCompactButtonClass(isDeafened())"
|
||||
title="Toggle Deafen"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getCompactScreenShareClass()"
|
||||
title="Toggle Screen Share"
|
||||
>
|
||||
<ng-icon [name]="isScreenSharing() ? 'lucideMonitorOff' : 'lucideMonitor'" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="disconnect()"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
templateUrl: './floating-voice-controls.component.html'
|
||||
})
|
||||
export class FloatingVoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
@@ -0,0 +1,80 @@
|
||||
<div
|
||||
class="relative bg-black rounded-lg overflow-hidden"
|
||||
[class.fixed]="isFullscreen()"
|
||||
[class.inset-0]="isFullscreen()"
|
||||
[class.z-50]="isFullscreen()"
|
||||
[class.hidden]="!hasStream()"
|
||||
>
|
||||
<!-- Video Element -->
|
||||
<video
|
||||
#screenVideo
|
||||
autoplay
|
||||
playsinline
|
||||
class="w-full h-full object-contain"
|
||||
[class.max-h-[400px]]="!isFullscreen()"
|
||||
></video>
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<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" />
|
||||
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<ng-template #sharingUnknown>
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Viewer volume -->
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="screenVolume()"
|
||||
(input)="onScreenVolumeChange($event)"
|
||||
class="w-32 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
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" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
||||
}
|
||||
</button>
|
||||
@if (isLocalShare()) {
|
||||
<button
|
||||
(click)="stopSharing()"
|
||||
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" />
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="stopWatching()"
|
||||
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" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Stream Placeholder -->
|
||||
<div *ngIf="!hasStream()" 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" />
|
||||
<p>Waiting for screen share...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
lucideMonitor,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../store/users/users.selectors';
|
||||
import { User } from '../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-viewer',
|
||||
@@ -26,88 +26,7 @@ import { User } from '../../core/models';
|
||||
lucideMonitor,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="relative bg-black rounded-lg overflow-hidden"
|
||||
[class.fixed]="isFullscreen()"
|
||||
[class.inset-0]="isFullscreen()"
|
||||
[class.z-50]="isFullscreen()"
|
||||
[class.hidden]="!hasStream()"
|
||||
>
|
||||
<!-- Video Element -->
|
||||
<video
|
||||
#screenVideo
|
||||
autoplay
|
||||
playsinline
|
||||
class="w-full h-full object-contain"
|
||||
[class.max-h-[400px]]="!isFullscreen()"
|
||||
></video>
|
||||
|
||||
<!-- Overlay Controls -->
|
||||
<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" />
|
||||
<span class="text-sm font-medium" *ngIf="activeScreenSharer(); else sharingUnknown">
|
||||
{{ activeScreenSharer()?.displayName }} is sharing their screen
|
||||
</span>
|
||||
<ng-template #sharingUnknown>
|
||||
<span class="text-sm font-medium">Someone is sharing their screen</span>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Viewer volume -->
|
||||
<div class="flex items-center gap-2 text-white">
|
||||
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
[value]="screenVolume()"
|
||||
(input)="onScreenVolumeChange($event)"
|
||||
class="w-32 accent-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleFullscreen()"
|
||||
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" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMaximize" class="w-4 h-4 text-white" />
|
||||
}
|
||||
</button>
|
||||
@if (isLocalShare()) {
|
||||
<button
|
||||
(click)="stopSharing()"
|
||||
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" />
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
(click)="stopWatching()"
|
||||
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" />
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Stream Placeholder -->
|
||||
<div *ngIf="!hasStream()" 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" />
|
||||
<p>Waiting for screen share...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
templateUrl: './screen-share-viewer.component.html',
|
||||
})
|
||||
export class ScreenShareViewerComponent implements OnDestroy {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
@@ -171,19 +90,19 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
effect(() => {
|
||||
const watchingId = this.watchingUserId();
|
||||
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
|
||||
|
||||
|
||||
// Only check if we're actually watching a remote stream
|
||||
if (!watchingId || !isWatchingRemote) return;
|
||||
|
||||
|
||||
const users = this.onlineUsers();
|
||||
const watchedUser = users.find(u => u.id === watchingId || u.oderId === watchingId);
|
||||
|
||||
|
||||
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
|
||||
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
|
||||
this.stopWatching();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Also check if the stream's video tracks are still available
|
||||
const stream = this.webrtcService.getRemoteStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(t => t.readyState === 'live');
|
||||
@@ -0,0 +1,180 @@
|
||||
<div class="bg-card border-t 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">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (showConnectionError()) {
|
||||
<span class="text-destructive">● Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">● Connected</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">● Disconnected</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
(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" />
|
||||
</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>
|
||||
<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>
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
lucideHeadphones,
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||
import * as UsersActions from '../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import * as UsersActions from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
|
||||
interface AudioDevice {
|
||||
deviceId: string;
|
||||
@@ -43,188 +43,7 @@ interface AudioDevice {
|
||||
lucideHeadphones,
|
||||
}),
|
||||
],
|
||||
template: `
|
||||
<div class="bg-card border-t 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">
|
||||
<span class="w-2 h-2 rounded-full bg-destructive animate-pulse"></span>
|
||||
<span class="text-xs text-destructive">{{ connectionErrorMessage() || 'Connection error' }}</span>
|
||||
<button (click)="retryConnection()" class="ml-auto text-xs text-destructive hover:underline">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-primary font-semibold text-sm">
|
||||
{{ currentUser()?.displayName?.charAt(0)?.toUpperCase() || '?' }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm text-foreground truncate">
|
||||
{{ currentUser()?.displayName || 'Unknown' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@if (showConnectionError()) {
|
||||
<span class="text-destructive">● Connection Error</span>
|
||||
} @else if (isConnected()) {
|
||||
<span class="text-green-500">● Connected</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">● Disconnected</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Voice Controls -->
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
@if (isConnected()) {
|
||||
<!-- Mute Toggle -->
|
||||
<button
|
||||
(click)="toggleMute()"
|
||||
[class]="getMuteButtonClass()"
|
||||
>
|
||||
@if (isMuted()) {
|
||||
<ng-icon name="lucideMicOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMic" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Deafen Toggle -->
|
||||
<button
|
||||
(click)="toggleDeafen()"
|
||||
[class]="getDeafenButtonClass()"
|
||||
>
|
||||
<ng-icon name="lucideHeadphones" class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<!-- Screen Share Toggle -->
|
||||
<button
|
||||
(click)="toggleScreenShare()"
|
||||
[class]="getScreenShareButtonClass()"
|
||||
>
|
||||
@if (isScreenSharing()) {
|
||||
<ng-icon name="lucideMonitorOff" class="w-5 h-5" />
|
||||
} @else {
|
||||
<ng-icon name="lucideMonitor" class="w-5 h-5" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Disconnect -->
|
||||
<button
|
||||
(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" />
|
||||
</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>
|
||||
<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>
|
||||
`,
|
||||
templateUrl: './voice-controls.component.html',
|
||||
})
|
||||
export class VoiceControlsComponent implements OnInit, OnDestroy {
|
||||
private webrtcService = inject(WebRTCService);
|
||||
Reference in New Issue
Block a user