feat: initial prototype works

I can log in and create a book! Woo!
This commit is contained in:
2026-02-04 12:17:05 -08:00
parent e167a17bd9
commit b6d66d34cb
44 changed files with 3695 additions and 493 deletions
@@ -0,0 +1,451 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GamesService } from '../../services/games.service';
import { AuthService } from '../../services/auth.service';
import { Game, GameStatus, CreateGameDto } from '@library/shared-types';
@Component({
selector: 'app-games-list',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="container">
<div class="header-section">
<h2>My Game Collection</h2>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Game' }}
</button>
}
</div>
@if (showAddForm() && authService.isAdmin()) {
<form (ngSubmit)="addGame()" class="add-form">
<h3>Add New Game</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="newGame.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="platform">Platform</label>
<input
type="text"
id="platform"
[(ngModel)]="newGame.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="newGame.status" name="status" required>
<option value="playing">Currently Playing</option>
<option value="completed">Completed</option>
<option value="backlog">In Backlog</option>
</select>
</div>
<div class="form-group">
<label for="rating">Rating (0-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="newGame.rating"
name="rating"
min="0"
max="10"
>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="newGame.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the game..."
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Game</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="filters">
<button
(click)="setFilter('all')"
[class.active]="statusFilter() === 'all'"
class="filter-btn"
>
All ({{ games().length }})
</button>
<button
(click)="setFilter(GameStatus.playing)"
[class.active]="statusFilter() === GameStatus.playing"
class="filter-btn"
>
Playing ({{ getCountByStatus(GameStatus.playing) }})
</button>
<button
(click)="setFilter(GameStatus.completed)"
[class.active]="statusFilter() === GameStatus.completed"
class="filter-btn"
>
Completed ({{ getCountByStatus(GameStatus.completed) }})
</button>
<button
(click)="setFilter(GameStatus.backlog)"
[class.active]="statusFilter() === GameStatus.backlog"
class="filter-btn"
>
Backlog ({{ getCountByStatus(GameStatus.backlog) }})
</button>
</div>
@if (loading()) {
<div class="loading">Loading games...</div>
} @else if (filteredGames().length === 0) {
<div class="empty-state">
<p>No games found in this category.</p>
</div>
} @else {
<div class="games-grid">
@for (game of filteredGames(); track game.id) {
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
@if (game.coverImage) {
<img [src]="game.coverImage" [alt]="game.title" class="game-cover">
}
<div class="game-info">
<h3>{{ game.title }}</h3>
@if (game.platform) {
<p class="platform">{{ game.platform }}</p>
}
<span class="status status-{{ game.status }}">
{{ getStatusLabel(game.status) }}
</span>
@if (game.rating) {
<div class="rating">
⭐ {{ game.rating }}/10
</div>
}
@if (game.notes) {
<p class="notes">{{ game.notes }}</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
</div>
</div>
}
</div>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h2 {
margin: 0;
}
.add-form {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.filter-btn {
padding: 0.5rem 1rem;
background: #e5e7eb;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover {
background: #d1d5db;
}
.filter-btn.active {
background: #3b82f6;
color: white;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.game-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s, box-shadow 0.3s;
}
.game-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.game-card.completed {
opacity: 0.8;
}
.game-cover {
width: 100%;
height: 200px;
object-fit: cover;
}
.game-info {
padding: 1rem;
}
.game-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
}
.platform {
color: #666;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.status-playing { background: #fef3c7; color: #92400e; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-backlog { background: #e0e7ff; color: #3730a3; }
.rating {
margin: 0.5rem 0;
font-weight: 500;
}
.notes {
font-size: 0.9rem;
color: #4b5563;
margin: 0.5rem 0;
}
.actions {
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.3s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-secondary { background: #6b7280; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
`]
})
export class GamesListComponent implements OnInit {
gamesService = inject(GamesService);
authService = inject(AuthService);
games = signal<Game[]>([]);
loading = signal(true);
showAddForm = signal(false);
statusFilter = signal<'all' | GameStatus>('all');
// Expose GameStatus enum to template
GameStatus = GameStatus;
newGame: Partial<CreateGameDto> = {
title: '',
platform: '',
status: GameStatus.backlog,
rating: undefined,
notes: ''
};
ngOnInit() {
this.loadGames();
}
loadGames() {
this.loading.set(true);
this.gamesService.getAllGames().subscribe({
next: (games) => {
this.games.set(games);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
filteredGames() {
const filter = this.statusFilter();
if (filter === 'all') {
return this.games();
}
return this.games().filter(game => game.status === filter);
}
getCountByStatus(status: GameStatus): number {
return this.games().filter(game => game.status === status).length;
}
setFilter(filter: 'all' | GameStatus) {
this.statusFilter.set(filter);
}
getStatusLabel(status: GameStatus): string {
switch (status) {
case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog';
}
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newGame = {
title: '',
platform: '',
status: GameStatus.backlog,
rating: undefined,
notes: ''
};
}
addGame() {
if (!this.newGame.title || !this.newGame.status) return;
const gameToAdd: CreateGameDto = {
title: this.newGame.title,
platform: this.newGame.platform,
status: this.newGame.status,
rating: this.newGame.rating,
notes: this.newGame.notes
};
this.gamesService.createGame(gameToAdd).subscribe(() => {
this.loadGames();
this.toggleAddForm();
});
}
deleteGame(game: Game) {
if (confirm(`Are you sure you want to delete "${game.title}"?`)) {
this.gamesService.deleteGame(game.id).subscribe(() => {
this.loadGames();
});
}
}
}