/** * @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 { RouterLink, ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { GamesService } from '../../services/games.service'; import { CommentsService } from '../../services/comments.service'; import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { LikeButtonComponent } from '../shared/like-button.component'; import { GameFormComponent } from '../shared/game-form.component'; import { Game, Comment, GameStatus, UpdateGameDto } from '@library/shared-types'; @Component({ selector: 'app-game-detail', standalone: true, imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent, GameFormComponent], template: `
@if (loading()) {
Loading game details...
} @else if (error()) {

Game Not Found

{{ error() }}

Return to Games
} @else if (game()) {

{{ game()!.title }}

{{ getStatusLabel(game()!.status) }}
@if (authService.user()?.isAdmin) {
} @if (showEditForm() && authService.user()?.isAdmin && game()) { } @if (game()!.series) {

📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}

} @if (game()!.platform) {
Platform: {{ game()!.platform }}
} @if (game()!.rating) {
Rating:
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { } ({{ game()!.rating }}/10)
} @if (game()!.timeSpent) {
Time Played: {{ formatTimeSpent(game()!.timeSpent!) }}
} @if (game()!.dateStarted) {
Started: {{ formatDate(game()!.dateStarted!) }}
} @if (game()!.dateFinished) {
Finished: {{ formatDate(game()!.dateFinished!) }}
}
Added: {{ formatDate(game()!.createdAt) }}
Last Updated: {{ formatDate(game()!.updatedAt) }}
@if (game()!.notes) {

Notes

{{ game()!.notes }}

} @if (game()!.tags && game()!.tags.length > 0) {

Tags

@for (tag of game()!.tags; track tag) { {{ tag }} }
} @if (game()!.links && game()!.links.length > 0) { }

Comments

@if (authService.isAuthenticated()) { @if (authService.user()?.isBanned) {
You have been banned from commenting.
} @else {
} } @else {

Please sign in to comment.

} @if (commentsLoading()) {
Loading comments...
} @else { }
}
`, styles: [` .container { max-width: 900px; margin: 0 auto; padding: 2rem; } .breadcrumb { margin-bottom: 1.5rem; } .breadcrumb-link { color: #ff6b6b; text-decoration: none; font-weight: 500; transition: opacity 0.3s; } .breadcrumb-link:hover { opacity: 0.8; } .loading { text-align: center; padding: 3rem; color: #666; font-size: 1.1rem; } .error-state { text-align: center; padding: 3rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; } .error-state h2 { color: #991b1b; margin-bottom: 1rem; } .error-state p { color: #dc2626; margin-bottom: 1.5rem; } .game-detail-card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); } .game-cover-section { width: 100%; background: #f3f4f6; display: flex; justify-content: center; align-items: center; padding: 2rem; } .game-cover-large { max-width: 100%; max-height: 400px; object-fit: contain; border-radius: 4px; } .game-content { padding: 2rem; } .game-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; } .game-header h1 { margin: 0; font-size: 2rem; color: #1f2937; } .status { display: inline-block; padding: 0.5rem 1rem; border-radius: 4px; font-size: 0.9rem; font-weight: 500; } .status-playing { background: #fef3c7; color: #92400e; } .status-completed { background: #d1fae5; color: #065f46; } .status-backlog { background: #e0e7ff; color: #3730a3; } .status-retired { background: #f3f4f6; color: #4b5563; } .admin-actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; padding: 1rem; background: #fef3c7; border-radius: 4px; border: 1px solid #fbbf24; } .btn-edit { background: #3b82f6; color: white; } .btn-edit:hover { background: #2563eb; } .btn-delete { background: #ef4444; color: white; } .btn-delete:hover { background: #dc2626; } .series { color: #8b6f47; font-size: 1rem; margin: 0.5rem 0 1.5rem 0; font-weight: 500; font-style: italic; } .info-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid #f3f4f6; } .info-label { font-weight: 600; color: #4b5563; min-width: 120px; } .info-value { color: #1f2937; } .time-spent { color: #10b981; font-weight: 600; } .rating { display: flex; align-items: center; gap: 0.25rem; } .rating span { color: #e5e7eb; font-size: 1.2rem; } .rating span.filled { color: #f59e0b; } .rating-text { margin-left: 0.5rem; font-size: 1rem; color: #6b7280; font-weight: 500; } .like-section { margin: 1.5rem 0; } .notes-section, .tags-section, .links-section { margin-top: 2rem; } .notes-section h3, .tags-section h3, .links-section h3, .comments-section h3 { font-size: 1.25rem; color: #1f2937; margin-bottom: 1rem; } .notes { font-size: 1rem; color: #4b5563; line-height: 1.6; white-space: pre-wrap; } .tags-display { display: flex; flex-wrap: wrap; gap: 0.5rem; } .tag-chip { background: #ff6b6b; color: white; padding: 0.375rem 0.875rem; border-radius: 12px; font-size: 0.875rem; font-weight: 500; } .links-display { display: flex; flex-wrap: wrap; gap: 0.75rem; } .external-link { color: #ff6b6b; text-decoration: none; font-size: 0.95rem; padding: 0.5rem 1rem; border: 2px solid #ff6b6b; border-radius: 4px; transition: all 0.2s; font-weight: 500; } .external-link:hover { background: #ff6b6b; color: white; } .comments-section { margin-top: 2rem; padding-top: 2rem; border-top: 2px solid #e5e7eb; } .comment-form { margin-bottom: 1.5rem; } .comment-form textarea { width: 100%; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 1rem; resize: vertical; margin-bottom: 0.75rem; font-family: inherit; } .comment-form textarea:focus { outline: none; border-color: #ff6b6b; } .banned-notice { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 1rem; border-radius: 4px; text-align: center; margin-bottom: 1.5rem; font-size: 0.95rem; } .auth-prompt { background: #f3f4f6; padding: 1rem; border-radius: 4px; text-align: center; margin-bottom: 1.5rem; } .auth-prompt p { margin: 0; color: #4b5563; } .comments-loading { text-align: center; padding: 1.5rem; color: #6b7280; font-size: 0.95rem; } .btn { padding: 0.625rem 1.25rem; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; transition: all 0.3s; font-weight: 500; } .btn:hover { opacity: 0.9; } .btn-primary { background: #ff6b6b; color: white; } @media (max-width: 640px) { .container { padding: 1rem; } .game-header { flex-direction: column; align-items: flex-start; } .game-header h1 { font-size: 1.5rem; } .info-row { flex-direction: column; align-items: flex-start; gap: 0.25rem; } .info-label { min-width: auto; } } `] }) export class GameDetailComponent implements OnInit { private readonly gamesService = inject(GamesService); private readonly commentsService = inject(CommentsService); readonly authService = inject(AuthService); private readonly sanitizeService = inject(SanitizeService); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); game = signal(null); comments = signal([]); loading = signal(true); commentsLoading = signal(false); error = signal(null); newCommentContent = ''; showEditForm = signal(false); ngOnInit() { const gameId = this.route.snapshot.paramMap.get('id'); if (!gameId) { this.error.set('No game ID provided'); this.loading.set(false); return; } this.loadGame(gameId); this.loadComments(gameId); } private loadGame(gameId: string) { this.loading.set(true); this.gamesService.getGameById(gameId).subscribe({ next: (game) => { if (!game) { this.error.set('Game not found'); } else { this.game.set(game); } this.loading.set(false); }, error: () => { this.error.set('Failed to load game. It may not exist or there was an error.'); this.loading.set(false); } }); } private loadComments(gameId: string) { this.commentsLoading.set(true); this.commentsService.getCommentsForGame(gameId).subscribe({ next: (comments) => { this.comments.set(comments); this.commentsLoading.set(false); }, error: () => { this.commentsLoading.set(false); } }); } addComment() { const game = this.game(); if (!game || !this.newCommentContent.trim()) return; this.commentsService.addCommentToGame(game.id, { content: this.newCommentContent }).subscribe({ next: (comment) => { this.comments.set([comment, ...this.comments()]); this.newCommentContent = ''; } }); } handleCommentEdit(event: { commentId: string; content: string }) { const game = this.game(); if (!game) return; this.commentsService.updateCommentOnGame(game.id, event.commentId, event.content).subscribe({ next: (updatedComment) => { this.comments.set( this.comments().map(c => c.id === event.commentId ? updatedComment : c) ); } }); } deleteComment(commentId: string) { const game = this.game(); if (!game || !confirm('Are you sure you want to delete this comment?')) return; this.commentsService.deleteCommentFromGame(game.id, commentId).subscribe({ next: () => { this.comments.set(this.comments().filter(c => c.id !== commentId)); } }); } 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'; } } formatDate(date: Date | string): string { return new Date(date).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }); } formatTimeSpent(minutes: number): string { const hours = Math.floor(minutes / 60); const mins = minutes % 60; if (hours === 0) { return `${mins} minutes`; } else if (mins === 0) { return `${hours} hour${hours === 1 ? '' : 's'}`; } else { return `${hours} hour${hours === 1 ? '' : 's'} ${mins} minute${mins === 1 ? '' : 's'}`; } } toggleEditForm() { this.showEditForm.update(value => !value); } saveEdit(data: UpdateGameDto) { const game = this.game(); if (!game) return; this.gamesService.updateGame(game.id, data).subscribe({ next: (updatedGame) => { this.game.set(updatedGame); this.showEditForm.set(false); }, error: (err) => { alert('Failed to update game: ' + (err.error?.message || 'Unknown error')); } }); } cancelEdit() { this.showEditForm.set(false); } deleteGame() { const game = this.game(); if (!game) return; if (!confirm(`Are you sure you want to delete "${game.title}"? This action cannot be undone.`)) { return; } this.gamesService.deleteGame(game.id).subscribe({ next: () => { this.router.navigate(['/games']); }, error: (err) => { alert('Failed to delete game: ' + (err.error?.message || 'Unknown error')); } }); } }