Move toju-app into own its folder
This commit is contained in:
171
toju-app/src/app/features/shell/title-bar.component.html
Normal file
171
toju-app/src/app/features/shell/title-bar.component.html
Normal file
@@ -0,0 +1,171 @@
|
||||
<div
|
||||
class="fixed top-0 left-16 right-0 h-10 bg-card border-b border-border flex items-center justify-between px-4 z-50 select-none"
|
||||
style="-webkit-app-region: drag"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 min-w-0 relative"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
@if (inRoom()) {
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm font-semibold text-foreground truncate">{{ roomName() }}</span>
|
||||
|
||||
@if (showRoomCompatibilityNotice()) {
|
||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||
{{ signalServerCompatibilityError() }}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (showRoomReconnectNotice()) {
|
||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||
<ng-icon
|
||||
name="lucideRefreshCw"
|
||||
class="h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
Reconnecting to signal server…
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (roomDescription()) {
|
||||
<span class="hidden md:inline text-sm text-muted-foreground border-l border-border pl-2 truncate">
|
||||
{{ roomDescription() }}
|
||||
</span>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||
[class.hidden]="!isReconnecting()"
|
||||
>Reconnecting…</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
style="-webkit-app-region: no-drag"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 h-8 grid place-items-center hover:bg-secondary rounded text-sm text-foreground"
|
||||
[class.hidden]="isAuthed()"
|
||||
(click)="goLogin()"
|
||||
title="Login"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleMenu()"
|
||||
class="ml-2 p-2 hover:bg-secondary rounded"
|
||||
title="Menu"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMenu"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<!-- Anchored dropdown under the menu button -->
|
||||
@if (showMenu()) {
|
||||
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card 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"
|
||||
>
|
||||
@if (creatingInvite()) {
|
||||
Creating Invite Link…
|
||||
} @else {
|
||||
Create Invite Link
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
(click)="leaveServer()"
|
||||
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground"
|
||||
>
|
||||
Leave Server
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||
[class.hidden]="!inviteStatus()"
|
||||
>
|
||||
{{ inviteStatus() }}
|
||||
</div>
|
||||
<div class="border-t border-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"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (isElectron()) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Minimize"
|
||||
(click)="minimize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMinus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-secondary rounded"
|
||||
title="Maximize"
|
||||
(click)="maximize()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSquare"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 grid place-items-center hover:bg-destructive/10 rounded"
|
||||
title="Close"
|
||||
(click)="close()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-4 h-4 text-destructive"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Click-away overlay to close dropdown -->
|
||||
@if (showMenu()) {
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
(click)="closeMenu()"
|
||||
(keydown.enter)="closeMenu()"
|
||||
(keydown.space)="closeMenu()"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close menu overlay"
|
||||
style="-webkit-app-region: no-drag"
|
||||
></div>
|
||||
}
|
||||
|
||||
@if (showLeaveConfirm() && currentRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="currentRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeave($event)"
|
||||
(cancelled)="cancelLeave()"
|
||||
/>
|
||||
}
|
||||
264
toju-app/src/app/features/shell/title-bar.component.ts
Normal file
264
toju-app/src/app/features/shell/title-bar.component.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucideRefreshCw
|
||||
} from '@ng-icons/lucide';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectIsSignalServerReconnecting,
|
||||
selectSignalServerCompatibilityError
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
||||
import { LeaveServerDialogComponent } from '../../shared';
|
||||
import { Room } from '../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-title-bar',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
LeaveServerDialogComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({ lucideMinus,
|
||||
lucideSquare,
|
||||
lucideX,
|
||||
lucideChevronLeft,
|
||||
lucideHash,
|
||||
lucideMenu,
|
||||
lucideRefreshCw })
|
||||
],
|
||||
templateUrl: './title-bar.component.html'
|
||||
})
|
||||
/**
|
||||
* Electron-style title bar with window controls, navigation, and server menu.
|
||||
*/
|
||||
export class TitleBarComponent {
|
||||
private store = inject(Store);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private router = inject(Router);
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private platform = inject(PlatformService);
|
||||
|
||||
private getWindowControlsApi() {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
|
||||
isElectron = computed(() => this.platform.isElectron);
|
||||
showMenuState = computed(() => false);
|
||||
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
username = computed(() => this.currentUser()?.displayName || 'Guest');
|
||||
serverName = computed(() => this.serverDirectory.activeServer()?.name || 'No Server');
|
||||
isConnected = computed(() => this.webrtc.isConnected());
|
||||
isReconnecting = computed(() => !this.webrtc.isConnected() && this.webrtc.hasEverConnected());
|
||||
isAuthed = computed(() => !!this.currentUser());
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
inRoom = computed(() => !!this.currentRoom());
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
roomDescription = computed(() => this.currentRoom()?.description || '');
|
||||
showRoomCompatibilityNotice = computed(() =>
|
||||
this.inRoom() && !!this.signalServerCompatibilityError()
|
||||
);
|
||||
showRoomReconnectNotice = computed(() =>
|
||||
this.inRoom()
|
||||
&& !this.signalServerCompatibilityError()
|
||||
&& (
|
||||
this.isSignalServerReconnecting()
|
||||
|| this.webrtc.shouldShowConnectionError()
|
||||
|| this.isReconnecting()
|
||||
)
|
||||
);
|
||||
private _showMenu = signal(false);
|
||||
showMenu = computed(() => this._showMenu());
|
||||
showLeaveConfirm = signal(false);
|
||||
inviteStatus = signal<string | null>(null);
|
||||
creatingInvite = signal(false);
|
||||
|
||||
/** Minimize the Electron window. */
|
||||
minimize() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.minimizeWindow?.();
|
||||
}
|
||||
|
||||
/** Maximize or restore the Electron window. */
|
||||
maximize() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.maximizeWindow?.();
|
||||
}
|
||||
|
||||
/** Close the Electron window. */
|
||||
close() {
|
||||
const api = this.getWindowControlsApi();
|
||||
|
||||
api?.closeWindow?.();
|
||||
}
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
private openLeaveConfirm() {
|
||||
this._showMenu.set(false);
|
||||
|
||||
if (this.currentRoom()) {
|
||||
this.showLeaveConfirm.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the server dropdown menu. */
|
||||
toggleMenu() {
|
||||
this.inviteStatus.set(null);
|
||||
this._showMenu.set(!this._showMenu());
|
||||
}
|
||||
|
||||
/** Create a new invite link for the active room and copy it to the clipboard. */
|
||||
async createInviteLink(): Promise<void> {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
|
||||
if (!room || !user || this.creatingInvite()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingInvite.set(true);
|
||||
this.inviteStatus.set('Creating invite link…');
|
||||
|
||||
try {
|
||||
const invite = await firstValueFrom(this.serverDirectory.createInvite(
|
||||
room.id,
|
||||
{
|
||||
requesterUserId: user.id,
|
||||
requesterDisplayName: user.displayName,
|
||||
requesterRole: user.role
|
||||
},
|
||||
this.toSourceSelector(room)
|
||||
));
|
||||
|
||||
await this.copyInviteLink(invite.inviteUrl);
|
||||
this.inviteStatus.set('Invite link copied to clipboard.');
|
||||
} catch (error: unknown) {
|
||||
const inviteError = error as { error?: { error?: string } };
|
||||
|
||||
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
|
||||
} finally {
|
||||
this.creatingInvite.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leave the current server and navigate to the servers list. */
|
||||
leaveServer() {
|
||||
this.openLeaveConfirm();
|
||||
}
|
||||
|
||||
/** Confirm the unified leave action and remove the server locally. */
|
||||
confirmLeave(result: { nextOwnerKey?: string }) {
|
||||
const roomId = this.currentRoom()?.id;
|
||||
|
||||
this.showLeaveConfirm.set(false);
|
||||
|
||||
if (!roomId)
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
/** Cancel the leave-server confirmation dialog. */
|
||||
cancelLeave() {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
/** Close the server dropdown menu. */
|
||||
closeMenu() {
|
||||
this._showMenu.set(false);
|
||||
}
|
||||
|
||||
/** Log out the current user, disconnect from signaling, and navigate to login. */
|
||||
logout() {
|
||||
this._showMenu.set(false);
|
||||
// Disconnect from signaling server - this broadcasts "user_left" to all
|
||||
// servers the user was a member of, so other users see them go offline.
|
||||
this.webrtc.disconnect();
|
||||
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||
} catch {}
|
||||
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
private async copyInviteLink(inviteUrl: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
|
||||
textarea.value = inviteUrl;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
const copied = document.execCommand('copy');
|
||||
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to prompt fallback */
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
window.prompt('Copy this invite link', inviteUrl);
|
||||
}
|
||||
|
||||
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
|
||||
return {
|
||||
sourceId: room.sourceId,
|
||||
sourceUrl: room.sourceUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user