style: Consistent backdrop and create server in server-rail

wider server rail with larger icons ans slightly animated.
This commit is contained in:
2026-06-05 02:34:02 +02:00
parent 2f6c52e73c
commit 9e1d75d038
34 changed files with 729 additions and 95 deletions

View File

@@ -70,7 +70,7 @@ Both endpoints live in `server/src/routes/servers.ts` and **must be registered b
- `ServerDirectoryService``ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary. - `ServerDirectoryService``ServerDirectoryFacade` expose `getFeaturedServers()` / `getTrendingServers()` as the domain boundary.
- `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`. - `FindServersComponent` (`/servers`) composes **Recently active** (the user's saved rooms, capped at 6), **Featured**, and **Trending** sections, all rendered through `app-server-browser` with `[showMyServers]="true"`.
- `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`. - `DashboardComponent` (`/dashboard`) is a single-column landing page (max-width centered, no in-page sidebars): a header greeting (no emoji), a global search with `Ctrl+K` focus and localStorage-backed **Recent Searches** chips shown beneath it, three primary action cards (Find People → `/people`, Find Servers → `/servers`, Create Server → `/create-server` — one link each), and discovery panels **People you might know**, **Popular Servers**, **Your Friends**, and **Recently Active Servers**. Each list is capped at 5 (`DISCOVERY_LIMIT`). It loads `popularServers` on init from `getFeaturedServers(5)`, falling back to `getTrendingServers(5)` when featured is empty; reuses `app-friend-button` for Add and `app-user-avatar` for people rows. `peopleYouMightKnow` excludes existing friends (via `FriendService.friendIds()`); `friends` lists discovered people who are friends. "See all" header links route to the matching `/people` or `/servers` page (no duplicated footer links). Recent searches are recorded on Enter (deduped, most-recent-first, capped at 8) and persisted under `metoyou_dashboard_recent_searches`.
- The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. Creating a server is reached via the dashboard / `/create-server` link instead. - The servers-rail top button (`servers-rail.component`) is the **Dashboard** button (`lucideLayoutDashboard`, `title="Dashboard"`); its `goToDashboard()` handler deselects any active voice server and navigates to `/dashboard`. A **Create a server** button (`lucidePlus`, `data-testid="server-rail-create"`) sits below the saved-server icons and opens `app-create-server-dialog` (a Toju modal on desktop / bottom sheet on mobile) which dispatches `RoomsActions.createRoom` directly; the dashboard / `/create-server` route remains as an alternative entry point. Rail icons (`h-12 w-12`, `md:h-11 w-11`) animate their corner radius on hover and `:active` for a Discord-style squircle effect.
- On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements. - On mobile (`ViewportService.isMobile()`), `DashboardComponent`, `FindPeopleComponent` (`/people`), and `FindServersComponent` (`/servers`) each mount their page body inside a single `<swiper-container>` slide next to `app-servers-rail` (rail `shrink-0`, content `flex-1` with a left border), mirroring the chat-room / DM-workspace mobile layout so the primary navigation rail stays reachable. The page body is shared between the desktop and mobile branches via an `<ng-template #pageContent>` + `[ngTemplateOutlet]`, and each component declares `schemas: [CUSTOM_ELEMENTS_SCHEMA]` for the Swiper custom elements.
## Related ## Related

View File

