Rework design part 1

This commit is contained in:
2026-04-01 19:31:00 +02:00
parent fed270d28d
commit 65b9419869
15 changed files with 324 additions and 222 deletions

View File

@@ -1,15 +1,15 @@
<div class="h-screen bg-background text-foreground flex">
<div class="workspace-bright-theme relative flex h-screen overflow-hidden bg-background text-foreground">
<!-- Global left servers rail always visible -->
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
<aside class="w-16 flex-shrink-0 bg-transparent">
<app-servers-rail class="h-full" />
</aside>
<main class="flex-1 min-w-0 relative overflow-hidden">
<main class="relative min-w-0 flex-1 overflow-hidden bg-background">
<!-- Custom draggable title bar -->
<app-title-bar />
@if (desktopUpdateState().restartRequired) {
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-md border border-border bg-card p-4 shadow-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
@@ -22,7 +22,7 @@
<button
type="button"
(click)="openUpdatesSettings()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
class="inline-flex items-center rounded-md border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Update settings
</button>
@@ -30,7 +30,7 @@
<button
type="button"
(click)="restartToApplyUpdate()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart now
</button>

View File

@@ -184,7 +184,7 @@
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
class="chat-textarea w-full rounded-md border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-dashed]="dragActive()"
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
@@ -195,7 +195,7 @@
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md border-2 border-dashed border-primary bg-primary/5"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>

View File

@@ -1,12 +1,11 @@
@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">
<div class="fixed bottom-4 right-4 z-50 border border-border bg-card shadow-lg">
<div class="flex items-center gap-2 p-2">
<!-- Back to server button -->
<button
(click)="navigateToServer()"
type="button"
class="flex items-center gap-1.5 px-2 py-1 bg-primary/10 hover:bg-primary/20 text-primary rounded-lg transition-colors"
class="flex items-center gap-1.5 rounded-md bg-secondary px-2 py-1 text-foreground transition-colors hover:bg-secondary/80"
title="Back to {{ voiceSession()?.serverName }}"
>
<ng-icon
@@ -20,7 +19,7 @@
alt=""
/>
} @else {
<div class="w-5 h-5 rounded bg-primary/20 flex items-center justify-center text-[10px] font-semibold">
<div class="flex h-5 w-5 items-center justify-center rounded-sm bg-muted text-[10px] font-semibold">
{{ voiceSession()?.serverName?.charAt(0)?.toUpperCase() || '?' }}
</div>
}
@@ -33,7 +32,7 @@
</div>
<!-- Divider -->
<div class="w-px h-6 bg-border"></div>
<div class="h-6 w-px bg-border"></div>
<!-- Voice controls -->
<div class="flex items-center gap-1">
@@ -81,7 +80,7 @@
<button
(click)="disconnect()"
type="button"
class="w-7 h-7 inline-flex items-center justify-center bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors"
class="inline-flex h-7 w-7 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
title="Disconnect"
>
<ng-icon

View File

