Big commit

This commit is contained in:
2026-03-02 00:13:34 +01:00
parent d146138fca
commit 6d7465ff18
54 changed files with 5999 additions and 2291 deletions

View File

@@ -36,135 +36,133 @@
<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 class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('text')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Text Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
<div class="space-y-0.5">
@for (ch of textChannels(); track ch.id) {
<button
class="w-full px-2 py-1.5 text-sm rounded flex items-center gap-2 text-left transition-colors"
[class.bg-secondary]="activeChannelId() === ch.id"
[class.text-foreground]="activeChannelId() === ch.id"
[class.font-medium]="activeChannelId() === ch.id"
[class.text-foreground/60]="activeChannelId() !== ch.id"
[class.hover:bg-secondary/60]="activeChannelId() !== ch.id"
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
(click)="selectTextChannel(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
>
<span class="text-muted-foreground text-base">#</span>
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span class="truncate">{{ ch.name }}</span>
}
</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>
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
@if (canManageChannels()) {
<button (click)="createChannel('voice')" class="text-muted-foreground hover:text-foreground transition-colors" title="Create Voice Channel">
<ng-icon name="lucidePlus" class="w-3.5 h-3.5" />
</button>
}
</div>
@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>
@for (ch of voiceChannels(); track ch.id) {
<div>
<button
class="w-full px-2 py-1.5 text-sm rounded hover:bg-secondary/60 flex items-center justify-between text-left transition-colors"
(click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
>
<span class="flex items-center gap-2 text-foreground/80">
<ng-icon name="lucideMic" class="w-4 h-4 text-muted-foreground" />
@if (renamingChannelId() === ch.id) {
<input
#renameInput
type="text"
[value]="ch.name"
(keydown.enter)="confirmRename($event)"
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
class="flex-1 bg-secondary border border-border rounded px-1 py-0.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
<span>{{ ch.name }}</span>
}
</span>
@if (voiceOccupancy(ch.id) > 0) {
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
}
</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>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1">
@for (u of voiceUsersInRoom(ch.id); 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>
}
</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>
</div>
@@ -217,7 +215,10 @@
</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="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@if (user.avatarUrl) {
<img [src]="user.avatarUrl" alt="" class="w-8 h-8 rounded-full object-cover" />
@@ -229,7 +230,16 @@
<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-1.5">
<p class="text-sm text-foreground truncate">{{ user.displayName }}</p>
@if (user.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded font-medium">Owner</span>
} @else if (user.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded font-medium">Admin</span>
} @else if (user.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
@@ -270,3 +280,80 @@
</div>
}
</aside>
<!-- Channel context menu -->
@if (showChannelMenu()) {
<div class="fixed inset-0 z-40" (click)="closeChannelMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-44 py-1" [style.left.px]="channelMenuX()" [style.top.px]="channelMenuY()">
<button (click)="resyncMessages()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Resync Messages
</button>
@if (canManageChannels()) {
<div class="border-t border-border my-1"></div>
<button (click)="startRename()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Rename Channel
</button>
<button (click)="deleteChannel()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Delete Channel
</button>
}
</div>
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
<div class="fixed inset-0 z-40" (click)="closeUserMenu()"></div>
<div class="fixed z-50 bg-card border border-border rounded-lg shadow-lg w-48 py-1" [style.left.px]="userMenuX()" [style.top.px]="userMenuY()">
@if (isAdmin()) {
<!-- Role management -->
@if (contextMenuUser()?.role === 'member') {
<button (click)="changeUserRole('moderator')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Moderator
</button>
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
}
@if (contextMenuUser()?.role === 'moderator') {
<button (click)="changeUserRole('admin')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Promote to Admin
</button>
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
}
@if (contextMenuUser()?.role === 'admin') {
<button (click)="changeUserRole('member')" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-foreground">
Demote to Member
</button>
}
<div class="border-t border-border my-1"></div>
<button (click)="kickUserAction()" class="w-full text-left px-3 py-1.5 text-sm hover:bg-secondary transition-colors text-destructive">
Kick User
</button>
} @else {
<div class="px-3 py-1.5 text-sm text-muted-foreground">No actions available</div>
}
</div>
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
<div class="fixed inset-0 z-40 bg-black/30" (click)="cancelCreateChannel()"></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-[320px]">
<div class="p-4">
<h4 class="font-semibold text-foreground mb-3">Create {{ createChannelType() === 'text' ? 'Text' : 'Voice' }} Channel</h4>
<input
type="text"
[(ngModel)]="newChannelName"
placeholder="Channel name"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary text-sm"
(keydown.enter)="confirmCreateChannel()"
/>
</div>
<div class="flex gap-2 p-3 border-t border-border">
<button (click)="cancelCreateChannel()" class="flex-1 px-3 py-2 bg-secondary text-foreground rounded-lg hover:bg-secondary/80 transition-colors text-sm">Cancel</button>
<button (click)="confirmCreateChannel()" class="flex-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors text-sm">Create</button>
</div>
</div>
}