Files
library/apps/frontend/src/app/components/games/games-list.component.ts
T
hikari ff0ae73fa7
Node.js CI / CI (push) Successful in 1m44s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m16s
feat: themed avatars and branding updates (#67)
## Summary

Added comprehensive avatar and branding updates across the library application:

### 🌸 Updated Main Branding
- New library-themed avatar (with playful "shh" gesture) for navigation icon
- Updated favicon and all PWA icons (10 sizes from 72x72 to 512x512)
- Added hero avatar to home page between title and subtitle
- All branding uses consistent circular styling with elegant hover effects

### 🎨 Media-Specific Avatars
Added unique themed avatars to each media list page:

- **🎮 Games**: Gaming setup with controller and LED lights (red #ff6b6b border)
- **📚 Books**: Reading in cozy library setting (brown #8b6f47 border)
- **🎵 Music**: Joyful with headphones and urban nightscape (blue #74b9ff border)
- **📺 Shows**: Relaxing with remote and theater curtains (pink #e84393 border)
- **📖 Manga**: Reading manga with shelves background (teal #00b894 border)
- **🎨 Art**: Art studio with paintbrush (yellow #fdcb6e border)

### ✨ Features
- 120x120px circular avatars with themed colour borders
- Smooth hover animations (scale + shadow effects)
- Centered hero sections at top of each list view
- Consistent styling across all media types
- Perfect integration with existing colour themes

### 📊 Technical Details
- All icons generated from source images at multiple resolutions
- Static assets served with correct MIME types
- Optimised image formats for performance
- Responsive design with proper accessibility attributes

## Test Plan

- [x] Verify navigation icon displays correctly in header
- [x] Check favicon appears in browser tabs
- [x] Test PWA icons on mobile devices
- [x] Confirm home page hero avatar renders properly
- [x] Verify all 6 media list avatars display with correct borders
- [x] Test hover animations on all avatars
- [x] Verify build succeeds
- [x] Check static assets serve with correct MIME types

🌸 Created with love by Hikari 💖

Reviewed-on: #67
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-20 21:11:05 -08:00

1723 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { GamesService } from '../../services/games.service';
import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-games-list',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/games-avatar.jpg" alt="Gaming avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Game Collection</h2>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Game' }}
</button>
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
<button (click)="toggleSuggestForm()" class="btn btn-primary">
{{ showSuggestForm() ? 'Cancel' : 'Suggest a 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]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="newGame.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-row">
<div class="form-group">
<label for="timeHours">Time Spent (Hours)</label>
<input
type="number"
id="timeHours"
[(ngModel)]="newGameTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateNewGameTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="newGameTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateNewGameTimeSpent()"
>
</div>
</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-group">
<label for="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="newGame.series"
name="series"
placeholder="e.g., The Legend of Zelda"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="newGame.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</div>
<div class="form-group">
<label for="coverImage">Box Art (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'new')"
>
@if (newGameImagePreview()) {
<div class="image-preview">
<img [src]="newGameImagePreview()" alt="Box art preview">
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of newGame.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., Steam)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</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>
}
@if (editingGame() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Game</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editGame.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
type="text"
id="edit-platform"
[(ngModel)]="editGame.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editGame.status" name="status" required>
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editGame.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-row">
<div class="form-group">
<label for="edit-timeHours">Time Spent (Hours)</label>
<input
type="number"
id="edit-timeHours"
[(ngModel)]="editGameTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateEditGameTimeSpent()"
>
</div>
<div class="form-group">
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="edit-timeMinutes"
[(ngModel)]="editGameTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateEditGameTimeSpent()"
>
</div>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editGame.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the game..."
></textarea>
</div>
<div class="form-group">
<label for="edit-series">Series (optional)</label>
<input
type="text"
id="edit-series"
[(ngModel)]="editGame.series"
name="series"
placeholder="e.g., The Legend of Zelda"
>
</div>
<div class="form-group">
<label for="edit-seriesOrder">Series Order (optional)</label>
<input
type="number"
id="edit-seriesOrder"
[(ngModel)]="editGame.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</div>
<div class="form-group">
<label for="edit-coverImage">Box Art (max 500KB)</label>
<input
type="file"
id="edit-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'edit')"
>
@if (editGameImagePreview()) {
<div class="image-preview">
<img [src]="editGameImagePreview()" alt="Box art preview">
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of editGame.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., Steam)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {
<form (ngSubmit)="submitSuggestion()" class="add-form suggest-form">
<h3>Suggest a Game</h3>
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the backlog!</p>
<div class="form-group">
<label for="suggest-title">Title</label>
<input
type="text"
id="suggest-title"
[(ngModel)]="suggestedGame.title"
name="title"
required
placeholder="Enter game title"
>
</div>
<div class="form-group">
<label for="suggest-platform">Platform</label>
<input
type="text"
id="suggest-platform"
[(ngModel)]="suggestedGame.platform"
name="platform"
placeholder="PC, PS5, Xbox, Switch, etc."
>
</div>
<div class="form-group">
<label for="suggest-notes">Notes (why should Naomi play this?)</label>
<textarea
id="suggest-notes"
[(ngModel)]="suggestedGame.notes"
name="notes"
rows="3"
placeholder="Tell Naomi why this game is worth playing..."
></textarea>
</div>
<div class="form-group">
<label for="suggest-coverImage">Box Art (max 500KB)</label>
<input
type="file"
id="suggest-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'suggest')"
>
@if (suggestGameImagePreview()) {
<div class="image-preview">
<img [src]="suggestGameImagePreview()" alt="Box art preview">
<button type="button" (click)="clearImage('suggest')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit Suggestion</button>
<button type="button" (click)="toggleSuggestForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="search-section" role="search" aria-label="Game search and filters">
<label for="game-search" class="sr-only">Search games</label>
<input
type="search"
id="game-search"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
name="search"
placeholder="Search by title, platform, or notes..."
class="search-input"
aria-label="Search by title, platform, or notes"
>
<button
(click)="toggleFilters()"
class="btn btn-secondary btn-sm"
[attr.aria-expanded]="showFilters()"
aria-controls="advanced-filters"
type="button"
>
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
@if (selectedTags().length > 0) {
<span aria-label="{{ selectedTags().length }} active filters">({{ selectedTags().length }})</span>
}
</button>
@if (searchQuery() || selectedTags().length > 0) {
<button (click)="clearFilters()" class="btn btn-secondary btn-sm" type="button">
Clear All Filters
</button>
}
</div>
@if (showFilters()) {
<div class="advanced-filters" id="advanced-filters" role="region" aria-label="Advanced filters">
<div class="filter-group">
<h3>Filter by Tags</h3>
<div class="tags-filter" role="group" aria-label="Tag filters">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
type="checkbox"
[checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)"
[attr.aria-label]="'Filter by ' + tag"
>
<span>{{ tag }}</span>
</label>
}
@empty {
<p class="no-tags">No tags available</p>
}
</div>
</div>
</div>
}
<div class="filters" role="group" aria-label="Filter games by status">
<button
(click)="setFilter('all')"
[class.active]="statusFilter() === 'all'"
[attr.aria-pressed]="statusFilter() === 'all'"
class="filter-btn"
type="button"
>
All ({{ games().length }})
</button>
<button
(click)="setFilter(GameStatus.playing)"
[class.active]="statusFilter() === GameStatus.playing"
[attr.aria-pressed]="statusFilter() === GameStatus.playing"
class="filter-btn"
type="button"
>
Playing ({{ playingCount() }})
</button>
<button
(click)="setFilter(GameStatus.completed)"
[class.active]="statusFilter() === GameStatus.completed"
[attr.aria-pressed]="statusFilter() === GameStatus.completed"
class="filter-btn"
type="button"
>
Completed ({{ completedCount() }})
</button>
<button
(click)="setFilter(GameStatus.backlog)"
[class.active]="statusFilter() === GameStatus.backlog"
[attr.aria-pressed]="statusFilter() === GameStatus.backlog"
class="filter-btn"
type="button"
>
Backlog ({{ backlogCount() }})
</button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
[attr.aria-pressed]="statusFilter() === GameStatus.retired"
class="filter-btn"
type="button"
>
Retired ({{ retiredCount() }})
</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 {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredGames()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="games-grid">
@for (game of paginatedGames(); track game.id) {
<div class="game-card" [class.completed]="game.status === GameStatus.completed">
<a [routerLink]="['/games', game.id]" class="card-link">
<img [src]="game.coverImage || '/assets/default-cover.jpg'" [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">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= game.rating!">★</span>
}
</div>
}
</div>
</a>
<div class="card-actions">
<app-like-button
entityType="game"
[entityId]="game.id"
></app-like-button>
@if (authService.isAdmin()) {
<div class="admin-actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteGame(game)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
</div>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredGames()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #ff6b6b;
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(255, 107, 107, 0.5);
}
.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;
}
.suggest-form {
border: 2px solid #ff6b6b;
background: #fff9f9;
}
.suggest-note {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
font-style: italic;
}
.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-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.search-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #ff6b6b;
}
.advanced-filters {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-group h4 {
margin: 0 0 0.75rem 0;
color: #374151;
font-size: 0.95rem;
font-weight: 600;
}
.tags-filter {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.tag-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 20px;
background: white;
transition: all 0.2s;
}
.tag-checkbox:hover {
border-color: #ff6b6b;
background: #fef2f2;
}
.tag-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.tag-checkbox span {
font-size: 0.875rem;
color: #374151;
}
.no-tags {
color: #6b7280;
font-style: italic;
font-size: 0.875rem;
}
.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: #ff6b6b;
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;
display: flex;
flex-direction: column;
}
.game-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.game-card.completed {
opacity: 0.8;
}
.card-link {
text-decoration: none;
color: inherit;
display: block;
flex: 1;
}
.card-link:hover h3 {
color: #ff6b6b;
}
.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;
transition: color 0.2s;
}
.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;
margin: 0.5rem 0;
}
.status-playing { background: #fef3c7; color: #92400e; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-backlog { background: #e0e7ff; color: #3730a3; }
.status-retired { background: #f3f4f6; color: #4b5563; }
.rating {
margin: 0.5rem 0;
}
.rating span {
color: #e5e7eb;
font-size: 1rem;
}
.rating span.filled {
color: #f59e0b;
}
.card-actions {
padding: 0.75rem 1rem;
border-top: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.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: #ff6b6b; 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; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
/* Tags and Links Styling for Forms */
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
}
.tags-input-container input {
border: none;
outline: none;
flex: 1;
min-width: 150px;
padding: 0.25rem;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #ff6b6b;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
margin-left: 0.25rem;
}
.tag-remove:hover {
color: #fecaca;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid #eee;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.image-preview {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 100px;
max-height: 150px;
border-radius: 4px;
border: 2px solid #e5e7eb;
}
.error-text {
color: #ef4444;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
input[type="file"] {
padding: 0.5rem;
border: 2px dashed #e5e7eb;
border-radius: 4px;
background: #f9fafb;
cursor: pointer;
}
input[type="file"]:hover {
border-color: #10b981;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
`]
})
export class GamesListComponent implements OnInit {
gamesService = inject(GamesService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
suggestionService = inject(SuggestionService);
games = signal<Game[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingGame = signal<Game | null>(null);
statusFilter = signal<'all' | GameStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Search and filter state
searchQuery = signal('');
selectedTags = signal<string[]>([]);
showFilters = signal(false);
// Comments state
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
editingCommentId = signal<string | null>(null);
editCommentContent = '';
// Image upload state
newGameImagePreview = signal<string | null>(null);
editGameImagePreview = signal<string | null>(null);
suggestGameImagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB
// Suggestion state
showSuggestForm = signal(false);
suggestedGame: { title: string; platform?: string; notes?: string; coverImage?: string } = {
title: '',
platform: '',
notes: '',
coverImage: undefined
};
// Expose GameStatus enum to template
GameStatus = GameStatus;
// Computed signals for reactive count updates
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
this.games().forEach(game => {
game.tags?.forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet).sort();
});
filteredGames = computed(() => {
let games = this.games();
// Apply status filter
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
games = games.filter(game => game.status === statusFilter);
}
// Apply search filter
const searchQuery = this.searchQuery().toLowerCase().trim();
if (searchQuery) {
games = games.filter(game =>
game.title.toLowerCase().includes(searchQuery) ||
game.platform?.toLowerCase().includes(searchQuery) ||
game.notes?.toLowerCase().includes(searchQuery)
);
}
// Apply tag filter
const selectedTags = this.selectedTags();
if (selectedTags.length > 0) {
games = games.filter(game =>
selectedTags.every(tag => game.tags?.includes(tag))
);
}
return games;
});
paginatedGames = computed(() => {
const games = this.filteredGames();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return games.slice(start, end);
});
totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
links: []
};
editGame: Partial<UpdateGameDto> = {};
// Time tracking state
newGameTimeHours = 0;
newGameTimeMinutes = 0;
editGameTimeHours = 0;
editGameTimeMinutes = 0;
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
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);
}
});
}
setFilter(filter: 'all' | GameStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
toggleTag(tag: string) {
const current = this.selectedTags();
if (current.includes(tag)) {
this.selectedTags.set(current.filter(t => t !== tag));
} else {
this.selectedTags.set([...current, tag]);
}
this.currentPage.set(1); // Reset to first page when tags change
}
clearFilters() {
this.searchQuery.set('');
this.selectedTags.set([]);
this.statusFilter.set('all');
this.currentPage.set(1);
}
toggleFilters() {
this.showFilters.update(v => !v);
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: GameStatus): string {
switch (status) {
case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog';
case GameStatus.retired: return 'Retired';
}
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newGame = {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
tags: [],
links: []
};
this.newGameTimeHours = 0;
this.newGameTimeMinutes = 0;
this.newGameImagePreview.set(null);
this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
updateNewGameTimeSpent() {
const totalMinutes = (this.newGameTimeHours * 60) + this.newGameTimeMinutes;
this.newGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
updateEditGameTimeSpent() {
const totalMinutes = (this.editGameTimeHours * 60) + this.editGameTimeMinutes;
this.editGame.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newGame.tags = [...(this.newGame.tags || []), input];
this.newTagInput = '';
} else {
this.editGame.tags = [...(this.editGame.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newGame.tags = (this.newGame.tags || []).filter((_, i) => i !== index);
} else {
this.editGame.tags = (this.editGame.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newGame.links = [...(this.newGame.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editGame.links = [...(this.editGame.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newGame.links = (this.newGame.links || []).filter((_, i) => i !== index);
} else {
this.editGame.links = (this.editGame.links || []).filter((_, i) => i !== index);
}
}
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,
coverImage: this.newGame.coverImage,
dateStarted: this.newGame.dateStarted ? new Date(this.newGame.dateStarted) : undefined,
dateFinished: this.newGame.dateFinished ? new Date(this.newGame.dateFinished) : undefined,
tags: this.newGame.tags || [],
links: this.newGame.links || []
};
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();
});
}
}
startEdit(game: Game) {
this.editingGame.set(game);
this.editGame = {
title: game.title,
platform: game.platform,
status: game.status,
dateStarted: game.dateStarted,
dateFinished: game.dateFinished,
rating: game.rating,
notes: game.notes,
coverImage: game.coverImage,
tags: [...(game.tags || [])],
links: [...(game.links || [])],
series: game.series,
seriesOrder: game.seriesOrder,
timeSpent: game.timeSpent
};
// Populate time fields from existing timeSpent
if (game.timeSpent) {
this.editGameTimeHours = Math.floor(game.timeSpent / 60);
this.editGameTimeMinutes = game.timeSpent % 60;
} else {
this.editGameTimeHours = 0;
this.editGameTimeMinutes = 0;
}
this.editGameImagePreview.set(game.coverImage || null);
this.showAddForm.set(false);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
this.editingGame.set(null);
this.editGame = {};
this.editGameImagePreview.set(null);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
saveEdit() {
const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return;
const updateData: UpdateGameDto = {
...this.editGame,
dateStarted: this.editGame.dateStarted ? new Date(this.editGame.dateStarted) : undefined,
dateFinished: this.editGame.dateFinished ? new Date(this.editGame.dateFinished) : undefined
};
this.gamesService.updateGame(game.id, updateData).subscribe(() => {
this.loadGames();
this.cancelEdit();
});
}
// Image handling methods
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.imageError.set(null);
if (file.size > this.MAX_IMAGE_SIZE) {
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
input.value = '';
return;
}
if (!file.type.startsWith('image/')) {
this.imageError.set('Please select an image file.');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
if (target === 'new') {
this.newGameImagePreview.set(base64);
this.newGame.coverImage = base64;
} else if (target === 'edit') {
this.editGameImagePreview.set(base64);
this.editGame.coverImage = base64;
} else {
this.suggestGameImagePreview.set(base64);
this.suggestedGame.coverImage = base64;
}
};
reader.readAsDataURL(file);
}
clearImage(target: 'new' | 'edit' | 'suggest') {
if (target === 'new') {
this.newGameImagePreview.set(null);
this.newGame.coverImage = undefined;
} else if (target === 'edit') {
this.editGameImagePreview.set(null);
this.editGame.coverImage = undefined;
} else {
this.suggestGameImagePreview.set(null);
this.suggestedGame.coverImage = undefined;
}
this.imageError.set(null);
}
// Comments methods
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
formatTimeSpent(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) {
return `${mins}m`;
} else if (mins === 0) {
return `${hours}h`;
} else {
return `${hours}h ${mins}m`;
}
}
toggleComments(gameId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[gameId];
this.expandedComments.set({
...expanded,
[gameId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[gameId]) {
this.loadComments(gameId);
}
}
loadComments(gameId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: true
});
this.commentsService.getCommentsForGame(gameId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[gameId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[gameId]: false
});
}
});
}
getCommentCount(gameId: string): number {
return this.comments()[gameId]?.length || 0;
}
addComment(gameId: string) {
const content = this.newCommentContent[gameId];
if (!content?.trim()) return;
this.commentsService.addCommentToGame(gameId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[gameId]: [comment, ...(this.comments()[gameId] || [])]
});
this.newCommentContent[gameId] = '';
}
});
}
deleteComment(gameId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromGame(gameId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).filter(c => c.id !== commentId)
});
}
});
}
canEditComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canDeleteComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
startEditComment(gameId: string, comment: Comment) {
this.editingCommentId.set(comment.id);
this.editCommentContent = comment.rawContent ?? comment.content;
}
cancelCommentEdit() {
this.editingCommentId.set(null);
this.editCommentContent = '';
}
saveCommentEdit(gameId: string, commentId: string) {
if (!this.editCommentContent.trim()) return;
this.commentsService.updateCommentOnGame(gameId, commentId, this.editCommentContent).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).map(c =>
c.id === commentId ? updatedComment : c
)
});
this.cancelCommentEdit();
}
});
}
handleCommentEdit(gameId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[gameId]: (this.comments()[gameId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(gameId: string) {
return signal(this.comments()[gameId] || []);
}
// Suggestion methods
toggleSuggestForm() {
this.showSuggestForm.update(v => !v);
if (!this.showSuggestForm()) {
this.resetSuggestForm();
}
}
resetSuggestForm() {
this.suggestedGame = {
title: '',
platform: '',
notes: '',
coverImage: undefined
};
this.suggestGameImagePreview.set(null);
this.imageError.set(null);
}
async submitSuggestion() {
if (!this.suggestedGame.title) return;
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.game,
title: this.suggestedGame.title,
platform: this.suggestedGame.platform,
notes: this.suggestedGame.notes,
coverImage: this.suggestedGame.coverImage
});
alert('Thank you for your suggestion! It will be reviewed soon.');
this.toggleSuggestForm();
} catch {
alert('Failed to submit suggestion. Please try again.');
}
}
}