feat: Response mobile layout support v1
All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
All checks were successful
Queue Release Build / prepare (push) Successful in 1m6s
Deploy Web Apps / deploy (push) Successful in 7m35s
Queue Release Build / build-windows (push) Successful in 29m57s
Queue Release Build / build-linux (push) Successful in 46m28s
Queue Release Build / finalize (push) Successful in 49s
This commit is contained in:
@@ -1,71 +1,188 @@
|
||||
<div class="flex h-full flex-col bg-background">
|
||||
@if (currentRoom()) {
|
||||
<div
|
||||
class="grid min-h-0 flex-1 overflow-hidden"
|
||||
[ngStyle]="roomLayoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="chatRoomChannelsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="channelsPanelLayoutStyles()"
|
||||
@if (isMobile()) {
|
||||
<!-- Mobile: Swiper-driven page stack (channels -> main -> members) -->
|
||||
<swiper-container
|
||||
#swiperEl
|
||||
class="block min-h-0 w-full flex-1"
|
||||
slides-per-view="1"
|
||||
space-between="0"
|
||||
initial-slide="0"
|
||||
threshold="10"
|
||||
resistance-ratio="0"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="channels"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
appThemeNode="chatRoomMainPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="mainPanelLayoutStyles()"
|
||||
>
|
||||
@if (!isVoiceWorkspaceExpanded()) {
|
||||
@if (hasTextChannels()) {
|
||||
<div class="h-full overflow-hidden">
|
||||
<app-chat-messages />
|
||||
<swiper-slide class="block h-full w-full">
|
||||
<div class="flex h-full w-full min-h-0 overflow-hidden">
|
||||
<app-servers-rail class="block h-full shrink-0" />
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border bg-card">
|
||||
<app-rooms-side-panel
|
||||
panelMode="channels"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
class="flex h-full items-center justify-center px-6"
|
||||
>
|
||||
<div class="max-w-md text-center text-muted-foreground">
|
||||
<div
|
||||
data-theme-slot="icon"
|
||||
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||
></div>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
|
||||
<swiper-slide class="block h-full w-full">
|
||||
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setMobilePage('channels')"
|
||||
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Back to channels"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||
name="lucideChevronLeft"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<h2
|
||||
data-theme-slot="text"
|
||||
class="mb-2 text-xl font-medium text-foreground"
|
||||
>
|
||||
No text channels
|
||||
</h2>
|
||||
<p class="text-sm">There are no existing text channels currently.</p>
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
@if (activeChannel(); as channel) {
|
||||
<p class="flex min-w-0 items-center gap-1 truncate text-sm font-semibold text-foreground">
|
||||
@if (channel.type === 'text') {
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
<span class="truncate">{{ channel.name }}</span>
|
||||
</p>
|
||||
} @else {
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ currentRoom()?.name }}</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="setMobilePage('members')"
|
||||
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Show members"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
<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">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center px-6">
|
||||
<div class="max-w-md text-center text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||
/>
|
||||
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
|
||||
<p class="text-sm">There are no existing text channels currently.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<aside
|
||||
appThemeNode="chatRoomMembersPanel"
|
||||
class="flex min-h-0 overflow-hidden border-l border-border bg-card"
|
||||
[ngStyle]="membersPanelLayoutStyles()"
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
|
||||
<swiper-slide class="block h-full w-full">
|
||||
<div class="flex h-full w-full min-h-0 flex-col overflow-hidden bg-card">
|
||||
<div class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
(click)="setMobilePage('main')"
|
||||
class="grid h-11 w-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Back to chat"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideChevronLeft"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
<p class="truncate text-sm font-semibold text-foreground">Members</p>
|
||||
</div>
|
||||
<app-rooms-side-panel
|
||||
panelMode="users"
|
||||
[showVoiceControls]="false"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</swiper-slide>
|
||||
</swiper-container>
|
||||
} @else {
|
||||
<!-- Desktop: theme-driven 3-pane grid layout -->
|
||||
<div
|
||||
class="grid min-h-0 flex-1 overflow-hidden"
|
||||
[ngStyle]="roomLayoutStyles()"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="users"
|
||||
[showVoiceControls]="false"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
<aside
|
||||
appThemeNode="chatRoomChannelsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="channelsPanelLayoutStyles()"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="channels"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
appThemeNode="chatRoomMainPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="mainPanelLayoutStyles()"
|
||||
>
|
||||
@if (!isVoiceWorkspaceExpanded()) {
|
||||
@if (hasTextChannels()) {
|
||||
<div class="h-full overflow-hidden">
|
||||
<app-chat-messages />
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
class="flex h-full items-center justify-center px-6"
|
||||
>
|
||||
<div class="max-w-md text-center text-muted-foreground">
|
||||
<div
|
||||
data-theme-slot="icon"
|
||||
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||
></div>
|
||||
<ng-icon
|
||||
name="lucideHash"
|
||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||
/>
|
||||
<h2
|
||||
data-theme-slot="text"
|
||||
class="mb-2 text-xl font-medium text-foreground"
|
||||
>
|
||||
No text channels
|
||||
</h2>
|
||||
<p class="text-sm">There are no existing text channels currently.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<app-voice-workspace />
|
||||
</main>
|
||||
|
||||
<aside
|
||||
appThemeNode="chatRoomMembersPanel"
|
||||
class="flex min-h-0 overflow-hidden border-l border-border bg-card"
|
||||
[ngStyle]="membersPanelLayoutStyles()"
|
||||
>
|
||||
<app-rooms-side-panel
|
||||
panelMode="users"
|
||||
[showVoiceControls]="false"
|
||||
class="block h-full w-full"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div
|
||||
appThemeNode="chatRoomEmptyState"
|
||||
@@ -91,3 +208,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
Component,
|
||||
ElementRef,
|
||||
NgZone,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
signal,
|
||||
viewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -21,13 +26,37 @@ import {
|
||||
import { ChatMessagesComponent } from '../../../domains/chat/feature/chat-messages/chat-messages.component';
|
||||
import { RoomsSidePanelComponent } from '../rooms-side-panel/rooms-side-panel.component';
|
||||
import { VoiceWorkspaceComponent } from '../voice-workspace/voice-workspace.component';
|
||||
import { ServersRailComponent } from '../../servers/servers-rail/servers-rail.component';
|
||||
|
||||
import { selectCurrentRoom, selectTextChannels } from '../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
selectActiveChannelId,
|
||||
selectCurrentRoom,
|
||||
selectTextChannels
|
||||
} from '../../../store/rooms/rooms.selectors';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
|
||||
/** Mobile-only page identifier within the chat-room view. */
|
||||
export type ChatRoomMobilePage = 'channels' | 'main' | 'members';
|
||||
|
||||
const PAGE_TO_INDEX: Record<ChatRoomMobilePage, number> = {
|
||||
channels: 0,
|
||||
main: 1,
|
||||
members: 2
|
||||
};
|
||||
const INDEX_TO_PAGE: ChatRoomMobilePage[] = [
|
||||
'channels',
|
||||
'main',
|
||||
'members'
|
||||
];
|
||||
|
||||
interface SwiperElement extends HTMLElement {
|
||||
swiper?: { activeIndex: number; slideTo: (index: number, speed?: number) => void };
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-room',
|
||||
standalone: true,
|
||||
@@ -37,6 +66,7 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
ChatMessagesComponent,
|
||||
VoiceWorkspaceComponent,
|
||||
RoomsSidePanelComponent,
|
||||
ServersRailComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -50,22 +80,52 @@ import { ThemeNodeDirective, ThemeService } from '../../../domains/theme';
|
||||
lucideChevronLeft
|
||||
})
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
templateUrl: './chat-room.component.html'
|
||||
})
|
||||
/**
|
||||
* Main chat room view combining the messages panel, side panels, and admin controls.
|
||||
*
|
||||
* On desktop the three panels (channels | main | members) are rendered side-by-side via the
|
||||
* theme-driven grid layout. On mobile the same panels are rendered as Swiper slides
|
||||
* (channels -> main -> members) so the user can swipe between them. `mobilePage`
|
||||
* remains the source of truth and stays in sync with the active slide.
|
||||
*/
|
||||
export class ChatRoomComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly settingsModal = inject(SettingsModalService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private readonly zone = inject(NgZone);
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private lastSeenChannelId: string | null = null;
|
||||
private lastSeenRoomId: string | null = null;
|
||||
private swiperListenerAttached: SwiperElement | null = null;
|
||||
showMenu = signal(false);
|
||||
showAdminPanel = signal(false);
|
||||
|
||||
/** Active page within the mobile single-pane navigation flow. Ignored on desktop. */
|
||||
readonly mobilePage = signal<ChatRoomMobilePage>('channels');
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
readonly swiperRef = viewChild<ElementRef<SwiperElement>>('swiperEl');
|
||||
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
|
||||
textChannels = this.store.selectSignal(selectTextChannels);
|
||||
activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
/**
|
||||
* Resolved channel object for `activeChannelId`. Used on mobile to title the main pane
|
||||
* with the selected channel name instead of the room name.
|
||||
*/
|
||||
activeChannel = computed(() => {
|
||||
const id = this.activeChannelId();
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.currentRoom()?.channels?.find((channel) => channel.id === id) ?? null;
|
||||
});
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||
@@ -73,6 +133,82 @@ export class ChatRoomComponent {
|
||||
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
||||
|
||||
constructor() {
|
||||
// When entering a server, always land on the channels list ("first page") on mobile, even
|
||||
// if a default channel is pre-selected. Once inside the server, *changing* channels
|
||||
// (i.e. user taps a channel in the list) advances to the main pane so the user sees the chat.
|
||||
effect(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id ?? null;
|
||||
const isRoomChange = roomId !== this.lastSeenRoomId;
|
||||
|
||||
this.lastSeenRoomId = roomId;
|
||||
|
||||
if (!this.isMobile()) {
|
||||
this.lastSeenChannelId = channelId ?? null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRoomChange) {
|
||||
// New server: show the channels list and don't auto-advance.
|
||||
this.lastSeenChannelId = channelId ?? null;
|
||||
this.mobilePage.set('channels');
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelId && channelId !== this.lastSeenChannelId) {
|
||||
this.mobilePage.set('main');
|
||||
}
|
||||
|
||||
this.lastSeenChannelId = channelId ?? null;
|
||||
});
|
||||
|
||||
// Mirror `mobilePage` into the Swiper instance so back-button taps and the
|
||||
// channel-selected auto-advance actually slide the carousel.
|
||||
effect(() => {
|
||||
const el = this.swiperRef()?.nativeElement;
|
||||
const targetIndex = PAGE_TO_INDEX[this.mobilePage()];
|
||||
|
||||
if (el?.swiper && el.swiper.activeIndex !== targetIndex) {
|
||||
el.swiper.slideTo(targetIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Bridge Swiper's slidechange event back into `mobilePage`.
|
||||
effect((onCleanup) => {
|
||||
const el = this.swiperRef()?.nativeElement;
|
||||
|
||||
if (!el || el === this.swiperListenerAttached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
const swiper = Array.isArray(detail) ? detail[0] : detail;
|
||||
const index = swiper?.activeIndex ?? 0;
|
||||
const page = INDEX_TO_PAGE[index] ?? 'channels';
|
||||
|
||||
this.zone.run(() => this.mobilePage.set(page));
|
||||
};
|
||||
|
||||
el.addEventListener('swiperslidechange', handler);
|
||||
this.swiperListenerAttached = el;
|
||||
|
||||
onCleanup(() => {
|
||||
el.removeEventListener('swiperslidechange', handler);
|
||||
|
||||
if (this.swiperListenerAttached === el) {
|
||||
this.swiperListenerAttached = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Set the active mobile page. No-op on desktop. */
|
||||
setMobilePage(page: ChatRoomMobilePage): void {
|
||||
this.mobilePage.set(page);
|
||||
}
|
||||
|
||||
/** Open the settings modal to the Server admin page for the current room. */
|
||||
toggleAdminPanel() {
|
||||
const room = this.currentRoom();
|
||||
@@ -82,3 +218,4 @@ export class ChatRoomComponent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
[class.hidden]="isMobile()"
|
||||
(click)="toggleScreenShare()"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ScreenShareQuality,
|
||||
ScreenShareStartOptions
|
||||
} from '../../../domains/screen-share';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
@@ -90,6 +91,8 @@ export class VoiceWorkspaceComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly workspacePlayback = inject(VoiceWorkspacePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||
<!-- Backdrop -->
|
||||
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
|
||||
<div
|
||||
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
|
||||
class="fixed inset-0 z-[90] hidden bg-black/80 backdrop-blur-sm transition-opacity duration-200 md:block"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="onBackdropClick()"
|
||||
@@ -13,15 +13,14 @@
|
||||
aria-label="Close settings"
|
||||
></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||
<!-- Modal: full-screen page on mobile, centered dialog on desktop -->
|
||||
<div class="fixed inset-0 z-[91] flex pointer-events-none md:items-center md:justify-center md:p-4">
|
||||
<div
|
||||
appThemeNode="settingsModalSurface"
|
||||
class="pointer-events-auto relative flex w-full max-w-5xl overflow-hidden rounded-lg border border-border bg-card shadow-lg transition-all duration-200"
|
||||
style="height: min(720px, 88vh)"
|
||||
class="pointer-events-auto relative flex h-full w-full overflow-hidden bg-card transition-all duration-200 md:h-[min(720px,88vh)] md:max-w-5xl md:rounded-lg md:border md:border-border md:shadow-lg"
|
||||
[class.scale-100]="animating()"
|
||||
[class.opacity-100]="animating()"
|
||||
[class.scale-95]="!animating()"
|
||||
[class.md:scale-95]="!animating()"
|
||||
[class.opacity-0]="!animating()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
@@ -31,18 +30,32 @@
|
||||
aria-labelledby="settings-modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Side Navigation -->
|
||||
<!-- Side Navigation: persistent on desktop; full-width "menu" page on mobile -->
|
||||
<nav
|
||||
appThemeNode="settingsModalNav"
|
||||
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"
|
||||
class="flex w-full flex-shrink-0 flex-col border-r border-border bg-card md:w-56"
|
||||
[class.hidden]="isMobile() && mobilePage() !== 'menu'"
|
||||
>
|
||||
<div class="border-b border-border px-3 py-3">
|
||||
<div class="flex items-center justify-between border-b border-border px-3 py-3">
|
||||
<h2
|
||||
id="settings-modal-title"
|
||||
class="text-lg font-semibold text-foreground"
|
||||
>
|
||||
Settings
|
||||
</h2>
|
||||
@if (isMobile()) {
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
aria-label="Close settings"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideX"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
@@ -52,8 +65,8 @@
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors"
|
||||
[class.bg-secondary]="activePage() === page.id"
|
||||
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
|
||||
[class.bg-secondary]="activePage() === page.id && !isMobile()"
|
||||
[class.text-foreground]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-muted-foreground]="activePage() !== page.id"
|
||||
@@ -92,8 +105,8 @@
|
||||
<button
|
||||
(click)="navigate(page.id)"
|
||||
type="button"
|
||||
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors"
|
||||
[class.bg-secondary]="activePage() === page.id"
|
||||
class="mx-2 flex w-[calc(100%-1rem)] items-center gap-2.5 rounded-md px-2.5 py-2.5 text-sm transition-colors md:py-1.5"
|
||||
[class.bg-secondary]="activePage() === page.id && !isMobile()"
|
||||
[class.text-foreground]="activePage() === page.id"
|
||||
[class.font-medium]="activePage() === page.id"
|
||||
[class.text-muted-foreground]="activePage() !== page.id"
|
||||
@@ -123,66 +136,85 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<!-- Content: shown alongside nav on desktop; full-width "detail" page on mobile -->
|
||||
<div
|
||||
class="flex flex-1 flex-col min-w-0"
|
||||
[class.hidden]="isMobile() && mobilePage() !== 'detail'"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
appThemeNode="settingsModalHeader"
|
||||
class="flex items-center justify-between border-b border-border px-5 py-3 flex-shrink-0"
|
||||
class="flex items-center justify-between border-b border-border px-3 py-3 flex-shrink-0 md:px-5"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
General
|
||||
}
|
||||
@case ('plugins') {
|
||||
Client Plugins
|
||||
}
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@case ('theme') {
|
||||
Theme Studio
|
||||
}
|
||||
@case ('notifications') {
|
||||
Notifications
|
||||
}
|
||||
@case ('voice') {
|
||||
Voice & Audio
|
||||
}
|
||||
@case ('updates') {
|
||||
Updates
|
||||
}
|
||||
@case ('localApi') {
|
||||
Local API
|
||||
}
|
||||
@case ('data') {
|
||||
Data
|
||||
}
|
||||
@case ('debugging') {
|
||||
Debugging
|
||||
}
|
||||
@case ('server') {
|
||||
Server Settings
|
||||
}
|
||||
@case ('serverPlugins') {
|
||||
Server Plugins
|
||||
}
|
||||
@case ('members') {
|
||||
Members
|
||||
}
|
||||
@case ('bans') {
|
||||
Bans
|
||||
}
|
||||
@case ('permissions') {
|
||||
Permissions
|
||||
}
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
@if (isMobile()) {
|
||||
<button
|
||||
(click)="backToMenu()"
|
||||
type="button"
|
||||
aria-label="Back to settings menu"
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground md:hidden"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideChevronLeft"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</h3>
|
||||
<h3 class="truncate text-lg font-semibold text-foreground">
|
||||
@switch (activePage()) {
|
||||
@case ('general') {
|
||||
General
|
||||
}
|
||||
@case ('plugins') {
|
||||
Client Plugins
|
||||
}
|
||||
@case ('network') {
|
||||
Network
|
||||
}
|
||||
@case ('theme') {
|
||||
Theme Studio
|
||||
}
|
||||
@case ('notifications') {
|
||||
Notifications
|
||||
}
|
||||
@case ('voice') {
|
||||
Voice & Audio
|
||||
}
|
||||
@case ('updates') {
|
||||
Updates
|
||||
}
|
||||
@case ('localApi') {
|
||||
Local API
|
||||
}
|
||||
@case ('data') {
|
||||
Data
|
||||
}
|
||||
@case ('debugging') {
|
||||
Debugging
|
||||
}
|
||||
@case ('server') {
|
||||
Server Settings
|
||||
}
|
||||
@case ('serverPlugins') {
|
||||
Server Plugins
|
||||
}
|
||||
@case ('members') {
|
||||
Members
|
||||
}
|
||||
@case ('bans') {
|
||||
Bans
|
||||
}
|
||||
@case ('permissions') {
|
||||
Permissions
|
||||
}
|
||||
}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
(click)="close()"
|
||||
type="button"
|
||||
aria-label="Close settings"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideBell,
|
||||
lucideChevronLeft,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { ViewportService } from '../../../core/platform';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
@@ -83,6 +85,7 @@ import {
|
||||
lucideX,
|
||||
lucideBug,
|
||||
lucideBell,
|
||||
lucideChevronLeft,
|
||||
lucideDownload,
|
||||
lucideGlobe,
|
||||
lucideAudioLines,
|
||||
@@ -103,9 +106,21 @@ export class SettingsModalComponent {
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private theme = inject(ThemeService);
|
||||
private themeLibrary = inject(ThemeLibraryService);
|
||||
private viewport = inject(ViewportService);
|
||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||
private lastRequestedServerId: string | null = null;
|
||||
|
||||
/** True on mobile breakpoints. Drives the full-screen, page-stack layout. */
|
||||
readonly isMobile = this.viewport.isMobile;
|
||||
|
||||
/**
|
||||
* Active mobile sub-page within the settings flow.
|
||||
* 'menu' -> the section list (nav)
|
||||
* 'detail' -> the selected page content
|
||||
* Ignored on desktop.
|
||||
*/
|
||||
readonly mobilePage = signal<'menu' | 'detail'>('menu');
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
@@ -299,6 +314,11 @@ export class SettingsModalComponent {
|
||||
}
|
||||
|
||||
this.animating.set(true);
|
||||
|
||||
// On mobile, always start on the section list so the user picks the page first.
|
||||
if (this.isMobile()) {
|
||||
this.mobilePage.set('menu');
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
@@ -360,6 +380,12 @@ export class SettingsModalComponent {
|
||||
}
|
||||
|
||||
if (this.isOpen()) {
|
||||
// On mobile, Escape on the detail page just navigates back to the menu.
|
||||
if (this.isMobile() && this.mobilePage() === 'detail') {
|
||||
this.backToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -386,6 +412,16 @@ export class SettingsModalComponent {
|
||||
|
||||
navigate(page: SettingsPage): void {
|
||||
this.modal.navigate(page);
|
||||
|
||||
// On mobile, advance to the detail page so the user sees the selected pane.
|
||||
if (this.isMobile()) {
|
||||
this.mobilePage.set('detail');
|
||||
}
|
||||
}
|
||||
|
||||
/** Go back to the section list on mobile. No-op on desktop. */
|
||||
backToMenu(): void {
|
||||
this.mobilePage.set('menu');
|
||||
}
|
||||
|
||||
openThemeStudio(): void {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import { ViewportService } from '../../../core/platform/viewport.service';
|
||||
import { ContextMenuComponent } from '../../../shared';
|
||||
import type { ContextMenuParams } from '../../../core/platform/electron/electron-api.models';
|
||||
|
||||
@@ -55,11 +56,19 @@ export class NativeContextMenuComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
private cleanup: (() => void) | null = null;
|
||||
private selectionSnapshot: ContextMenuSelectionSnapshot | null = null;
|
||||
|
||||
@HostListener('document:contextmenu', ['$event'])
|
||||
onDocumentContextMenu(event: MouseEvent): void {
|
||||
// On mobile (non-Electron), let the OS-native context menu handle text inputs,
|
||||
// selection, links, and images. Intercepting here suppresses the OS menu and
|
||||
// leaves the user without copy/paste/select-all affordances.
|
||||
if (this.viewport.isMobile() && !this.electronBridge.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.captureSelectionSnapshot(event);
|
||||
|
||||
if (this.electronBridge.isAvailable) {
|
||||
|
||||
Reference in New Issue
Block a user