/** * @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: `
Gaming avatar

My Game Collection

@if (authService.isAdmin()) { } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { }
@if (showAddForm() && authService.isAdmin()) {

Add New Game

@if (newGameImagePreview()) {
Box art preview
} @if (imageError()) { {{ imageError() }} }
@for (tag of newGame.tags; track tag; let i = $index) { {{ tag }} }
} @if (editingGame() && authService.isAdmin()) {

Edit Game

@if (editGameImagePreview()) {
Box art preview
} @if (imageError()) { {{ imageError() }} }
@for (tag of editGame.tags; track tag; let i = $index) { {{ tag }} }
} @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {

Suggest a Game

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the backlog!

@if (suggestGameImagePreview()) {
Box art preview
} @if (imageError()) { {{ imageError() }} }
} @if (showFilters()) {

Filter by Tags

@for (tag of allTags(); track tag) { } @empty {

No tags available

}
}
@if (loading()) {
Loading games...
} @else if (filteredGames().length === 0) {

No games found in this category.

} @else {
@for (game of paginatedGames(); track game.id) { }
}
`, 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([]); loading = signal(true); showAddForm = signal(false); editingGame = signal(null); statusFilter = signal<'all' | GameStatus>('all'); // Pagination state currentPage = signal(1); pageSize = signal(25); // Search and filter state searchQuery = signal(''); selectedTags = signal([]); showFilters = signal(false); // Comments state comments = signal>({}); commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; editingCommentId = signal(null); editCommentContent = ''; // Image upload state newGameImagePreview = signal(null); editGameImagePreview = signal(null); suggestGameImagePreview = signal(null); imageError = signal(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(); 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 & { dateStarted?: Date; dateFinished?: Date } = { title: '', platform: '', status: GameStatus.backlog, dateStarted: undefined, dateFinished: undefined, rating: undefined, notes: '', tags: [], links: [] }; editGame: Partial = {}; // 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.'); } } }