style: Consistent backdrop and create server in server-rail
wider server rail with larger icons ans slightly animated.
This commit is contained in:
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user