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:
2026-08-03 18:46:33 -04:00
co-authored by Claude Opus 5
parent 10757575c2
commit 2a7d90b2d5
165 changed files with 14908 additions and 31942 deletions
+23
View File
@@ -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.
],
};
+50
View File
@@ -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' },
];
+9
View File
@@ -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 {}
+29
View File
@@ -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);
});
});
+41
View File
@@ -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);
}),
);
};
+92
View File
@@ -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;
}
}
+53
View File
@@ -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);
}
}
+108
View File
@@ -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;
}
}
+78
View File
@@ -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.';
}
+35
View File
@@ -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);
}
+100
View File
@@ -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']);
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LudosData</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Personal video game library and collection tracker." />
<meta name="color-scheme" content="light dark" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
+52
View File
@@ -0,0 +1,52 @@
@use '@angular/material' as mat;
// Material 3 theming. `color-scheme: light dark` lets a single theme definition
// follow the OS preference, so there is no separate dark stylesheet to maintain.
html {
color-scheme: light dark;
@include mat.theme(
(
color: (
primary: mat.$violet-palette,
tertiary: mat.$magenta-palette,
),
typography: Roboto,
density: 0,
)
);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font-family: var(--mat-sys-body-medium-font, Roboto, sans-serif);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--mat-sys-primary);
}
// Respect users who have asked for less motion.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}