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.
- `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`.
- 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.
## 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 -->
@if (lightboxAttachment()) {
<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()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"

View File

@@ -1,5 +1,5 @@
@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">
<section

View File

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

View File

@@ -432,10 +432,10 @@
</div>
@if (serverInstallDialog(); as dialog) {
<div
class="fixed inset-0 z-[80] bg-black/60"
role="presentation"
></div>
<app-modal-backdrop
[zIndex]="80"
[dismissable]="false"
/>
<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"
role="dialog"

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ProfileAvatarFacade } from '../../application/services/profile-avatar.facade';
import { ModalBackdropComponent } from '../../../../shared';
import {
EditableProfileAvatarSource,
ProcessedProfileAvatar,
@@ -21,7 +22,7 @@ import {
@Component({
selector: 'app-profile-avatar-editor',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ModalBackdropComponent],
templateUrl: './profile-avatar-editor.component.html'
})
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;
}
const CATEGORY_PRESETS: ServerCategoryPreset[] = [
export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
{ id: 'gaming', label: 'Gaming', topic: 'gaming' },
{ id: 'music', label: 'Music', topic: 'music' },
{ id: 'coding', label: 'Coding', topic: 'coding' },

View File

@@ -290,10 +290,10 @@
}
@if (pluginConsentDialog(); as dialog) {
<div
class="fixed inset-0 z-50 bg-black/50"
role="presentation"
></div>
<app-modal-backdrop
[zIndex]="50"
[dismissable]="false"
/>
<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"
role="dialog"
@@ -469,11 +469,11 @@
</section>
@if (pluginConsentReadme(); as readme) {
<div
class="fixed inset-0 z-[52] bg-black/60"
role="presentation"
(click)="closePluginConsentReadme()"
></div>
<app-modal-backdrop
[zIndex]="52"
ariaLabel="Close readme"
(dismissed)="closePluginConsentReadme()"
/>
<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"
role="dialog"

View File

@@ -49,6 +49,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
import {
ConfirmDialogComponent,
LeaveServerDialogComponent,
ModalBackdropComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { ChatMessageMarkdownComponent } from '../../../chat';
@@ -89,7 +90,8 @@ export interface ServerDiscoverySection {
NgIcon,
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent
LeaveServerDialogComponent,
ModalBackdropComponent
],
viewProviders: [
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 -->
<button
appThemeNode="serversRailCreateButton"
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"
(click)="goToDashboard()"
>
@@ -26,7 +26,7 @@
<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]="
callAvatarUrls(call).length > 0
? 'bg-emerald-950 text-white shadow-sm hover:bg-emerald-900'
@@ -60,7 +60,7 @@
<ng-icon
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>
</div>
@@ -82,7 +82,7 @@
<button
appThemeNode="serversRailItem"
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'"
[title]="room.name"
[attr.aria-current]="isSelectedRoom(room) ? 'page' : null"
@@ -135,6 +135,29 @@
</button>
</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
@@ -235,3 +258,10 @@
(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 { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone } from '@ng-icons/lucide';
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
@@ -37,6 +37,7 @@ import { NotificationsFacade } from '../../../domains/notifications';
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component';
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 { hasRoomBanForUser } from '../../../domains/access-control';
import {
@@ -56,12 +57,13 @@ const ACTIVATION_DEBOUNCE_MS = 150;
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
CreateServerDialogComponent,
DmRailComponent,
LeaveServerDialogComponent,
ThemeNodeDirective,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePhone })],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
@@ -90,6 +92,7 @@ export class ServersRailComponent {
contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false);
showCreateDialog = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({});
@@ -257,6 +260,14 @@ export class ServersRailComponent {
this.router.navigate(['/dashboard']);
}
openCreateDialog(): void {
this.showCreateDialog.set(true);
}
closeCreateDialog(): void {
this.showCreateDialog.set(false);
}
joinSavedRoom(room: Room): void {
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
const currentUserId = localStorage.getItem('metoyou_currentUserId');

View File

@@ -2,7 +2,7 @@
@if (isOpen() && !isThemeStudioFullscreen()) {
<!-- Backdrop (hidden on mobile where the modal is full-screen) -->
<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-0]="!animating()"
(click)="onBackdropClick()"

View File

@@ -272,10 +272,10 @@
}
@if (requiredPluginRequirements().length > 0 && currentRoom()) {
<div
class="fixed inset-0 z-[80] bg-black/60"
role="presentation"
></div>
<app-modal-backdrop
[zIndex]="80"
[dismissable]="false"
/>
<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"
role="dialog"

View File

@@ -41,7 +41,7 @@ import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
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 { VoiceWorkspaceService } from '../../../domains/voice-session';
import { ThemeNodeDirective } from '../../../domains/theme';
@@ -59,7 +59,8 @@ import { getPluginInstallScope } from '../../../domains/plugins/domain/logic/plu
CommonModule,
NgIcon,
LeaveServerDialogComponent,
ThemeNodeDirective
ThemeNodeDirective,
ModalBackdropComponent
],
viewProviders: [
provideIcons({ lucideMinus,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,8 @@
<div
class="fixed inset-0 z-40 bg-black/30"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close dialog"
></div>
<app-modal-backdrop
[zIndex]="40"
ariaLabel="Close dialog"
(dismissed)="cancelled.emit(undefined)"
/>
<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"
>

View File

@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalBackdropComponent } from '../modal-backdrop/modal-backdrop.component';
import {
Room,
RoomMember,
@@ -22,7 +23,11 @@ export interface LeaveServerDialogResult {
@Component({
selector: 'app-leave-server-dialog',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
CommonModule,
FormsModule,
ModalBackdropComponent
],
templateUrl: './leave-server-dialog.component.html'
})
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
class="fixed inset-0 z-[110] bg-black/70 backdrop-blur-sm"
(click)="cancelled.emit(undefined)"
(keydown.enter)="cancelled.emit(undefined)"
(keydown.space)="cancelled.emit(undefined)"
role="button"
tabindex="0"
aria-label="Close screen share quality dialog"
></div>
<app-modal-backdrop
[zIndex]="110"
ariaLabel="Close screen share quality dialog"
(dismissed)="cancelled.emit(undefined)"
/>
<div class="fixed inset-0 z-[111] flex items-center justify-center p-4 pointer-events-none">
<div

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
* Shared reusable UI components barrel.
*/
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 { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';

View File

@@ -1,5 +1,14 @@
@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 {
from {
opacity: 0;