feat: dashboard
This commit is contained in:
@@ -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" />
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user