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

@@ -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({