Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View 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()"
/>
}

View 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
};
}
}