feat: dashboard

This commit is contained in:
2026-06-05 01:25:16 +02:00
parent 147858de2f
commit 2f6c52e73c
73 changed files with 3490 additions and 1061 deletions

View File

@@ -0,0 +1,83 @@
<ng-template #pageContent>
<div class="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<header class="flex items-center gap-3 border-b border-border px-4 py-3">
<a
routerLink="/dashboard"
aria-label="Back to dashboard"
class="grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-border bg-secondary text-muted-foreground transition-colors hover:bg-secondary/80"
>
<ng-icon
name="lucideArrowLeft"
class="h-5 w-5"
/>
</a>
<div class="min-w-0">
<h1 class="truncate text-lg font-semibold text-foreground">Find people</h1>
<p class="truncate text-xs text-muted-foreground">Search for people you share servers with.</p>
</div>
</header>
<div class="border-b border-border px-4 py-3">
<div class="relative">
<ng-icon
name="lucideSearch"
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
aria-label="Search people"
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Search people..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
/>
</div>
</div>
@if (hasDiscoverablePeople()) {
<app-user-search-list
class="min-h-0 flex-1 overflow-y-auto"
[searchQuery]="searchQuery()"
/>
} @else {
<div class="flex flex-1 flex-col items-center justify-center px-6 py-16 text-center text-muted-foreground">
<div class="mb-4 grid h-14 w-14 place-items-center rounded-full bg-secondary">
<ng-icon
name="lucideUsers"
class="h-7 w-7 text-muted-foreground"
/>
</div>
<p class="text-base font-semibold text-foreground">No people to show yet</p>
<p class="mt-1 max-w-sm text-sm">Join servers to discover people with shared interests.</p>
<a
routerLink="/servers"
class="mt-5 inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Find servers
</a>
</div>
}
</div>
</ng-template>
@if (isMobile()) {
<swiper-container
class="block h-full min-h-0 w-full bg-background"
slides-per-view="1"
space-between="0"
initial-slide="0"
threshold="10"
resistance-ratio="0"
>
<swiper-slide class="block h-full w-full">
<div class="flex h-full w-full min-h-0 overflow-hidden">
<app-servers-rail class="block h-full shrink-0" />
<div class="flex min-h-0 flex-1 overflow-hidden border-l border-border">
<ng-container [ngTemplateOutlet]="pageContent" />
</div>
</div>
</swiper-slide>
</swiper-container>
} @else {
<ng-container [ngTemplateOutlet]="pageContent" />
}

View File

@@ -0,0 +1,89 @@
import '@angular/compiler';
import {
describe,
it,
expect,
vi
} from 'vitest';
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { FindPeopleComponent } from './find-people.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import type { User, Room } from '../../../../shared-kernel';
interface HarnessOptions {
users?: User[];
saved?: Room[];
isMobile?: boolean;
}
function createHarness(options: HarnessOptions = {}) {
const usersSig = signal<User[]>(options.users ?? []);
const savedSig = signal<Room[]>(options.saved ?? []);
const store = {
selectSignal: (selector: unknown) => {
if (selector === selectAllUsers) {
return usersSig;
}
if (selector === selectSavedRooms) {
return savedSig;
}
return signal(null);
},
dispatch: vi.fn()
} as unknown as Store;
const injector = Injector.create({
providers: [
FindPeopleComponent,
{ provide: Store, useValue: store },
{ provide: ViewportService, useValue: { isMobile: signal(options.isMobile ?? false) } }
]
});
const component = runInInjectionContext(injector, () => injector.get(FindPeopleComponent));
return { component };
}
describe('FindPeopleComponent', () => {
it('exposes the mobile viewport flag', () => {
expect(createHarness().component.isMobile()).toBe(false);
expect(createHarness({ isMobile: true }).component.isMobile()).toBe(true);
});
it('has no discoverable people for a brand-new account', () => {
const { component } = createHarness();
expect(component.hasDiscoverablePeople()).toBe(false);
});
it('reports discoverable people when users are known', () => {
const { component } = createHarness({ users: [{ id: 'u1' } as unknown as User] });
expect(component.hasDiscoverablePeople()).toBe(true);
});
it('reports discoverable people from saved-room members', () => {
const { component } = createHarness({
saved: [{ id: 'r1', members: [{ id: 'm1' }] } as unknown as Room]
});
expect(component.hasDiscoverablePeople()).toBe(true);
});
it('updates the search query', () => {
const { component } = createHarness();
component.onSearchChange('alice');
expect(component.searchQuery()).toBe('alice');
});
});

View File

@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
CUSTOM_ELEMENTS_SCHEMA,
Component,
computed,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideArrowLeft,
lucideSearch,
lucideUsers
} from '@ng-icons/lucide';
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
import { ServersRailComponent } from '../../../../features/servers/servers-rail/servers-rail.component';
import { ViewportService } from '../../../../core/platform';
import { selectAllUsers } from '../../../../store/users/users.selectors';
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
/**
* Dedicated people-discovery page. Wraps {@link UserSearchListComponent} with a search
* field and an onboarding empty state for accounts that have not joined any servers yet.
* On mobile the page is mounted inside a Swiper slide alongside the servers rail so the
* primary navigation stays reachable, matching the chat-room and DM workspace layouts.
*/
@Component({
selector: 'app-find-people',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterLink,
NgIcon,
UserSearchListComponent,
ServersRailComponent
],
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
templateUrl: './find-people.component.html'
})
export class FindPeopleComponent {
private store = inject(Store);
private readonly viewport = inject(ViewportService);
readonly isMobile = this.viewport.isMobile;
searchQuery = signal('');
private users = this.store.selectSignal(selectAllUsers);
private savedRooms = this.store.selectSignal(selectSavedRooms);
/** True when the account has any people to surface (known users or server members). */
hasDiscoverablePeople = computed(() => {
if (this.users().length > 0) {
return true;
}
return this.savedRooms().some((room) => (room.members?.length ?? 0) > 0);
});
onSearchChange(query: string): void {
this.searchQuery.set(query);
}
}