@@ -1,7 +1,7 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc --> <!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@if (lightboxAttachment()) { @if (lightboxAttachment()) {
<div <div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
(click)="closeLightbox()" (click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)" (contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()" (keydown.escape)="closeLightbox()"

View File

@@ -1,5 +1,5 @@
@if (session()) { @if (session()) {
<div class="fixed inset-0 z-[120] bg-black/60 backdrop-blur-sm"></div> <app-modal-backdrop [zIndex]="120" [dismissable]="false" />
<div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4"> <div class="pointer-events-none fixed inset-0 z-[121] flex items-center justify-center p-4">
<section <section

View File

@@ -9,7 +9,7 @@ import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide'; import { lucidePhone, lucidePhoneOff } from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared'; import { ModalBackdropComponent, UserAvatarComponent } from '../../../../shared';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel'; import { User } from '../../../../shared-kernel';
import { DirectCallService } from '../../application/services/direct-call.service'; import { DirectCallService } from '../../application/services/direct-call.service';
@@ -21,7 +21,8 @@ import { DirectCallSession, participantToUser } from '../../domain/models/direct
imports: [ imports: [
CommonModule, CommonModule,
NgIcon, NgIcon,
UserAvatarComponent UserAvatarComponent,
ModalBackdropComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({

View File

@@ -432,10 +432,10 @@
</div> </div>
@if (serverInstallDialog(); as dialog) { @if (serverInstallDialog(); as dialog) {
<div <app-modal-backdrop
class="fixed inset-0 z-[80] bg-black/60" [zIndex]="80"
role="presentation" [dismissable]="false"
></div> />
<section <section
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl" class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog" role="dialog"

View File

@@ -38,6 +38,7 @@ import type {
} from '../../../../shared-kernel'; } from '../../../../shared-kernel';
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { ModalBackdropComponent } from '../../../../shared';
import { PluginCapabilityService } from '../../application/services/plugin-capability.service'; import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
import { PluginStoreService } from '../../application/services/plugin-store.service'; import { PluginStoreService } from '../../application/services/plugin-store.service';
import type { import type {
@@ -60,7 +61,8 @@ interface ServerPluginInstallDialog {
CommonModule, CommonModule,
FormsModule, FormsModule,
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
NgIcon NgIcon,
ModalBackdropComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({

View File

@@ -1,12 +1,8 @@
<div <app-modal-backdrop
class="fixed inset-0 z-[112] bg-black/70 backdrop-blur-sm" [zIndex]="112"
(click)="cancelled.emit(undefined)" ariaLabel="Close profile image editor"
(keydown.enter)="cancelled.emit(undefined)" (dismissed)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)" />
role="button"
tabindex="0"
aria-label="Close profile image editor"
></div>
<div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[113] flex items-center justify-center p-4 pointer-events-none">
<div <div

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade'; import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import { ModalBackdropComponent } from '../../../../shared';
import { import {
EditableProfileAvatarSource, EditableProfileAvatarSource,
ProcessedProfileAvatar, ProcessedProfileAvatar,
@@ -21,7 +22,7 @@ import {
@Component({ @Component({
selector: 'app-profile-avatar-editor', selector: 'app-profile-avatar-editor',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, ModalBackdropComponent],
templateUrl: './profile-avatar-editor.component.html' templateUrl: './profile-avatar-editor.component.html'
}) })
export class ProfileAvatarEditorComponent { export class ProfileAvatarEditorComponent {

View File

@@ -0,0 +1,216 @@
<!--
Server-creation modal. Two presentations matching the rest of Toju's dialogs:
- Mobile: rendered through `app-bottom-sheet` so it slides up from the bottom.
- Desktop: centered modal with backdrop.
-->
@if (isMobile()) {
<app-bottom-sheet
title="Create a server"
ariaLabel="Create a server"
(dismissed)="cancel()"
>
<ng-container *ngTemplateOutlet="form" />
<div class="flex gap-2 border-t border-border p-3">
<button
type="button"
class="min-h-11 flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
</button>
<button
type="button"
class="min-h-11 flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate"
(click)="create()"
>
Create server
</button>
</div>
</app-bottom-sheet>
} @else {
<!-- Backdrop -->
<app-modal-backdrop
[zIndex]="40"
ariaLabel="Close dialog"
(dismissed)="cancel()"
/>
<!-- Dialog -->
<div
appThemeNode="confirmDialogSurface"
class="fixed left-1/2 top-1/2 z-50 flex max-h-[88vh] w-[460px] max-w-[92vw] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-border bg-card shadow-lg"
>
<div class="border-b border-border p-4">
<h4 class="font-semibold text-foreground">Create a server</h4>
<p class="mt-0.5 text-xs text-muted-foreground">Your server is where you and your community hang out.</p>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<ng-container *ngTemplateOutlet="form" />
</div>
<div class="flex gap-2 border-t border-border p-3">
<button
type="button"
class="flex-1 rounded-lg bg-secondary px-3 py-2 text-sm text-foreground transition-colors hover:bg-secondary/80"
(click)="cancel()"
>
Cancel
</button>
<button
id="create-server-dialog-submit"
type="button"
class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
[disabled]="!canCreate"
(click)="create()"
>
Create server
</button>
</div>
</div>
}
<!-- Shared form body for both presentations. -->
<ng-template #form>
<div class="space-y-5 p-4">
<div>
<span class="mb-2 block text-sm font-medium text-foreground">Pick a category</span>
<div class="flex flex-wrap gap-2">
@for (category of categories; track category.id) {
<button
type="button"
class="rounded-full border px-3 py-1.5 text-sm font-medium transition-colors"
[class.border-primary]="selectedCategoryId() === category.id"
[class.bg-primary/10]="selectedCategoryId() === category.id"
[class.text-primary]="selectedCategoryId() === category.id"
[class.border-border]="selectedCategoryId() !== category.id"
[class.bg-secondary]="selectedCategoryId() !== category.id"
[class.text-foreground]="selectedCategoryId() !== category.id"
(click)="selectCategory(category)"
>
{{ category.label }}
</button>
}
</div>
</div>
<div>
<label
for="create-server-dialog-name"
class="mb-1 block text-sm font-medium text-foreground"
>Server name</label
>
<input
id="create-server-dialog-name"
type="text"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="My Awesome Server"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-dialog-description"
class="mb-1 block text-sm font-medium text-foreground"
>Description (optional)</label
>
<textarea
id="create-server-dialog-description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
placeholder="What's your server about?"
rows="3"
class="w-full resize-none rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
></textarea>
</div>
<div class="rounded-lg border border-border">
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-medium text-foreground"
[attr.aria-expanded]="showAdvanced()"
(click)="toggleAdvanced()"
>
<span>Advanced settings</span>
<ng-icon
[name]="showAdvanced() ? 'lucideChevronUp' : 'lucideChevronDown'"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (showAdvanced()) {
<div class="space-y-4 border-t border-border p-3">
<div>
<label
for="create-server-dialog-topic"
class="mb-1 block text-sm font-medium text-foreground"
>Topic (optional)</label
>
<input
id="create-server-dialog-topic"
type="text"
[ngModel]="topic()"
(ngModelChange)="topic.set($event)"
placeholder="gaming, music, coding..."
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="create-server-dialog-signal-endpoint"
class="mb-1 block text-sm font-medium text-foreground"
>Signal server endpoint</label
>
<select
id="create-server-dialog-signal-endpoint"
[ngModel]="sourceId()"
(ngModelChange)="sourceId.set($event)"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
@for (endpoint of activeEndpoints(); track endpoint.id) {
<option [value]="endpoint.id">{{ endpoint.name }} ({{ endpoint.url }})</option>
}
</select>
<p class="mt-1 text-xs text-muted-foreground">This endpoint handles all signaling for this server.</p>
</div>
<div class="flex items-center gap-2">
<input
id="create-server-dialog-private"
type="checkbox"
[ngModel]="isPrivate()"
(ngModelChange)="isPrivate.set($event)"
class="h-4 w-4 rounded border-border bg-secondary"
/>
<label
for="create-server-dialog-private"
class="text-sm text-foreground"
>Private server</label
>
</div>
<div>
<label
for="create-server-dialog-password"
class="mb-1 block text-sm font-medium text-foreground"
>Password (optional)</label
>
<input
id="create-server-dialog-password"
type="password"
[ngModel]="password()"
(ngModelChange)="password.set($event)"
placeholder="Leave blank to allow joining without a password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">Users who already joined keep access even if you change the password later.</p>
</div>
</div>
}
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,142 @@
import {
describe,
it,
expect,
vi,
beforeEach
} from 'vitest';
import { Injector, runInInjectionContext } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { CreateServerDialogComponent } from './create-server-dialog.component';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { ViewportService } from '../../../../core/platform';
function installLocalStorageMock(): void {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
}
});
}
function createHarness() {
const dispatch = vi.fn();
const store = { dispatch } as unknown as Store;
const router = { navigate: vi.fn() } as unknown as Router;
const serverDirectory = {
activeServers: () => [{ id: 'ep-1', name: 'Local', url: 'https://local.test' }]
} as unknown as ServerDirectoryFacade;
const viewport = { isMobile: () => false } as unknown as ViewportService;
const injector = Injector.create({
providers: [
CreateServerDialogComponent,
{ provide: Store, useValue: store },
{ provide: Router, useValue: router },
{ provide: ServerDirectoryFacade, useValue: serverDirectory },
{ provide: ViewportService, useValue: viewport }
]
});
const component = runInInjectionContext(injector, () => injector.get(CreateServerDialogComponent));
return { component, dispatch, router };
}
describe('CreateServerDialogComponent', () => {
beforeEach(() => {
installLocalStorageMock();
localStorage.setItem('metoyou_currentUserId', 'user-1');
});
it('defaults the signal endpoint to the first active endpoint', () => {
const { component } = createHarness();
expect(component.sourceId()).toBe('ep-1');
expect(component.canCreate).toBe(false);
});
it('dispatches createRoom and emits created with the form values', () => {
const { component, dispatch } = createHarness();
const created = vi.fn();
component.created.subscribe(created);
component.name.set('My Server');
component.description.set('A place');
component.topic.set('gaming');
component.isPrivate.set(true);
component.password.set('secret');
component.create();
const action = dispatch.mock.calls.find(([entry]) => entry.type === RoomsActions.createRoom.type)?.[0];
expect(action).toMatchObject({
name: 'My Server',
description: 'A place',
topic: 'gaming',
isPrivate: true,
password: 'secret',
sourceId: 'ep-1'
});
expect(created).toHaveBeenCalledTimes(1);
});
it('does not dispatch or emit created when the name is blank', () => {
const { component, dispatch } = createHarness();
const created = vi.fn();
component.created.subscribe(created);
component.create();
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
expect(created).not.toHaveBeenCalled();
});
it('applies a category preset to the topic and toggles it off', () => {
const { component } = createHarness();
const gaming = component.categories[0];
component.selectCategory(gaming);
expect(component.topic()).toBe(gaming.topic);
expect(component.selectedCategoryId()).toBe(gaming.id);
component.selectCategory(gaming);
expect(component.topic()).toBe('');
expect(component.selectedCategoryId()).toBeNull();
});
it('emits cancelled when cancelled', () => {
const { component } = createHarness();
const cancelled = vi.fn();
component.cancelled.subscribe(cancelled);
component.cancel();
expect(cancelled).toHaveBeenCalledTimes(1);
});
it('redirects to login and does not dispatch when not logged in', () => {
const { component, dispatch, router } = createHarness();
localStorage.removeItem('metoyou_currentUserId');
component.name.set('My Server');
component.create();
expect(router.navigate).toHaveBeenCalledWith(['/login']);
expect(dispatch.mock.calls.some(([entry]) => entry.type === RoomsActions.createRoom.type)).toBe(false);
});
});

View File

@@ -0,0 +1,122 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
inject,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { ThemeNodeDirective } from '../../../theme';
import { ViewportService } from '../../../../core/platform';
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
/**
* Modal presentation of the server-creation form. Mirrors the dedicated
* `/create-server` page but is rendered as a Toju modal (desktop) / bottom sheet
* (mobile) so it can be opened straight from the servers rail. Emits `created`
* once a room creation has been dispatched and `cancelled` when dismissed.
*/
@Component({
selector: 'app-create-server-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ThemeNodeDirective,
BottomSheetComponent,
ModalBackdropComponent
],
viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })],
templateUrl: './create-server-dialog.component.html',
host: {
style: 'display: contents;'
}
})
export class CreateServerDialogComponent {
private store = inject(Store);
private router = inject(Router);
private serverDirectory = inject(ServerDirectoryFacade);
readonly isMobile = inject(ViewportService).isMobile;
readonly created = output<undefined>();
readonly cancelled = output<undefined>();
readonly categories = CATEGORY_PRESETS;
activeEndpoints = this.serverDirectory.activeServers;
name = signal('');
description = signal('');
topic = signal('');
selectedCategoryId = signal<string | null>(null);
isPrivate = signal(false);
password = signal('');
sourceId = signal('');
showAdvanced = signal(false);
constructor() {
this.sourceId.set(this.activeEndpoints()[0]?.id ?? '');
}
/** True when the form has enough to create a server. */
get canCreate(): boolean {
return this.name().trim().length > 0 && this.sourceId().length > 0;
}
selectCategory(category: ServerCategoryPreset): void {
if (this.selectedCategoryId() === category.id) {
this.selectedCategoryId.set(null);
this.topic.set('');
return;
}
this.selectedCategoryId.set(category.id);
this.topic.set(category.topic);
}
toggleAdvanced(): void {
this.showAdvanced.update((shown) => !shown);
}
@HostListener('document:keydown.escape')
cancel(): void {
this.cancelled.emit(undefined);
}
create(): void {
if (!this.canCreate) {
return;
}
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.store.dispatch(
RoomsActions.createRoom({
name: this.name().trim(),
description: this.description().trim() || undefined,
topic: this.topic().trim() || undefined,
isPrivate: this.isPrivate(),
password: this.password().trim() || undefined,
sourceId: this.sourceId() || undefined
})
);
this.created.emit(undefined);
}
}

View File

@@ -26,7 +26,7 @@ export interface ServerCategoryPreset {
topic: string; topic: string;
} }
const CATEGORY_PRESETS: ServerCategoryPreset[] = [ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
{ id: 'gaming', label: 'Gaming', topic: 'gaming' }, { id: 'gaming', label: 'Gaming', topic: 'gaming' },
{ id: 'music', label: 'Music', topic: 'music' }, { id: 'music', label: 'Music', topic: 'music' },
{ id: 'coding', label: 'Coding', topic: 'coding' }, { id: 'coding', label: 'Coding', topic: 'coding' },

View File

@@ -290,10 +290,10 @@
} }
@if (pluginConsentDialog(); as dialog) { @if (pluginConsentDialog(); as dialog) {
<div <app-modal-backdrop
class="fixed inset-0 z-50 bg-black/50" [zIndex]="50"
role="presentation" [dismissable]="false"
></div> />
<section <section
class="fixed left-1/2 top-1/2 z-[51] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl" class="fixed left-1/2 top-1/2 z-[51] flex max-h-[min(42rem,calc(100vh-2rem))] w-[min(34rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog" role="dialog"
@@ -469,11 +469,11 @@
</section> </section>
@if (pluginConsentReadme(); as readme) { @if (pluginConsentReadme(); as readme) {
<div <app-modal-backdrop
class="fixed inset-0 z-[52] bg-black/60" [zIndex]="52"
role="presentation" ariaLabel="Close readme"
(click)="closePluginConsentReadme()" (dismissed)="closePluginConsentReadme()"
></div> />
<section <section
class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl" class="fixed left-1/2 top-1/2 z-[53] flex max-h-[min(44rem,calc(100vh-2rem))] w-[min(44rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog" role="dialog"

View File

@@ -49,6 +49,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { import {
ConfirmDialogComponent, ConfirmDialogComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
ModalBackdropComponent,
type LeaveServerDialogResult type LeaveServerDialogResult
} from '../../../../shared'; } from '../../../../shared';
import { ChatMessageMarkdownComponent } from '../../../chat'; import { ChatMessageMarkdownComponent } from '../../../chat';
@@ -89,7 +90,8 @@ export interface ServerDiscoverySection {
NgIcon, NgIcon,
ChatMessageMarkdownComponent, ChatMessageMarkdownComponent,
ConfirmDialogComponent, ConfirmDialogComponent,
LeaveServerDialogComponent LeaveServerDialogComponent,
ModalBackdropComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ provideIcons({

View File

@@ -1,9 +1,9 @@
<nav class="relative flex h-full min-w-14 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full"> <nav class="relative flex h-full min-w-16 flex-col items-center gap-2 border-r border-border bg-secondary/35 px-0 py-3 md:min-w-0 md:w-full">
<!-- Home / dashboard button --> <!-- Home / dashboard button -->
<button <button
appThemeNode="serversRailCreateButton" appThemeNode="serversRailCreateButton"
type="button" type="button"
class="flex h-11 w-11 items-center justify-center overflow-hidden rounded-md bg-primary transition-colors hover:bg-primary/90 md:h-10 md:w-10" class="flex h-12 w-12 items-center justify-center overflow-hidden rounded-2xl bg-primary transition-[border-radius,background-color] duration-150 ease-out hover:rounded-xl hover:bg-primary/90 active:rounded-lg md:h-11 md:w-11"
title="Dashboard" title="Dashboard"
(click)="goToDashboard()" (click)="goToDashboard()"
> >
@@ -26,7 +26,7 @@
<button <button
type="button" type="button"
class="relative z-10 grid h-11 w-11 place-items-center overflow-hidden rounded-xl transition-colors hover:rounded-lg md:h-10 md:w-10" class="relative z-10 grid h-12 w-12 place-items-center overflow-hidden rounded-xl transition-[border-radius,background-color] duration-150 ease-out hover:rounded-lg active:rounded-2xl md:h-11 md:w-11"
[ngClass]=" [ngClass]="
callAvatarUrls(call).length > 0 callAvatarUrls(call).length > 0
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900' ? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
@@ -60,7 +60,7 @@
<ng-icon <ng-icon
name="lucidePhone" name="lucidePhone"
class="relative z-10 h-[22px] w-[22px] drop-shadow md:h-5 md:w-5" class="relative z-10 h-6 w-6 drop-shadow md:h-[22px] md:w-[22px]"
/> />
</button> </button>
</div> </div>
@@ -82,7 +82,7 @@
<button <button
appThemeNode="serversRailItem" appThemeNode="serversRailItem"
type="button" type="button"
class="relative z-10 flex h-11 w-11 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card md:h-10 md:w-10" class="relative z-10 flex h-12 w-12 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-150 ease-out hover:rounded-lg hover:bg-card active:rounded-2xl md:h-11 md:w-11"
[ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'" [ngClass]="isSelectedRoom(room) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
[title]="room.name" [title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null" [attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
@@ -135,6 +135,29 @@
</button> </button>
</div> </div>
} }
<!-- Separator between servers and create button -->
<div
aria-hidden="true"
class="my-0.5 h-px w-12 shrink-0 bg-border/70 md:w-11"
></div>
<!-- Create server -->
<div class="group/create relative flex w-full shrink-0 justify-center">
<button
type="button"
class="relative z-10 grid h-12 w-12 place-items-center rounded-xl bg-card text-emerald-500 transition-[border-radius,background-color,color] duration-150 ease-out hover:rounded-lg hover:bg-emerald-500 hover:text-white active:rounded-2xl md:h-11 md:w-11"
data-testid="server-rail-create"
title="Create a server"
aria-label="Create a server"
(click)="openCreateDialog()"
>
<ng-icon
name="lucidePlus"
class="h-6 w-6 md:h-[22px] md:w-[22px]"
/>
</button>
</div>
</div> </div>
<div <div
@@ -235,3 +258,10 @@
(cancelled)="cancelLeave()" (cancelled)="cancelLeave()"
/> />
} }
@if (showCreateDialog()) {
<app-create-server-dialog
(created)="closeCreateDialog()"
(cancelled)="closeCreateDialog()"
/>
}

View File

@@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone } from '@ng-icons/lucide'; import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import { import {
EMPTY, EMPTY,
Subject, Subject,
@@ -37,6 +37,7 @@ import { NotificationsFacade } from '../../../domains/notifications';
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call'; import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component'; import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory'; import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { CreateServerDialogComponent } from '../../../domains/server-directory/feature/create-server-dialog/create-server-dialog.component';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control'; import { hasRoomBanForUser } from '../../../domains/access-control';
import { import {
@@ -56,12 +57,13 @@ const ACTIVATION_DEBOUNCE_MS = 150;
NgIcon, NgIcon,
ConfirmDialogComponent, ConfirmDialogComponent,
ContextMenuComponent, ContextMenuComponent,
CreateServerDialogComponent,
DmRailComponent, DmRailComponent,
LeaveServerDialogComponent, LeaveServerDialogComponent,
ThemeNodeDirective, ThemeNodeDirective,
UserBarComponent UserBarComponent
], ],
viewProviders: [provideIcons({ lucidePhone })], viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
templateUrl: './servers-rail.component.html' templateUrl: './servers-rail.component.html'
}) })
export class ServersRailComponent { export class ServersRailComponent {
@@ -90,6 +92,7 @@ export class ServersRailComponent {
contextRoom = signal<Room | null>(null); contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null); optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
showCreateDialog = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers); onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({}); bannedRoomLookup = signal<Record<string, boolean>>({});
@@ -257,6 +260,14 @@ export class ServersRailComponent {
this.router.navigate(['/dashboard']); this.router.navigate(['/dashboard']);
} }
openCreateDialog(): void {
this.showCreateDialog.set(true);
}
closeCreateDialog(): void {
this.showCreateDialog.set(false);
}
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room; const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');

View File

@@ -2,7 +2,7 @@
@if (isOpen() && !isThemeStudioFullscreen()) { @if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop (hidden on mobile where the modal is full-screen) --> <!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<div <div
class="fixed inset-0 z-[90] hidden bg-black/80 backdrop-blur-sm transition-opacity duration-200 md:block" class="fixed inset-0 z-[90] hidden bg-black/60 backdrop-blur-sm transition-opacity duration-200 md:block"
[class.opacity-100]="animating()" [class.opacity-100]="animating()"
[class.opacity-0]="!animating()" [class.opacity-0]="!animating()"
(click)="onBackdropClick()" (click)="onBackdropClick()"

View File

@@ -272,10 +272,10 @@
} }
@if (requiredPluginRequirements().length > 0 && currentRoom()) { @if (requiredPluginRequirements().length > 0 && currentRoom()) {
<div <app-modal-backdrop
class="fixed inset-0 z-[80] bg-black/60" [zIndex]="80"
role="presentation" [dismissable]="false"
></div> />
<section <section
class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(38rem,calc(100vh-2rem))] w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl" class="fixed left-1/2 top-1/2 z-[81] flex max-h-[min(38rem,calc(100vh-2rem))] w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-card text-foreground shadow-2xl"
role="dialog" role="dialog"

View File

@@ -41,7 +41,7 @@ import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform'; import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage'; import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../core/services/settings-modal.service';
import { LeaveServerDialogComponent } from '../../../shared'; import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
import { Room, type PluginRequirementSummary } from '../../../shared-kernel'; import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
import { VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
@@ -59,7 +59,8 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
CommonModule, CommonModule,
NgIcon, NgIcon,
LeaveServerDialogComponent, LeaveServerDialogComponent,
ThemeNodeDirective ThemeNodeDirective,
ModalBackdropComponent
], ],
viewProviders: [ viewProviders: [
provideIcons({ lucideMinus, provideIcons({ lucideMinus,

View File

@@ -1,13 +1,9 @@
<!-- Dimmed backdrop. Tap to dismiss. --> <!-- Dimmed backdrop. Tap to dismiss. -->
<div <app-modal-backdrop
class="fixed inset-0 z-[140] bg-black/40 backdrop-blur-sm" [zIndex]="140"
(click)="onBackdropClick()" ariaLabel="Close"
(keydown.enter)="onBackdropClick()" (dismissed)="onBackdropClick()"
(keydown.space)="onBackdropClick()" />
role="button"
tabindex="0"
aria-label="Close"
></div>
<!-- <!--
Bottom sheet panel. Slides up from the bottom of the viewport. Drag the top handle downward Bottom sheet panel. Slides up from the bottom of the viewport. Drag the top handle downward

View File

@@ -7,6 +7,7 @@ import {
signal signal
} from '@angular/core'; } from '@angular/core';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
/** /**
* Mobile bottom-sheet container. * Mobile bottom-sheet container.
@@ -32,7 +33,7 @@ import { ThemeNodeDirective } from '../../../domains/theme';
@Component({ @Component({
selector: 'app-bottom-sheet', selector: 'app-bottom-sheet',
standalone: true, standalone: true,
imports: [ThemeNodeDirective], imports: [ThemeNodeDirective, ModalBackdropComponent],
templateUrl: './bottom-sheet.component.html', templateUrl: './bottom-sheet.component.html',
styleUrl: './bottom-sheet.component.scss' styleUrl: './bottom-sheet.component.scss'
}) })

View File

@@ -37,15 +37,11 @@
</app-bottom-sheet> </app-bottom-sheet>
} @else { } @else {
<!-- Backdrop --> <!-- Backdrop -->
<div <app-modal-backdrop
class="fixed inset-0 z-40 bg-black/30" [zIndex]="40"
(click)="cancelled.emit(undefined)" ariaLabel="Close dialog"
(keydown.enter)="cancelled.emit(undefined)" (dismissed)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)" />
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<!-- Dialog --> <!-- Dialog -->
<div <div

View File

@@ -8,11 +8,16 @@ import {
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ViewportService } from '../../../core/platform'; import { ViewportService } from '../../../core/platform';
import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component'; import { BottomSheetComponent } from '../bottom-sheet/bottom-sheet.component';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
standalone: true, standalone: true,
imports: [ThemeNodeDirective, BottomSheetComponent], imports: [
ThemeNodeDirective,
BottomSheetComponent,
ModalBackdropComponent
],
templateUrl: './confirm-dialog.component.html', templateUrl: './confirm-dialog.component.html',
host: { host: {
style: 'display: contents;' style: 'display: contents;'

View File

@@ -1,12 +1,8 @@
<div <app-modal-backdrop
class="fixed inset-0 z-40 bg-black/30" [zIndex]="40"
(click)="cancelled.emit(undefined)" ariaLabel="Close dialog"
(keydown.enter)="cancelled.emit(undefined)" (dismissed)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)" />
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<div <div
class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg" class="fixed left-1/2 top-1/2 z-50 w-[360px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card shadow-lg"
> >

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
import { import {
Room, Room,
RoomMember, RoomMember,
@@ -22,7 +23,11 @@ export interface LeaveServerDialogResult {
@Component({ @Component({
selector: 'app-leave-server-dialog', selector: 'app-leave-server-dialog',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [
CommonModule,
FormsModule,
ModalBackdropComponent
],
templateUrl: './leave-server-dialog.component.html' templateUrl: './leave-server-dialog.component.html'
}) })
export class LeaveServerDialogComponent { export class LeaveServerDialogComponent {

View File

@@ -0,0 +1,20 @@
@if (dismissable()) {
<div
class="fixed inset-0 bg-black/60"
[class.backdrop-blur-sm]="blur()"
[style.z-index]="zIndex()"
(click)="dismiss()"
(keydown.enter)="dismiss()"
(keydown.space)="dismiss()"
role="button"
tabindex="0"
[attr.aria-label]="ariaLabel()"
></div>
} @else {
<div
class="fixed inset-0 bg-black/60"
[class.backdrop-blur-sm]="blur()"
[style.z-index]="zIndex()"
role="presentation"
></div>
}

View File

@@ -0,0 +1,37 @@
import {
describe,
it,
expect,
vi
} from 'vitest';
import { Injector, runInInjectionContext } from '@angular/core';
import { ModalBackdropComponent } from './modal-backdrop.component';
function createComponent(): ModalBackdropComponent {
const injector = Injector.create({ providers: [ModalBackdropComponent] });
return runInInjectionContext(injector, () => injector.get(ModalBackdropComponent));
}
describe('ModalBackdropComponent', () => {
it('exposes consistent defaults (centered z-index, blurred, dismissable)', () => {
const component = createComponent();
expect(component.zIndex()).toBe(50);
expect(component.blur()).toBe(true);
expect(component.dismissable()).toBe(true);
expect(component.ariaLabel()).toBe('Close');
});
it('emits dismissed when the backdrop is dismissed', () => {
const component = createComponent();
const handler = vi.fn();
component.dismissed.subscribe(handler);
component.dismiss();
expect(handler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,49 @@
import {
Component,
input,
output
} from '@angular/core';
/**
* Shared modal backdrop.
*
* Renders the full-viewport dimming scrim that sits behind centered dialogs, bottom sheets, and
* other modal surfaces. Centralizing it keeps the backdrop darkness (`bg-black/60`) and the
* `backdrop-blur-sm` treatment consistent across every modal in the app - callers only pick a
* stacking level and (optionally) opt out of dismiss-on-click.
*
* The component renders only the scrim; the modal content should be a sibling element with a
* higher `z-index` so it layers above the backdrop.
*
* @example
* ```html
* <app-modal-backdrop [zIndex]="110" ariaLabel="Close dialog" (dismissed)="cancel()" />
* <div class="fixed inset-0 z-[111] flex items-center justify-center"> ...dialog... </div>
* ```
*/
@Component({
selector: 'app-modal-backdrop',
standalone: true,
templateUrl: './modal-backdrop.component.html'
})
export class ModalBackdropComponent {
/** Stacking level applied to the scrim. Place modal content one level above this value. */
readonly zIndex = input<number>(50);
/** Whether to apply the frosted `backdrop-blur-sm` treatment. Defaults to on for consistency. */
readonly blur = input<boolean>(true);
/** Whether clicking / pressing the backdrop dismisses the modal. Disable for blocking modals. */
readonly dismissable = input<boolean>(true);
/** Accessible label for the dismiss affordance when the backdrop is dismissable. */
readonly ariaLabel = input<string>('Close');
/** Emits when the user dismisses the backdrop (click, Enter, or Space). */
readonly dismissed = output<undefined>();
/** Emit a dismissal request. Wired to the backdrop's pointer and keyboard handlers. */
dismiss(): void {
this.dismissed.emit(undefined);
}
}

View File

@@ -1,12 +1,8 @@
<div <app-modal-backdrop
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm" [zIndex]="110"
(click)="cancelled.emit(undefined)" ariaLabel="Close screen share quality dialog"
(keydown.enter)="cancelled.emit(undefined)" (dismissed)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)" />
role="button"
tabindex="0"
aria-label="Close screen share quality dialog"
></div>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<div <div

View File

@@ -8,11 +8,12 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share'; import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
@Component({ @Component({
selector: 'app-screen-share-quality-dialog', selector: 'app-screen-share-quality-dialog',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, ModalBackdropComponent],
templateUrl: './screen-share-quality-dialog.component.html' templateUrl: './screen-share-quality-dialog.component.html'
}) })
export class ScreenShareQualityDialogComponent implements OnInit { export class ScreenShareQualityDialogComponent implements OnInit {

View File

@@ -1,13 +1,9 @@
@if (request(); as pickerRequest) { @if (request(); as pickerRequest) {
<div <app-modal-backdrop
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm" [zIndex]="110"
(click)="cancel()" ariaLabel="Close source picker"
(keydown.enter)="cancel()" (dismissed)="cancel()"
(keydown.space)="cancel()" />
role="button"
tabindex="0"
aria-label="Close source picker"
></div>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none"> <div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<section <section

View File

@@ -15,6 +15,7 @@ import {
ScreenShareSourcePickerService ScreenShareSourcePickerService
} from '../../../domains/screen-share'; } from '../../../domains/screen-share';
import { ThemeNodeDirective } from '../../../domains/theme'; import { ThemeNodeDirective } from '../../../domains/theme';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
@Component({ @Component({
selector: 'app-screen-share-source-picker', selector: 'app-screen-share-source-picker',
@@ -22,7 +23,8 @@ import { ThemeNodeDirective } from '../../../domains/theme';
imports: [ imports: [
CommonModule, CommonModule,
NgOptimizedImage, NgOptimizedImage,
ThemeNodeDirective ThemeNodeDirective,
ModalBackdropComponent
], ],
templateUrl: './screen-share-source-picker.component.html', templateUrl: './screen-share-source-picker.component.html',
styleUrl: './screen-share-source-picker.component.scss', styleUrl: './screen-share-source-picker.component.scss',

View File

@@ -2,6 +2,7 @@
* Shared reusable UI components barrel. * Shared reusable UI components barrel.
*/ */
export { ContextMenuComponent } from './components/context-menu/context-menu.component'; export { ContextMenuComponent } from './components/context-menu/context-menu.component';
export { ModalBackdropComponent } from './components/modal-backdrop/modal-backdrop.component';
export { BottomSheetComponent } from './components/bottom-sheet/bottom-sheet.component'; export { BottomSheetComponent } from './components/bottom-sheet/bottom-sheet.component';
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component'; export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component'; export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';

View File

@@ -1,5 +1,14 @@
@import '@angular/cdk/overlay-prebuilt.css'; @import '@angular/cdk/overlay-prebuilt.css';
/*
* Keep CDK overlay dark backdrops in sync with the `bg-black/60` darkness used by all
* component-level modal backdrops (confirm dialog, settings modal, bottom sheets, etc.).
* The prebuilt CDK class defaults to rgba(0, 0, 0, 0.32), which is noticeably lighter.
*/
.cdk-overlay-dark-backdrop {
background: rgb(0 0 0 / 60%);
}
@keyframes profile-card-in { @keyframes profile-card-in {
from { from {
opacity: 0; opacity: 0;