Rebuild on Angular 22 + ASP.NET Core 10, containerised
The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.
Backend — ASP.NET Core 10, EF Core, SQLite
* ASP.NET Core Identity (PBKDF2) + JWT bearer auth
* Clean REST API replacing php-crud-api's filter[]/transform query syntax
* Box art uploads re-encoded to WebP via SkiaSharp
* Imports the 105 games recovered from the 2018 dump on first run
Frontend — Angular 22, zoneless, signals, Material 22
* Standalone components, lazy routes, functional guards and interceptor
* Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
* No provideAnimations: @angular/animations is deprecated in v22 and
Material no longer depends on it (pinned by a test)
Docker
* Multi-stage builds for both services, non-root at runtime
* nginx serves the SPA and reverse-proxies the API, so everything is
same-origin; one volume holds the database, uploads and DP keys
Security issues in the old code, not carried across:
* Two endpoints exposed unauthenticated CRUD over every table
* The client chose whose rows to read (filter[]=userId,eq,N); ownership now
comes from the JWT subject server-side
* Login was hardcoded to a single username
* crypt() with one global salt, silently truncating passwords to 8 chars
* JWT secret was the literal string "testing", tokens never expired
* Token travelled in the query string rather than a header
* Uploads were anonymous with the path built from the client filename
* Access-Control-Allow-Origin: *
The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './core/auth.interceptor';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
// Lets a component read route params via input() instead of injecting
|
||||
// ActivatedRoute and reading a snapshot.
|
||||
withComponentInputBinding(),
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' }),
|
||||
),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
// No provideAnimations here: @angular/animations is deprecated as of v22 and
|
||||
// Angular Material 22 no longer depends on it. Component motion now comes
|
||||
// from CSS, with animate.enter / animate.leave for element transitions.
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard, guestGuard } from './core/auth.guard';
|
||||
|
||||
/**
|
||||
* Every feature is lazily loaded, so the login page does not ship the editor's
|
||||
* code. In 2018 this was a single eager NgModule containing everything.
|
||||
*/
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'games' },
|
||||
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Sign in · LudosData',
|
||||
loadComponent: () => import('./features/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Create account · LudosData',
|
||||
loadComponent: () => import('./features/register/register').then((m) => m.Register),
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
canActivate: [authGuard],
|
||||
title: 'Library · LudosData',
|
||||
loadComponent: () => import('./features/game-grid/game-grid').then((m) => m.GameGrid),
|
||||
},
|
||||
{
|
||||
path: 'games/new',
|
||||
canActivate: [authGuard],
|
||||
title: 'New game · LudosData',
|
||||
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
|
||||
},
|
||||
{
|
||||
path: 'games/:id',
|
||||
canActivate: [authGuard],
|
||||
title: 'Edit game · LudosData',
|
||||
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
canActivate: [authGuard],
|
||||
title: 'Account · LudosData',
|
||||
loadComponent: () => import('./features/account/account').then((m) => m.Account),
|
||||
},
|
||||
|
||||
{ path: '**', redirectTo: 'games' },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Functional route guard — the modern replacement for the class-based
|
||||
* `AuthGuard implements CanActivate` this app used in 2018.
|
||||
*/
|
||||
export const authGuard: CanActivateFn = (_route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isLoggedIn()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: { returnUrl: state.url },
|
||||
});
|
||||
};
|
||||
|
||||
/** Keeps an already-signed-in user off the login and register pages. */
|
||||
export const guestGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
return auth.isLoggedIn() ? router.createUrlTree(['/games']) : true;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let auth: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([{ path: 'login', children: [] }]),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
auth = TestBed.inject(AuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
/** Signs in through AuthService, which is what actually stores the session. */
|
||||
function signIn(): void {
|
||||
auth.login('ckoch', 'pw').subscribe();
|
||||
httpMock.expectOne('/api/auth/login').flush({
|
||||
token: 'test-token',
|
||||
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
user: { id: 'u1', userName: 'ckoch', email: null, firstName: null, lastName: null, art: null },
|
||||
});
|
||||
}
|
||||
|
||||
it('sends the token as a bearer header, never in the query string', () => {
|
||||
signIn();
|
||||
|
||||
http.get('/api/games').subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/games');
|
||||
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
// The 2018 client appended ?token=... which leaks into logs and history.
|
||||
expect(req.request.urlWithParams).not.toContain('token');
|
||||
req.flush({ items: [], page: 1, pageSize: 20, total: 0, totalPages: 0 });
|
||||
});
|
||||
|
||||
it('does not attach the token to non-API requests', () => {
|
||||
signIn();
|
||||
|
||||
http.get('/assets/config.json').subscribe();
|
||||
const req = httpMock.expectOne('/assets/config.json');
|
||||
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('clears the stored session when the API rejects the token', () => {
|
||||
signIn();
|
||||
expect(auth.isLoggedIn()).toBe(true);
|
||||
|
||||
http.get('/api/games').subscribe({ error: () => undefined });
|
||||
httpMock
|
||||
.expectOne('/api/games')
|
||||
.flush({ title: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
expect(auth.token()).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an expired stored session as signed out', () => {
|
||||
localStorage.setItem(
|
||||
'ludos.session',
|
||||
JSON.stringify({
|
||||
token: 'stale',
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
||||
user: { id: 'u1', userName: 'ckoch' },
|
||||
}),
|
||||
);
|
||||
|
||||
// A fresh injector picks the value up from storage on construction.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([{ path: 'login', children: [] }]), provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
|
||||
expect(TestBed.inject(AuthService).isLoggedIn()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Attaches the bearer token to same-origin API calls and turns a 401 into a
|
||||
* redirect back to the login form.
|
||||
*
|
||||
* The old client put the token in the query string (`?token=...`), which leaks
|
||||
* it into server logs, browser history and Referer headers. It belongs in the
|
||||
* Authorization header.
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const token = auth.token();
|
||||
const isApiCall = req.url.startsWith('/api/');
|
||||
|
||||
const request =
|
||||
token && isApiCall
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && isApiCall) {
|
||||
// Expired or rejected token: drop it and bounce to login, remembering
|
||||
// where the user was trying to go.
|
||||
auth.logout();
|
||||
void router.navigate(['/login'], {
|
||||
queryParams: { returnUrl: router.url },
|
||||
});
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
import { AuthResponse, User } from './models';
|
||||
|
||||
interface StoredSession {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ludos.session';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly session = signal<StoredSession | null>(readStoredSession());
|
||||
|
||||
readonly user = computed(() => this.session()?.user ?? null);
|
||||
readonly token = computed(() => this.session()?.token ?? null);
|
||||
|
||||
/**
|
||||
* True only while a stored token is present and unexpired. Checking expiry
|
||||
* here means a stale token sends the user to the login form rather than
|
||||
* producing a wall of 401s.
|
||||
*/
|
||||
readonly isLoggedIn = computed(() => {
|
||||
const current = this.session();
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
return new Date(current.expiresAt).getTime() > Date.now();
|
||||
});
|
||||
|
||||
login(userName: string, password: string): Observable<AuthResponse> {
|
||||
return this.http
|
||||
.post<AuthResponse>('/api/auth/login', { userName, password })
|
||||
.pipe(tap((response) => this.store(response)));
|
||||
}
|
||||
|
||||
register(payload: {
|
||||
userName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}): Observable<AuthResponse> {
|
||||
return this.http
|
||||
.post<AuthResponse>('/api/auth/register', payload)
|
||||
.pipe(tap((response) => this.store(response)));
|
||||
}
|
||||
|
||||
/** Availability check for the registration form; returns true when free. */
|
||||
isAvailable(field: 'userName' | 'email', value: string): Observable<{ available: boolean }> {
|
||||
return this.http.get<{ available: boolean }>('/api/auth/available', {
|
||||
params: { [field]: value },
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
this.session.set(null);
|
||||
}
|
||||
|
||||
private store(response: AuthResponse): void {
|
||||
const stored: StoredSession = {
|
||||
token: response.token,
|
||||
expiresAt: response.expiresAt,
|
||||
user: response.user,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
this.session.set(stored);
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSession(): StoredSession | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
return parsed?.token && parsed?.user ? parsed : null;
|
||||
} catch {
|
||||
// Corrupt or hand-edited storage should not wedge startup.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Facets, Game, GameQuery, GameRequest, PagedResult, UploadResponse } from './models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GamesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* Note what is absent: the caller never passes a user id. Ownership is taken
|
||||
* from the bearer token server-side, so there is no filter for a client to
|
||||
* tamper with the way there was in the old php-crud-api query string.
|
||||
*/
|
||||
list(query: GameQuery): Observable<PagedResult<Game>> {
|
||||
let params = new HttpParams();
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<PagedResult<Game>>('/api/games', { params });
|
||||
}
|
||||
|
||||
get(id: number): Observable<Game> {
|
||||
return this.http.get<Game>(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
facets(): Observable<Facets> {
|
||||
return this.http.get<Facets>('/api/games/facets');
|
||||
}
|
||||
|
||||
create(game: GameRequest): Observable<Game> {
|
||||
return this.http.post<Game>('/api/games', game);
|
||||
}
|
||||
|
||||
update(id: number, game: GameRequest): Observable<Game> {
|
||||
return this.http.put<Game>(`/api/games/${id}`, game);
|
||||
}
|
||||
|
||||
remove(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
uploadArt(file: File): Observable<UploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
return this.http.post<UploadResponse>('/api/images', form);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Mirrors the API contracts in backend/src/LudosData.Api/Contracts. */
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
title: string;
|
||||
system: string | null;
|
||||
genre: string | null;
|
||||
year: string | null;
|
||||
developer: string | null;
|
||||
publisher: string | null;
|
||||
/** Stored filename. */
|
||||
art: string | null;
|
||||
/** Ready-to-use URL, built by the API. Null when no art was uploaded. */
|
||||
artUrl: string | null;
|
||||
description: string | null;
|
||||
own: boolean;
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Create/update payload. No id and no owner — the API derives both. */
|
||||
export interface GameRequest {
|
||||
title: string;
|
||||
system: string | null;
|
||||
genre: string | null;
|
||||
year: string | null;
|
||||
developer: string | null;
|
||||
publisher: string | null;
|
||||
art: string | null;
|
||||
description: string | null;
|
||||
own: boolean;
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface GameQuery {
|
||||
search?: string;
|
||||
system?: string;
|
||||
genre?: string;
|
||||
own?: boolean;
|
||||
dumped?: boolean;
|
||||
played?: boolean;
|
||||
finished?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
dir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface Facets {
|
||||
systems: string[];
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
userName: string;
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
art: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
fileName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** RFC 7807 body returned by the API for validation failures. */
|
||||
export interface ProblemDetails {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
status?: number;
|
||||
errors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const EMPTY_GAME: GameRequest = {
|
||||
title: '',
|
||||
system: null,
|
||||
genre: null,
|
||||
year: null,
|
||||
developer: null,
|
||||
publisher: null,
|
||||
art: null,
|
||||
description: null,
|
||||
own: true,
|
||||
dumped: false,
|
||||
played: false,
|
||||
finished: false,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
imports: [Toolbar, RouterLink, MatCardModule, MatIconModule, MatButtonModule, MatListModule],
|
||||
template: `
|
||||
<app-toolbar />
|
||||
|
||||
<div class="page">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Account</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
@if (user(); as currentUser) {
|
||||
<mat-list>
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>person</mat-icon>
|
||||
<div matListItemTitle>{{ currentUser.userName }}</div>
|
||||
<div matListItemLine>Username</div>
|
||||
</mat-list-item>
|
||||
|
||||
@if (currentUser.email) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>mail</mat-icon>
|
||||
<div matListItemTitle>{{ currentUser.email }}</div>
|
||||
<div matListItemLine>Email</div>
|
||||
</mat-list-item>
|
||||
}
|
||||
|
||||
@if (currentUser.firstName || currentUser.lastName) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>badge</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ currentUser.firstName }} {{ currentUser.lastName }}
|
||||
</div>
|
||||
<div matListItemLine>Name</div>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
}
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<a mat-button routerLink="/games">Back to library</a>
|
||||
<button mat-button (click)="logout()">Sign out</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.page {
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Account {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly user = this.auth.user;
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<app-toolbar />
|
||||
|
||||
@if (loading() || saving()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
@if (loadFailed()) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<h2>Game not found</h2>
|
||||
<p>It may have been removed, or it belongs to another account.</p>
|
||||
<a mat-flat-button color="primary" routerLink="/games">Back to library</a>
|
||||
</div>
|
||||
} @else {
|
||||
<form class="editor" [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<!-- Left column: art and actions -->
|
||||
<mat-card class="art-panel">
|
||||
<div class="art-frame">
|
||||
@if (artPreview(); as preview) {
|
||||
<img [src]="preview" alt="Box art preview" />
|
||||
} @else {
|
||||
<div class="art-placeholder">
|
||||
<mat-icon>image</mat-icon>
|
||||
<span>No box art</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (uploading()) {
|
||||
<div class="art-overlay"><mat-progress-bar mode="indeterminate" /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
|
||||
<div class="art-actions">
|
||||
<button mat-stroked-button type="button" (click)="fileInput.click()" [disabled]="uploading()">
|
||||
<mat-icon>upload</mat-icon>
|
||||
{{ uploading() ? 'Uploading…' : 'Upload art' }}
|
||||
</button>
|
||||
|
||||
@if (artPreview()) {
|
||||
<button mat-button type="button" (click)="removeArt()" [disabled]="uploading()">
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Right column: fields -->
|
||||
<mat-card class="fields-panel">
|
||||
<h1 class="editor-title">{{ isEdit() ? 'Edit game' : 'New game' }}</h1>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput formControlName="title" required />
|
||||
@if (form.controls.title.touched && form.controls.title.invalid) {
|
||||
<mat-error>Title is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>System</mat-label>
|
||||
<mat-select formControlName="system">
|
||||
<mat-option value="">—</mat-option>
|
||||
@for (option of systems; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Genre</mat-label>
|
||||
<mat-select formControlName="genre">
|
||||
<mat-option value="">—</mat-option>
|
||||
@for (option of genres; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Year</mat-label>
|
||||
<input matInput formControlName="year" inputmode="numeric" placeholder="1998" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Developer</mat-label>
|
||||
<input matInput formControlName="developer" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Publisher</mat-label>
|
||||
<input matInput formControlName="publisher" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="4"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<fieldset class="flags">
|
||||
<legend>Collection status</legend>
|
||||
<mat-checkbox formControlName="own">Own</mat-checkbox>
|
||||
<mat-checkbox formControlName="dumped">Dumped</mat-checkbox>
|
||||
<mat-checkbox formControlName="played">Played</mat-checkbox>
|
||||
<mat-checkbox formControlName="finished">Finished</mat-checkbox>
|
||||
</fieldset>
|
||||
|
||||
<div class="editor-actions">
|
||||
<a mat-button routerLink="/games">Cancel</a>
|
||||
|
||||
@if (isEdit()) {
|
||||
<button mat-button type="button" class="danger" (click)="confirmRemove()" [disabled]="saving()">
|
||||
<mat-icon>delete_outline</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="saving() || uploading()">
|
||||
{{ saving() ? 'Saving…' : isEdit() ? 'Save changes' : 'Add game' }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
.editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
padding: 1.5rem;
|
||||
max-width: 68rem;
|
||||
margin-inline: auto;
|
||||
|
||||
@media (max-width: 899px) {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.art-panel {
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
|
||||
@media (max-width: 899px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.art-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
color: var(--mat-sys-outline);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.art-overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
}
|
||||
|
||||
.art-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.fields-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
@media (max-width: 599px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
|
||||
legend {
|
||||
padding-inline: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { GameEdit } from './game-edit';
|
||||
|
||||
/**
|
||||
* These exist mainly to prove the Material components in this screen construct
|
||||
* and render without an animations provider. @angular/animations is deprecated
|
||||
* in v22 and Material no longer depends on it, but MatSelect, MatDialog and
|
||||
* MatCheckbox historically did — so this is worth pinning down in a test rather
|
||||
* than assuming.
|
||||
*/
|
||||
describe('GameEdit', () => {
|
||||
let fixture: ComponentFixture<GameEdit>;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GameEdit],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GameEdit);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
it('renders the new-game form without an animations provider', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
expect(host.querySelector('.editor')).toBeTruthy();
|
||||
expect(host.textContent).toContain('New game');
|
||||
});
|
||||
|
||||
it('treats a missing route id as create mode and issues no fetch', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component['isEdit']()).toBe(false);
|
||||
|
||||
// Nothing to load when creating, so no outbound request should exist.
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('builds a request payload that turns blank fields into null', () => {
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['form'].patchValue({
|
||||
title: ' Chrono Trigger ',
|
||||
system: 'SNES',
|
||||
genre: '',
|
||||
year: '1995',
|
||||
developer: ' ',
|
||||
finished: true,
|
||||
});
|
||||
|
||||
const payload = component['toRequest']();
|
||||
|
||||
expect(payload.title).toBe('Chrono Trigger');
|
||||
expect(payload.system).toBe('SNES');
|
||||
expect(payload.genre).toBeNull();
|
||||
expect(payload.developer).toBeNull();
|
||||
expect(payload.year).toBe('1995');
|
||||
expect(payload.finished).toBe(true);
|
||||
// The payload must never carry an owner: the API takes it from the token.
|
||||
expect('ownerId' in payload).toBe(false);
|
||||
expect('userId' in payload).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a title', () => {
|
||||
fixture.detectChanges();
|
||||
const form = fixture.componentInstance['form'];
|
||||
|
||||
expect(form.valid).toBe(false);
|
||||
form.patchValue({ title: 'Metroid' });
|
||||
expect(form.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../shared/confirm-dialog';
|
||||
import { GamesService } from '../../core/games.service';
|
||||
import { GameRequest } from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
/** Kept in sync with the values already present in the 2018 data. */
|
||||
const SYSTEMS = [
|
||||
'NES', 'SNES', 'N64', 'GC', 'WII', 'GB', 'GBA', 'DS',
|
||||
'PS1', 'PS2', 'PSP', '360', 'PC',
|
||||
];
|
||||
|
||||
const GENRES = [
|
||||
'action', 'adventure', 'card', 'fighter', 'fps', 'lightgun',
|
||||
'platformer', 'racing', 'rpg', 'simulation', 'sports', 'strategy',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-edit',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
Toolbar,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
],
|
||||
templateUrl: './game-edit.html',
|
||||
styleUrl: './game-edit.scss',
|
||||
})
|
||||
export class GameEdit {
|
||||
private readonly games = inject(GamesService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
/** Bound from the `:id` route param by withComponentInputBinding(). */
|
||||
readonly id = input<string | undefined>();
|
||||
|
||||
protected readonly systems = SYSTEMS;
|
||||
protected readonly genres = GENRES;
|
||||
|
||||
protected readonly isEdit = computed(() => !!this.id());
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly uploading = signal(false);
|
||||
protected readonly loadFailed = signal(false);
|
||||
|
||||
/** Preview URL: a freshly-picked local file, or the stored art from the API. */
|
||||
protected readonly artPreview = signal<string | null>(null);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
title: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
system: [''],
|
||||
genre: [''],
|
||||
year: ['', Validators.maxLength(50)],
|
||||
developer: ['', Validators.maxLength(100)],
|
||||
publisher: ['', Validators.maxLength(100)],
|
||||
description: [''],
|
||||
art: [''],
|
||||
own: [true],
|
||||
dumped: [false],
|
||||
played: [false],
|
||||
finished: [false],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// input() is a signal, so this reacts if the route id ever changes without
|
||||
// the component being torn down.
|
||||
queueMicrotask(() => this.load());
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
const gameId = this.id();
|
||||
if (!gameId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.games.get(Number(gameId)).subscribe({
|
||||
next: (game) => {
|
||||
this.form.patchValue({
|
||||
title: game.title,
|
||||
system: game.system ?? '',
|
||||
genre: game.genre ?? '',
|
||||
year: game.year ?? '',
|
||||
developer: game.developer ?? '',
|
||||
publisher: game.publisher ?? '',
|
||||
description: game.description ?? '',
|
||||
art: game.art ?? '',
|
||||
own: game.own,
|
||||
dumped: game.dumped,
|
||||
played: game.played,
|
||||
finished: game.finished,
|
||||
});
|
||||
this.artPreview.set(game.artUrl);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loadFailed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the local file straight away rather than waiting for the round trip.
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.artPreview.set(String(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
this.uploading.set(true);
|
||||
this.games.uploadArt(file).subscribe({
|
||||
next: (result) => {
|
||||
this.form.controls.art.setValue(result.fileName);
|
||||
this.artPreview.set(result.url);
|
||||
this.uploading.set(false);
|
||||
// Clear the input so picking the same file again still fires a change.
|
||||
input.value = '';
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.uploading.set(false);
|
||||
this.artPreview.set(null);
|
||||
input.value = '';
|
||||
this.snackBar.open(
|
||||
err.status === 400 ? 'That file could not be read as an image.' : 'Upload failed.',
|
||||
'Dismiss',
|
||||
{ duration: 5000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected removeArt(): void {
|
||||
this.form.controls.art.setValue('');
|
||||
this.artPreview.set(null);
|
||||
}
|
||||
|
||||
protected submit(): void {
|
||||
if (this.form.invalid || this.saving()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
const payload = this.toRequest();
|
||||
|
||||
const request = this.isEdit()
|
||||
? this.games.update(Number(this.id()), payload)
|
||||
: this.games.create(payload);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(this.isEdit() ? 'Game updated' : 'Game added', undefined, {
|
||||
duration: 3000,
|
||||
});
|
||||
void this.router.navigate(['/games']);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Could not save the game.', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected confirmRemove(): void {
|
||||
const data: ConfirmDialogData = {
|
||||
title: 'Remove this game?',
|
||||
message: `"${this.form.controls.title.value}" will be deleted from your library. This cannot be undone.`,
|
||||
confirmLabel: 'Remove',
|
||||
destructive: true,
|
||||
};
|
||||
|
||||
this.dialog
|
||||
.open(ConfirmDialog, { data, width: '24rem' })
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private remove(): void {
|
||||
this.saving.set(true);
|
||||
this.games.remove(Number(this.id())).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Game removed', undefined, { duration: 3000 });
|
||||
void this.router.navigate(['/games']);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Could not remove the game.', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Empty strings become null, matching the API's nullable columns. */
|
||||
private toRequest(): GameRequest {
|
||||
const value = this.form.getRawValue();
|
||||
const blankToNull = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
return {
|
||||
title: value.title.trim(),
|
||||
system: blankToNull(value.system),
|
||||
genre: blankToNull(value.genre),
|
||||
year: blankToNull(value.year),
|
||||
developer: blankToNull(value.developer),
|
||||
publisher: blankToNull(value.publisher),
|
||||
description: blankToNull(value.description),
|
||||
art: blankToNull(value.art),
|
||||
own: value.own,
|
||||
dumped: value.dumped,
|
||||
played: value.played,
|
||||
finished: value.finished,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<app-toolbar>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
placeholder="Search title, developer, publisher"
|
||||
[ngModel]="search()"
|
||||
(ngModelChange)="onSearch($event)"
|
||||
aria-label="Search games"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</app-toolbar>
|
||||
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>System</mat-label>
|
||||
<mat-select
|
||||
[ngModel]="system()"
|
||||
(ngModelChange)="system.set($event); onFilterChange()"
|
||||
>
|
||||
<mat-option value="">All systems</mat-option>
|
||||
@for (option of facets().systems; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Genre</mat-label>
|
||||
<mat-select [ngModel]="genre()" (ngModelChange)="genre.set($event); onFilterChange()">
|
||||
<mat-option value="">All genres</mat-option>
|
||||
@for (option of facets().genres; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [ngModel]="status()" (ngModelChange)="status.set($event); onFilterChange()">
|
||||
<mat-option value="">Any status</mat-option>
|
||||
<mat-option value="own">Owned</mat-option>
|
||||
<mat-option value="dumped">Dumped</mat-option>
|
||||
<mat-option value="played">Played</mat-option>
|
||||
<mat-option value="finished">Finished</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Sort by</mat-label>
|
||||
<mat-select [ngModel]="sort()" (ngModelChange)="sort.set($event)">
|
||||
<mat-option value="title">Title</mat-option>
|
||||
<mat-option value="system">System</mat-option>
|
||||
<mat-option value="genre">Genre</mat-option>
|
||||
<mat-option value="year">Year</mat-option>
|
||||
<mat-option value="created">Date added</mat-option>
|
||||
<mat-option value="updated">Last updated</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleDirection()"
|
||||
[attr.aria-label]="dir() === 'asc' ? 'Sort descending' : 'Sort ascending'"
|
||||
>
|
||||
<mat-icon>{{ dir() === 'asc' ? 'arrow_upward' : 'arrow_downward' }}</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (hasFilters()) {
|
||||
<button mat-button (click)="clearFilters()">
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (failed()) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<h2>Could not load your library</h2>
|
||||
<p>The server did not respond. Check that the API is running, then try again.</p>
|
||||
</div>
|
||||
} @else if (!loading() && items().length === 0) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>videogame_asset_off</mat-icon>
|
||||
@if (hasFilters()) {
|
||||
<h2>No games match those filters</h2>
|
||||
<button mat-button (click)="clearFilters()">Clear filters</button>
|
||||
} @else {
|
||||
<h2>Your library is empty</h2>
|
||||
<a mat-flat-button color="primary" routerLink="/games/new">Add your first game</a>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid">
|
||||
@for (game of items(); track game.id) {
|
||||
<mat-card class="game-card" [routerLink]="['/games', game.id]" tabindex="0">
|
||||
<div class="art">
|
||||
@if (game.artUrl) {
|
||||
<img [src]="game.artUrl" [alt]="game.title + ' box art'" loading="lazy" />
|
||||
} @else {
|
||||
<div class="art-placeholder" aria-hidden="true">
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<h3 class="title" [title]="game.title">{{ game.title }}</h3>
|
||||
<p class="meta">
|
||||
@if (game.system) {
|
||||
<span class="system">{{ game.system }}</span>
|
||||
}
|
||||
@if (game.year) {
|
||||
<span class="year">{{ game.year }}</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (badges(game).length) {
|
||||
<div class="badges">
|
||||
@for (badge of badges(game); track badge) {
|
||||
<span class="badge badge--{{ badge.toLowerCase() }}">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="total()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="page() - 1"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPage($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
.search-field {
|
||||
width: min(28rem, 40vw);
|
||||
|
||||
@media (max-width: 899px) {
|
||||
width: 12rem;
|
||||
}
|
||||
@media (max-width: 599px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
|
||||
mat-form-field {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
padding-inline: 1rem;
|
||||
mat-form-field {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
/* Cards size themselves; no masonry library required for this. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
@media (max-width: 599px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.game-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mat-sys-level3);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.art {
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
color: var(--mat-sys-outline);
|
||||
|
||||
mat-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
/* Two lines, then ellipsis — keeps every card the same height. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.system {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
&--finished {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
}
|
||||
&--played {
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
color: var(--mat-sys-on-tertiary-container);
|
||||
}
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Subject, debounceTime, switchMap } from 'rxjs';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { GamesService } from '../../core/games.service';
|
||||
import { Facets, Game } from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-grid',
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
Toolbar,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatChipsModule,
|
||||
],
|
||||
templateUrl: './game-grid.html',
|
||||
styleUrl: './game-grid.scss',
|
||||
})
|
||||
export class GameGrid {
|
||||
private readonly games = inject(GamesService);
|
||||
|
||||
protected readonly search = signal('');
|
||||
protected readonly system = signal<string>('');
|
||||
protected readonly genre = signal<string>('');
|
||||
protected readonly status = signal<'' | 'own' | 'played' | 'finished' | 'dumped'>('');
|
||||
protected readonly sort = signal<SortKey>('title');
|
||||
protected readonly dir = signal<'asc' | 'desc'>('asc');
|
||||
|
||||
protected readonly page = signal(1);
|
||||
protected readonly pageSize = signal(24);
|
||||
protected readonly pageSizeOptions = [12, 24, 48, 96];
|
||||
|
||||
protected readonly items = signal<Game[]>([]);
|
||||
protected readonly total = signal(0);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
protected readonly facets = toSignal(this.games.facets(), {
|
||||
initialValue: { systems: [], genres: [] } as Facets,
|
||||
});
|
||||
|
||||
protected readonly hasFilters = computed(
|
||||
() => !!this.search() || !!this.system() || !!this.genre() || !!this.status(),
|
||||
);
|
||||
|
||||
/** Debounced trigger, so typing in the search box does not fire a request per keystroke. */
|
||||
private readonly reload$ = new Subject<void>();
|
||||
|
||||
constructor() {
|
||||
this.reload$
|
||||
.pipe(
|
||||
// Collapses bursts of signal changes (typing, or a filter that also
|
||||
// resets the page) into one request. switchMap then cancels any
|
||||
// in-flight response that a newer query has superseded.
|
||||
debounceTime(250),
|
||||
switchMap(() => {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
return this.games.list({
|
||||
search: this.search().trim() || undefined,
|
||||
system: this.system() || undefined,
|
||||
genre: this.genre() || undefined,
|
||||
...this.statusFilter(),
|
||||
page: this.page(),
|
||||
pageSize: this.pageSize(),
|
||||
sort: this.sort(),
|
||||
dir: this.dir(),
|
||||
});
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.items.set(result.items);
|
||||
this.total.set(result.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Any filter change resets to page 1 and refetches.
|
||||
effect(() => {
|
||||
this.search();
|
||||
this.system();
|
||||
this.genre();
|
||||
this.status();
|
||||
this.sort();
|
||||
this.dir();
|
||||
this.pageSize();
|
||||
this.page();
|
||||
this.reload$.next();
|
||||
});
|
||||
}
|
||||
|
||||
private statusFilter(): Record<string, boolean | undefined> {
|
||||
const value = this.status();
|
||||
return value ? { [value]: true } : {};
|
||||
}
|
||||
|
||||
protected onSearch(value: string): void {
|
||||
this.page.set(1);
|
||||
this.search.set(value);
|
||||
}
|
||||
|
||||
protected onFilterChange(): void {
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected onPage(event: PageEvent): void {
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.page.set(event.pageIndex + 1);
|
||||
}
|
||||
|
||||
protected clearFilters(): void {
|
||||
this.page.set(1);
|
||||
this.search.set('');
|
||||
this.system.set('');
|
||||
this.genre.set('');
|
||||
this.status.set('');
|
||||
}
|
||||
|
||||
protected toggleDirection(): void {
|
||||
this.dir.set(this.dir() === 'asc' ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
/** Badges shown on each card for the four collection flags. */
|
||||
protected badges(game: Game): string[] {
|
||||
const flags: string[] = [];
|
||||
if (game.own) flags.push('Own');
|
||||
if (game.dumped) flags.push('Dumped');
|
||||
if (game.played) flags.push('Played');
|
||||
if (game.finished) flags.push('Finished');
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="auth-shell">
|
||||
<mat-card class="auth-card">
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
LudosData
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>Sign in to your game library</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="userName"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
@if (form.controls.userName.touched && form.controls.userName.invalid) {
|
||||
<mat-error>Username is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
[attr.aria-label]="showPassword() ? 'Hide password' : 'Show password'"
|
||||
>
|
||||
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (form.controls.password.touched && form.controls.password.invalid) {
|
||||
<mat-error>Password is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="form-error" role="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<span>No account yet?</span>
|
||||
<a mat-button routerLink="/register">Create one</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100dvh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 26rem);
|
||||
overflow: hidden;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
margin-top: 0.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 0.875rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss',
|
||||
})
|
||||
export class Login {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
/** Set by the router when the guard bounced an unauthenticated deep link. */
|
||||
readonly returnUrl = signal<string>('/games');
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly showPassword = signal(false);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
userName: ['', Validators.required],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const url = new URLSearchParams(window.location.search).get('returnUrl');
|
||||
if (url?.startsWith('/')) {
|
||||
// Only same-origin paths, so a crafted link cannot bounce through login
|
||||
// to an external site.
|
||||
this.returnUrl.set(url);
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.loading()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const { userName, password } = this.form.getRawValue();
|
||||
|
||||
this.auth.login(userName, password).subscribe({
|
||||
next: () => void this.router.navigateByUrl(this.returnUrl()),
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(
|
||||
err.status === 423
|
||||
? 'Too many failed attempts. Try again in 15 minutes.'
|
||||
: 'Incorrect username or password.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<div class="auth-shell">
|
||||
<mat-card class="auth-card">
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
Create account
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>Start cataloguing your collection</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<div class="name-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>First name</mat-label>
|
||||
<input matInput formControlName="firstName" autocomplete="given-name" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Last name</mat-label>
|
||||
<input matInput formControlName="lastName" autocomplete="family-name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="userName"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
@if (form.controls.userName.pending) {
|
||||
<mat-hint>Checking availability…</mat-hint>
|
||||
}
|
||||
@if (form.controls.userName.touched) {
|
||||
@if (form.controls.userName.hasError('required')) {
|
||||
<mat-error>Username is required</mat-error>
|
||||
} @else if (form.controls.userName.hasError('minlength')) {
|
||||
<mat-error>At least 3 characters</mat-error>
|
||||
} @else if (form.controls.userName.hasError('taken')) {
|
||||
<mat-error>That username is already taken</mat-error>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput formControlName="email" type="email" autocomplete="email" required />
|
||||
@if (form.controls.email.pending) {
|
||||
<mat-hint>Checking availability…</mat-hint>
|
||||
}
|
||||
@if (form.controls.email.touched) {
|
||||
@if (form.controls.email.hasError('required')) {
|
||||
<mat-error>Email is required</mat-error>
|
||||
} @else if (form.controls.email.hasError('email')) {
|
||||
<mat-error>Enter a valid email address</mat-error>
|
||||
} @else if (form.controls.email.hasError('taken')) {
|
||||
<mat-error>That email is already registered</mat-error>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
[attr.aria-label]="showPassword() ? 'Hide password' : 'Show password'"
|
||||
>
|
||||
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (form.controls.password.touched && form.controls.password.invalid) {
|
||||
<mat-error>At least 12 characters, with upper, lower and a number</mat-error>
|
||||
} @else {
|
||||
<mat-hint>At least 12 characters, with upper, lower and a number</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="form-error" role="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Creating…' : 'Create account' }}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<span>Already registered?</span>
|
||||
<a mat-button routerLink="/login">Sign in</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
AsyncValidatorFn,
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Observable, catchError, debounceTime, first, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { ProblemDetails } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Async "is this taken?" validator.
|
||||
*
|
||||
* The 2018 version asked the generic CRUD endpoint for the whole users row to
|
||||
* answer this, which exposed every user column to anonymous callers. The API
|
||||
* now has a dedicated endpoint that returns only a boolean.
|
||||
*/
|
||||
function availabilityValidator(
|
||||
auth: AuthService,
|
||||
field: 'userName' | 'email',
|
||||
): AsyncValidatorFn {
|
||||
return (control: AbstractControl): Observable<ValidationErrors | null> => {
|
||||
const value = String(control.value ?? '').trim();
|
||||
if (!value) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return of(value).pipe(
|
||||
debounceTime(400),
|
||||
switchMap((v) => auth.isAvailable(field, v)),
|
||||
map((result) => (result.available ? null : { taken: true })),
|
||||
// A failed availability check should not block submission; the server
|
||||
// enforces uniqueness regardless.
|
||||
catchError(() => of(null)),
|
||||
first(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './register.html',
|
||||
styleUrl: '../login/login.scss',
|
||||
})
|
||||
export class Register {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly showPassword = signal(false);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
firstName: [''],
|
||||
lastName: [''],
|
||||
userName: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(3), Validators.maxLength(50)],
|
||||
[availabilityValidator(this.auth, 'userName')],
|
||||
],
|
||||
email: [
|
||||
'',
|
||||
[Validators.required, Validators.email, Validators.maxLength(256)],
|
||||
[availabilityValidator(this.auth, 'email')],
|
||||
],
|
||||
// Mirrors the server's Identity policy, so the rules are visible before
|
||||
// submitting rather than coming back as an error.
|
||||
password: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(12),
|
||||
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.loading()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const value = this.form.getRawValue();
|
||||
|
||||
this.auth
|
||||
.register({
|
||||
userName: value.userName.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
firstName: value.firstName.trim() || undefined,
|
||||
lastName: value.lastName.trim() || undefined,
|
||||
})
|
||||
.subscribe({
|
||||
// Registration returns a token, so the new account lands straight in
|
||||
// the library instead of being bounced back to the login form.
|
||||
next: () => void this.router.navigate(['/games']),
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(describeProblem(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function describeProblem(err: HttpErrorResponse): string {
|
||||
const problem = err.error as ProblemDetails | undefined;
|
||||
|
||||
if (problem?.errors) {
|
||||
const messages = Object.values(problem.errors).flat();
|
||||
if (messages.length) {
|
||||
return messages.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return problem?.title ?? 'Could not create the account. Please try again.';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
/** Replaces the old pattern of deleting immediately on click with no confirmation. */
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [MatDialogModule, MatButtonModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>{{ data.message }}</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
[color]="data.destructive ? 'warn' : 'primary'"
|
||||
(click)="dialogRef.close(true)"
|
||||
cdkFocusInitial
|
||||
>
|
||||
{{ data.confirmLabel ?? 'Confirm' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly dialogRef = inject(MatDialogRef<ConfirmDialog, boolean>);
|
||||
readonly data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../core/auth.service';
|
||||
|
||||
/** App bar shared by the library and editor screens. */
|
||||
@Component({
|
||||
selector: 'app-toolbar',
|
||||
imports: [MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, RouterLink],
|
||||
template: `
|
||||
<mat-toolbar color="primary" class="toolbar">
|
||||
<a class="brand" routerLink="/games">
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
<span class="brand-text">LudosData</span>
|
||||
</a>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<ng-content />
|
||||
|
||||
<a mat-button routerLink="/games/new">
|
||||
<mat-icon>add</mat-icon>
|
||||
<span class="label-md">New game</span>
|
||||
</a>
|
||||
|
||||
<button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Account menu">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
@if (user(); as currentUser) {
|
||||
<div class="menu-header">
|
||||
<strong>{{ currentUser.userName }}</strong>
|
||||
@if (currentUser.email) {
|
||||
<small>{{ currentUser.email }}</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<a mat-menu-item routerLink="/account">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>Account</span>
|
||||
</a>
|
||||
<button mat-menu-item (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</mat-toolbar>
|
||||
`,
|
||||
styles: `
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.menu-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.menu-header small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Keep the bar usable on a phone: icons stay, text labels drop out. */
|
||||
@media (max-width: 599px) {
|
||||
.brand-text,
|
||||
.label-md {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Toolbar {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly user = this.auth.user;
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user