@@ -219,57 +219,57 @@ export class FloatingVoiceControlsComponent implements OnInit {
/** Return the CSS classes for the compact control button based on active state. */
getCompactButtonClass(isActive: boolean): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
const base = 'inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors';
if (isActive) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
}
/** Return the CSS classes for the compact screen-share button. */
getCompactScreenShareClass(): string {
const base = 'w-7 h-7 inline-flex items-center justify-center rounded-lg transition-colors';
const base = 'inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
return base + ' border-primary/20 bg-primary/10 text-primary hover:bg-primary/15';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
}
/** Return the CSS classes for the mute toggle button. */
getMuteButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
if (this.isMuted()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
}
/** Return the CSS classes for the deafen toggle button. */
getDeafenButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
if (this.isDeafened()) {
return base + ' bg-destructive/20 text-destructive hover:bg-destructive/30';
return base + ' border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
}
/** Return the CSS classes for the screen-share toggle button. */
getScreenShareButtonClass(): string {
const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors';
const base = 'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors';
if (this.isScreenSharing()) {
return base + ' bg-primary/20 text-primary hover:bg-primary/30';
return base + ' border-primary/20 bg-primary/10 text-primary hover:bg-primary/15';
}
return base + ' bg-secondary text-foreground hover:bg-secondary/80';
return base + ' border-border bg-card text-foreground hover:bg-secondary/70';
}
private syncScreenShareSettings(): void {

View File

@@ -1,7 +1,10 @@
<div class="bg-card border-border p-4">
<div
class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"
[style.height]="showConnectionError() ? null : 'calc(100px - 0.75rem)'"
>
<!-- Connection Error Banner -->
@if (showConnectionError()) {
<div class="mb-3 p-2 bg-destructive/20 border border-destructive/30 rounded-lg flex items-center gap-2">
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-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
@@ -15,7 +18,7 @@
}
<!-- User Info -->
<div class="flex items-center gap-3 mb-4">
<div class="mb-2 flex items-center gap-3">
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
size="sm"
@@ -24,15 +27,15 @@
<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>
@if (showConnectionError() || isConnected()) {
<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>
}
</p>
}
</div>
<div class="flex items-center gap-1">
<app-debug-console
@@ -42,7 +45,7 @@
<button
type="button"
(click)="toggleSettings()"
class="p-2 hover:bg-secondary rounded-lg transition-colors"
class="rounded-md p-2 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideSettings"
@@ -53,7 +56,7 @@
</div>
<!-- Voice Controls -->
<div class="flex items-center justify-center gap-2">
<div class="mt-auto flex items-center justify-center gap-2">
@if (isConnected()) {
<!-- Mute Toggle -->
<button
@@ -128,7 +131,7 @@
<button
type="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"
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
>
<ng-icon
name="lucidePhoneOff"

View File

@@ -585,45 +585,45 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
getMuteButtonClass(): string {
const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
if (this.isMuted()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
}
getDeafenButtonClass(): string {
const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
if (this.isDeafened()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
return `${base} border-destructive/30 bg-destructive/10 text-destructive hover:bg-destructive/15`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
}
getCameraButtonClass(): string {
const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
if (this.isCameraEnabled()) {
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
}
getScreenShareButtonClass(): string {
const base =
'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
'inline-flex h-10 w-10 items-center justify-center rounded-md border transition-colors disabled:cursor-not-allowed disabled:opacity-50';
if (this.isScreenSharing()) {
return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
return `${base} border-primary/20 bg-primary/10 text-primary hover:bg-primary/15`;
}
return `${base} bg-secondary text-foreground hover:bg-secondary/80`;
return `${base} border-border bg-card text-foreground hover:bg-secondary/70`;
}
}

View File

@@ -1,28 +1,16 @@
<div class="h-full flex flex-col bg-background">
<div class="flex h-full flex-col bg-background">
@if (currentRoom()) {
<!-- Channel header bar -->
@if (!isVoiceWorkspaceExpanded()) {
<div class="h-12 flex items-center gap-2 px-4 border-b border-border bg-card flex-shrink-0">
<ng-icon
[name]="isVoiceWorkspaceExpanded() ? 'lucideMonitor' : 'lucideHash'"
class="w-4 h-4 text-muted-foreground"
/>
<span class="font-medium text-foreground text-sm">{{ headerTitle() }}</span>
@if (isVoiceWorkspaceExpanded()) {
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.2em] text-primary">
Voice streams
</span>
}
<div class="flex-1"></div>
</div>
}
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<div class="flex min-h-0 flex-1 overflow-hidden">
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-r border-border bg-card">
<app-rooms-side-panel
panelMode="channels"
class="block h-full w-full"
/>
</aside>
<!-- Chat Area -->
<main class="relative flex-1 min-w-0">
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
@if (!isVoiceWorkspaceExpanded()) {
@if (hasTextChannels()) {
<div class="h-full overflow-hidden">
@@ -45,20 +33,23 @@
<app-voice-workspace />
</main>
<!-- Sidebar always visible -->
<aside class="w-80 flex-shrink-0 border-l border-border">
<app-rooms-side-panel class="h-full" />
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-l border-border bg-card">
<app-rooms-side-panel
panelMode="users"
[showVoiceControls]="false"
class="block h-full w-full"
/>
</aside>
</div>
} @else {
<!-- No Room Selected -->
<div class="flex-1 flex items-center justify-center">
<div class="flex flex-1 items-center justify-center bg-background px-6">
<div class="text-center text-muted-foreground">
<ng-icon
name="lucideHash"
class="w-16 h-16 mx-auto mb-4 opacity-30"
class="mx-auto mb-4 h-16 w-16 opacity-30"
/>
<h2 class="text-xl font-medium mb-2">No room selected</h2>
<h2 class="mb-2 text-xl font-medium">No room selected</h2>
<p class="text-sm">Select or create a room to start chatting</p>
</div>
</div>

View File

@@ -24,12 +24,10 @@ import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.comp
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels
selectTextChannels
} from '../../../store/rooms/rooms.selectors';
import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
@Component({
@@ -59,47 +57,18 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
* Main chat room view combining the messages panel, side panels, and admin controls.
*/
export class ChatRoomComponent {
private store = inject(Store);
private settingsModal = inject(SettingsModalService);
private readonly store = inject(Store);
private readonly settingsModal = inject(SettingsModalService);
private voiceWorkspace = inject(VoiceWorkspaceService);
showMenu = signal(false);
showAdminPanel = signal(false);
currentRoom = this.store.selectSignal(selectCurrentRoom);
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
currentUser = this.store.selectSignal(selectCurrentUser);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
hasTextChannels = computed(() => this.textChannels().length > 0);
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
if (textChannels.length === 0) {
return 'No text channels';
}
const id = this.activeChannelId();
const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0];
return activeChannel ? activeChannel.name : id;
});
connectedVoiceChannelName = computed(() => {
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
return voiceChannel?.name || 'Voice Lounge';
});
headerTitle = computed(() =>
this.isVoiceWorkspaceExpanded()
? this.connectedVoiceChannelName()
: this.activeTextChannelName()
);
/** Open the settings modal to the Server admin page for the current room. */
toggleAdminPanel() {
const room = this.currentRoom();

View File

@@ -1,54 +1,45 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/cyclomatic-complexity -->
<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">{{ knownUserCount() }}</span>
</button>
</div>
<aside class="flex h-full min-h-0 flex-col bg-card">
<div class="border-b border-border px-3 py-3">
@if (panelMode() === 'channels') {
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name || 'Server' }}</p>
<p class="truncate text-xs text-muted-foreground">{{ currentRoom()?.description || 'Choose a text channel or jump into voice.' }}</p>
</div>
</div>
} @else {
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-muted-foreground">
<ng-icon
name="lucideUsers"
class="h-4 w-4"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-foreground">{{ knownUserCount() }} members</p>
<p class="text-xs text-muted-foreground">{{ onlineRoomUsers().length + (currentUser() ? 1 : 0) }} online right now</p>
</div>
</div>
}
</div>
<!-- Channels View -->
@if (activeTab() === 'channels') {
@if (panelMode() === 'channels') {
<div class="flex-1 overflow-auto">
<!-- Text Channels -->
<div class="p-3">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Text Channels</h4>
<section class="px-2 py-3">
<div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Text Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('text')"
class="text-muted-foreground hover:text-foreground transition-colors"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Create Text Channel"
>
<ng-icon
@@ -58,15 +49,15 @@
</button>
}
</div>
<div class="space-y-0.5">
<div class="space-y-1">
@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="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm 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:bg-secondary/70]="activeChannelId() !== ch.id"
[class.hover:text-foreground/80]="activeChannelId() !== ch.id"
(click)="selectTextChannel(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
@@ -83,7 +74,7 @@
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
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"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
@@ -98,16 +89,16 @@
</button>
}
</div>
</div>
</section>
<!-- Voice Channels -->
<div class="p-3 pt-0">
<div class="flex items-center justify-between mb-2 px-1">
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium">Voice Channels</h4>
<section class="border-t border-border px-2 py-3">
<div class="mb-2 flex items-center justify-between px-1">
<h4 class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">Voice Channels</h4>
@if (canManageChannels()) {
<button
(click)="createChannel('voice')"
class="text-muted-foreground hover:text-foreground transition-colors"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Create Voice Channel"
>
<ng-icon
@@ -118,7 +109,7 @@
}
</div>
@if (!voiceEnabled()) {
<p class="text-sm text-muted-foreground px-2 py-2">Voice is disabled by host</p>
<p class="px-2 py-2 text-sm text-muted-foreground">Voice is disabled by host</p>
}
<div class="space-y-1">
@for (ch of voiceChannels(); track ch.id) {
@@ -132,10 +123,10 @@
(drop)="onVoiceChannelDrop($event, ch.id)"
>
<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"
class="flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-secondary/60"
(click)="joinVoice(ch.id)"
(contextmenu)="openChannelContextMenu($event, ch)"
[class.bg-secondary/40]="isCurrentRoom(ch.id)"
[class.bg-secondary]="isCurrentRoom(ch.id)"
[disabled]="!voiceEnabled()"
[title]="isCurrentRoom(ch.id) ? 'Open stream workspace' : 'Join voice channel'"
>
@@ -155,7 +146,7 @@
(keydown.escape)="cancelRename()"
(blur)="confirmRename($event)"
(input)="clearChannelNameError()"
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"
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
(click)="$event.stopPropagation()"
/>
} @else {
@@ -173,10 +164,10 @@
</button>
<!-- Voice users connected to this channel -->
@if (voiceUsersInRoom(ch.id).length > 0) {
<div class="ml-5 mt-1 space-y-1">
<div class="ml-5 mt-1 space-y-1 border-l border-border pb-1 pl-2">
@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"
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
[class.cursor-pointer]="canDragVoiceUser(u)"
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
[draggable]="canDragVoiceUser(u)"
@@ -239,18 +230,18 @@
</div>
}
</div>
</div>
</section>
</div>
}
<!-- Users View -->
@if (activeTab() === 'users') {
<div class="flex-1 overflow-auto p-3">
@if (panelMode() === 'users') {
<div class="flex-1 overflow-auto px-2 py-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="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2">
<div class="relative">
<app-user-avatar
[name]="currentUser()?.displayName || '?'"
@@ -296,7 +287,7 @@
<div class="space-y-1">
@for (user of onlineRoomUsers(); track user.id) {
<div
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-secondary/40 group/user"
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50"
(contextmenu)="openUserContextMenu($event, user)"
>
<div class="relative">
@@ -354,7 +345,7 @@
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">Offline - {{ offlineRoomMembers().length }}</h4>
<div class="space-y-1">
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
<div class="flex items-center gap-2 px-2 py-1.5 rounded opacity-80">
<div class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80">
<div class="relative">
<app-user-avatar
[name]="member.displayName"
@@ -392,15 +383,18 @@
}
<!-- Voice controls pinned to sidebar bottom (hidden when floating controls visible) -->
@if (voiceEnabled()) {
<div [class.invisible]="showFloatingControls()">
@if (panelMode() === 'channels' && showVoiceControls() && voiceEnabled()) {
<div
class="border-t border-border px-2 py-3"
[class.invisible]="showFloatingControls()"
>
<app-voice-controls />
</div>
}
</aside>
<!-- Channel context menu -->
@if (showChannelMenu()) {
@if (panelMode() === 'channels' && showChannelMenu()) {
<app-context-menu
[x]="channelMenuX()"
[y]="channelMenuY()"
@@ -440,7 +434,7 @@
}
<!-- User context menu (kick / role management) -->
@if (showUserMenu()) {
@if (panelMode() === 'users' && showUserMenu()) {
<app-context-menu
[x]="userMenuX()"
[y]="userMenuY()"
@@ -497,7 +491,7 @@
}
<!-- Per-user volume context menu -->
@if (showVolumeMenu()) {
@if (panelMode() === 'channels' && showVolumeMenu()) {
<app-user-volume-menu
[x]="volumeMenuX()"
[y]="volumeMenuY()"
@@ -508,7 +502,7 @@
}
<!-- Create channel dialog -->
@if (showCreateChannelDialog()) {
@if (panelMode() === 'channels' && showCreateChannelDialog()) {
<app-confirm-dialog
[title]="'Create ' + (createChannelType() === 'text' ? 'Text' : 'Voice') + ' Channel'"
confirmLabel="Create"
@@ -519,7 +513,7 @@
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"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
(keydown.enter)="confirmCreateChannel()"

View File

@@ -3,6 +3,7 @@ import {
Component,
inject,
computed,
input,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -58,7 +59,7 @@ import {
} from '../../../shared-kernel';
import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users';
type PanelMode = 'channels' | 'users';
@Component({
selector: 'app-rooms-side-panel',
@@ -100,7 +101,8 @@ export class RoomsSidePanelComponent {
private voicePlayback = inject(VoicePlaybackService);
voiceActivity = inject(VoiceActivityService);
activeTab = signal<TabView>('channels');
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
showFloatingControls = this.voiceSessionService.showFloatingControls;
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
onlineUsers = this.store.selectSignal(selectOnlineUsers);

View File

@@ -1,8 +1,8 @@
<nav class="h-full w-16 flex flex-col items-center gap-3 py-3 border-r border-border bg-card relative">
<nav class="relative flex h-full w-full flex-col items-center gap-2 border-r border-border bg-secondary/35 px-2 py-3">
<!-- Create button -->
<button
type="button"
class="w-10 h-10 rounded-2xl flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
class="flex h-10 w-10 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
title="Create Server"
(click)="createServer()"
>
@@ -13,22 +13,21 @@
</button>
<!-- Saved servers icons -->
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto">
@for (room of visibleSavedRooms(); track room.id) {
<div class="relative flex w-full justify-center pl-2">
<div class="relative flex w-full justify-center">
@if (isSelectedRoom(room)) {
<span
aria-hidden="true"
class="absolute left-1 top-1/2 h-7 w-1 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_10px_rgba(59,130,246,0.45)]"
class="pointer-events-none absolute left-0 top-1/2 h-10 w-1 -translate-y-1/2 rounded-l-full bg-primary"
></span>
}
<button
type="button"
class="relative w-10 h-10 flex-shrink-0 rounded-2xl border border-border hover:border-primary/60 hover:shadow-sm transition-all"
[class.border-primary]="isSelectedRoom(room)"
[class.shadow-[0_0_0_1px_rgba(59,130,246,0.45),0_10px_20px_rgba(15,23,42,0.25)]]="isSelectedRoom(room)"
[class.scale-105]="isSelectedRoom(room)"
class="relative z-10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md border border-transparent bg-card transition-colors hover:border-border hover:bg-card"
[class.border-primary/30]="isSelectedRoom(room)"
[class.bg-primary/10]="isSelectedRoom(room)"
[title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
(click)="joinSavedRoom(room)"
@@ -39,17 +38,18 @@
<img
[ngSrc]="room.icon"
[alt]="room.name"
class="w-full h-full object-cover"
class="h-full w-full object-cover"
/>
} @else {
<div
class="w-full h-full flex items-center justify-center bg-secondary transition-colors"
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
[class.bg-primary/15]="isSelectedRoom(room)"
>
<span
class="text-sm font-semibold text-muted-foreground transition-colors"
[class.text-foreground]="isSelectedRoom(room)"
>{{ initial(room.name) }}</span>
>{{ initial(room.name) }}</span
>
</div>
}
</div>
@@ -59,6 +59,23 @@
{{ formatUnreadCount(roomUnreadCount(room.id)) }}
</span>
}
@if (voicePresenceCount(room.id) > 0) {
<span
class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm ring-2 ring-card"
[title]="voicePresenceCount(room.id) + (voicePresenceCount(room.id) === 1 ? ' user in voice' : ' users in voice')"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-2.5 w-2.5 fill-current"
>
<path
d="M6.25 4.1a.75.75 0 0 1 1.25.57v6.66a.75.75 0 0 1-1.25.57L3.68 9.6H2.25A1.25 1.25 0 0 1 1 8.35v-.7c0-.69.56-1.25 1.25-1.25h1.43L6.25 4.1Zm4.1 1.35a.75.75 0 0 1 1.05.1 4.2 4.2 0 0 1 0 4.9.75.75 0 0 1-1.15-.96 2.7 2.7 0 0 0 0-2.98.75.75 0 0 1 .1-1.06Z"
/>
</svg>
</span>
}
</button>
</div>
}

View File

@@ -25,7 +25,10 @@ import {
import { Room, User } from '../../shared-kernel';
import { VoiceSessionFacade } from '../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors';
import {
selectCurrentUser,
selectOnlineUsers
} from '../../store/users/users.selectors';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../infrastructure/persistence';
import { NotificationsFacade } from '../../domains/notifications';
@@ -74,6 +77,7 @@ export class ServersRailComponent {
contextRoom = signal<Room | null>(null);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
@@ -82,6 +86,46 @@ export class ServersRailComponent {
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
voicePresenceByRoom = computed(() => {
const presence: Record<string, number> = {};
const seenByRoom = new Map<string, Set<string>>();
const addVoicePresence = (user: User | null | undefined): void => {
if (!user) {
return;
}
const voiceState = user?.voiceState;
const roomId = voiceState?.serverId;
if (!voiceState?.isConnected || !roomId) {
return;
}
const userKey = user.oderId || user.id;
let seenUsers = seenByRoom.get(roomId);
if (!seenUsers) {
seenUsers = new Set<string>();
seenByRoom.set(roomId, seenUsers);
}
if (seenUsers.has(userKey)) {
return;
}
seenUsers.add(userKey);
presence[roomId] = (presence[roomId] ?? 0) + 1;
};
for (const user of this.onlineUsers()) {
addVoicePresence(user);
}
addVoicePresence(this.currentUser());
return presence;
});
constructor() {
effect(() => {
@@ -236,6 +280,10 @@ export class ServersRailComponent {
return this.notifications.roomUnreadCount(roomId);
}
voicePresenceCount(roomId: string): number {
return this.voicePresenceByRoom()[roomId] ?? 0;
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}

View File

@@ -1,5 +1,5 @@
<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"
class="fixed left-16 right-0 top-0 z-50 flex h-10 items-center justify-between border-b border-border bg-card px-4 select-none"
style="-webkit-app-region: drag"
>
<div
@@ -11,7 +11,7 @@
name="lucideHash"
class="w-5 h-5 text-muted-foreground"
/>
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
<span class="truncate text-sm font-semibold text-foreground">{{ roomContextTitle() }}</span>
@if (showRoomCompatibilityNotice()) {
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
@@ -29,9 +29,9 @@
</span>
}
@if (roomDescription()) {
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
{{ roomDescription() }}
@if (roomContextMeta()) {
<span class="hidden truncate border-l border-border/70 pl-2 text-xs text-muted-foreground md:inline">
{{ roomContextMeta() }}
</span>
}
} @else {
@@ -51,7 +51,7 @@
>
<button
type="button"
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
class="grid h-8 place-items-center rounded-md px-3 text-sm text-foreground transition-colors hover:bg-secondary"
[class.hidden]="isAuthed()"
(click)="goLogin()"
title="Login"
@@ -62,7 +62,7 @@
<button
type="button"
(click)="toggleMenu()"
class="ml-2 p-2 hover:bg-secondary rounded"
class="ml-2 rounded-md p-2 transition-colors hover:bg-secondary"
title="Menu"
>
<ng-icon
@@ -72,13 +72,13 @@
</button>
<!-- Anchored dropdown under the menu button -->
@if (showMenu()) {
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
<div class="absolute right-0 top-full z-50 mt-2 w-64 rounded-md border border-border bg-popover p-1 shadow-lg">
@if (inRoom()) {
<button
type="button"
(click)="createInviteLink()"
[disabled]="creatingInvite()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
@if (creatingInvite()) {
Creating Invite Link…
@@ -89,22 +89,22 @@
<button
type="button"
(click)="leaveServer()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Leave Server
</button>
}
<div
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
class="px-3 py-2 text-xs leading-5 text-muted-foreground"
[class.hidden]="!inviteStatus()"
>
{{ inviteStatus() }}
</div>
<div class="border-t border-border"></div>
<div class="mx-2 my-1 h-px bg-border"></div>
<button
type="button"
(click)="logout()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
>
Logout
</button>
@@ -113,7 +113,7 @@
@if (isElectron()) {
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Minimize"
(click)="minimize()"
>
@@ -124,7 +124,7 @@
</button>
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
title="Maximize"
(click)="maximize()"
>
@@ -135,7 +135,7 @@
</button>
<button
type="button"
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-destructive/10"
title="Close"
(click)="close()"
>

View File

@@ -21,6 +21,9 @@ import {
import { Router } from '@angular/router';
import {
selectCurrentRoom,
selectActiveChannelId,
selectTextChannels,
selectVoiceChannels,
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../store/rooms/rooms.selectors';
@@ -33,6 +36,7 @@ import { PlatformService } from '../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../shared-kernel';
import { VoiceWorkspaceService } from '../../domains/voice-session';
@Component({
selector: 'app-title-bar',
@@ -63,6 +67,7 @@ export class TitleBarComponent {
private router = inject(Router);
private webrtc = inject(RealtimeSessionFacade);
private platform = inject(PlatformService);
private voiceWorkspace = inject(VoiceWorkspaceService);
private getWindowControlsApi() {
return this.electronBridge.getApi();
@@ -78,11 +83,62 @@ export class TitleBarComponent {
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
isAuthed = computed(() => !!this.currentUser());
currentRoom = this.store.selectSignal(selectCurrentRoom);
activeChannelId = this.store.selectSignal(selectActiveChannelId);
textChannels = this.store.selectSignal(selectTextChannels);
voiceChannels = this.store.selectSignal(selectVoiceChannels);
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
inRoom = computed(() => !!this.currentRoom());
roomName = computed(() => this.currentRoom()?.name || '');
roomDescription = computed(() => this.currentRoom()?.description || '');
activeTextChannelName = computed(() => {
const textChannels = this.textChannels();
if (textChannels.length === 0) {
return 'No text channels';
}
const id = this.activeChannelId();
const activeChannel = textChannels.find((channel) => channel.id === id) ?? textChannels[0];
return activeChannel ? activeChannel.name : id;
});
connectedVoiceChannelName = computed(() => {
const voiceChannelId = this.currentUser()?.voiceState?.roomId;
const voiceChannel = this.voiceChannels().find((channel) => channel.id === voiceChannelId);
return voiceChannel?.name || 'Voice Lounge';
});
roomContextTitle = computed(() => {
const room = this.currentRoom();
if (!room) {
return '';
}
if (this.isVoiceWorkspaceExpanded()) {
return `${room.name} / ${this.connectedVoiceChannelName()}`;
}
if (this.textChannels().length === 0) {
return room.name;
}
return `${room.name} / #${this.activeTextChannelName()}`;
});
roomContextMeta = computed(() => {
if (!this.currentRoom()) {
return '';
}
const parts = [`${this.textChannels().length} text`];
if (this.voiceChannels().length > 0) {
parts.push(`${this.voiceChannels().length} voice`);
}
return parts.join(' | ');
});
showRoomCompatibilityNotice = computed(() =>
this.inRoom() && !!this.signalServerCompatibilityError()
);

View File

@@ -64,3 +64,26 @@
-ms-overflow-style: none;
scrollbar-width: none;
}
.workspace-bright-theme {
--background: 220 14% 92%;
--foreground: 222 16% 18%;
--card: 220 18% 97%;
--card-foreground: 222 16% 18%;
--popover: 0 0% 100%;
--popover-foreground: 222 16% 18%;
--primary: 214 72% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 88%;
--secondary-foreground: 222 16% 18%;
--muted: 220 12% 85%;
--muted-foreground: 220 10% 38%;
--accent: 220 14% 90%;
--accent-foreground: 222 16% 18%;
--destructive: 0 72% 56%;
--destructive-foreground: 0 0% 100%;
--border: 220 12% 78%;
--input: 220 12% 78%;
--ring: 214 72% 50%;
--radius: 0.375rem;
}