Rework design part 1
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user