diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index c15c563..1c837e4 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -29,6 +29,30 @@ export const appRoutes: Route[] = [ path: 'manga', loadComponent: () => import('./components/manga/manga-list.component').then(m => m.MangaListComponent) }, + { + path: 'games/:id', + loadComponent: () => import('./components/games/game-detail.component').then(m => m.GameDetailComponent) + }, + { + path: 'books/:id', + loadComponent: () => import('./components/books/book-detail.component').then(m => m.BookDetailComponent) + }, + { + path: 'music/:id', + loadComponent: () => import('./components/music/music-detail.component').then(m => m.MusicDetailComponent) + }, + { + path: 'art/:id', + loadComponent: () => import('./components/art/art-detail.component').then(m => m.ArtDetailComponent) + }, + { + path: 'shows/:id', + loadComponent: () => import('./components/shows/show-detail.component').then(m => m.ShowDetailComponent) + }, + { + path: 'manga/:id', + loadComponent: () => import('./components/manga/manga-detail.component').then(m => m.MangaDetailComponent) + }, { path: 'admin/users', loadComponent: () => import('./components/admin/admin-users.component').then(m => m.AdminUsersComponent) diff --git a/apps/frontend/src/app/components/activity/activity-feed.component.ts b/apps/frontend/src/app/components/activity/activity-feed.component.ts index 927fac9..9cf6334 100644 --- a/apps/frontend/src/app/components/activity/activity-feed.component.ts +++ b/apps/frontend/src/app/components/activity/activity-feed.component.ts @@ -83,7 +83,7 @@ import { SanitizeService } from '../../services/sanitize.service'; ❤️ liked - + {{ activity.entityTitle }} diff --git a/apps/frontend/src/app/components/art/art-detail.component.ts b/apps/frontend/src/app/components/art/art-detail.component.ts new file mode 100644 index 0000000..8a9596a --- /dev/null +++ b/apps/frontend/src/app/components/art/art-detail.component.ts @@ -0,0 +1,542 @@ +/** + * @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 { ArtService } from '../../services/art.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 { Art, Comment } from '@library/shared-types'; + +@Component({ + selector: 'app-art-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
+ + + @if (loading()) { +
Loading artwork details...
+ } @else if (error()) { +
+

Artwork Not Found

+

{{ error() }}

+ Return to Art Gallery +
+ } @else if (art()) { +
+ @if (art()!.imageUrl) { +
+ +
+ } + +
+
+

{{ art()!.title }}

+
+ +

by {{ art()!.artist }}

+ +
+ Added: + {{ formatDate(art()!.createdAt) }} +
+ +
+ Last Updated: + {{ formatDate(art()!.updatedAt) }} +
+ + + + @if (art()!.description) { +
+

Description

+

{{ art()!.description }}

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

Tags

+
+ @for (tag of art()!.tags; track tag) { + {{ tag }} + } +
+
+ } + + @if (art()!.links && art()!.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: #ec4899; + 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; + } + + .art-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .art-image-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .art-image-large { + max-width: 100%; + max-height: 600px; + object-fit: contain; + border-radius: 4px; + } + + .art-content { + padding: 2rem; + } + + .art-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .art-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .artist { + color: #6b7280; + font-weight: 500; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .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; + } + + .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: #ec4899; + 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: #ec4899; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #ec4899; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #ec4899; + 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: #ec4899; + } + + .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: #ec4899; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .art-header { + flex-direction: column; + align-items: flex-start; + } + + .art-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 ArtDetailComponent implements OnInit { + private readonly artService = inject(ArtService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + art = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const artId = this.route.snapshot.paramMap.get('id'); + if (!artId) { + this.error.set('No artwork ID provided'); + this.loading.set(false); + return; + } + + this.loadArt(artId); + this.loadComments(artId); + } + + private loadArt(artId: string) { + this.loading.set(true); + this.artService.getArtById(artId).subscribe({ + next: (art) => { + if (!art) { + this.error.set('Artwork not found'); + } else { + this.art.set(art); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load artwork. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(artId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForArt(artId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const art = this.art(); + if (!art || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToArt(art.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const art = this.art(); + if (!art) return; + + this.commentsService.updateCommentOnArt(art.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 art = this.art(); + if (!art || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromArt(art.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-GB', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } +} diff --git a/apps/frontend/src/app/components/books/book-detail.component.ts b/apps/frontend/src/app/components/books/book-detail.component.ts new file mode 100644 index 0000000..d86199d --- /dev/null +++ b/apps/frontend/src/app/components/books/book-detail.component.ts @@ -0,0 +1,654 @@ +/** + * @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 { BooksService } from '../../services/books.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 { Book, Comment, BookStatus } from '@library/shared-types'; + +@Component({ + selector: 'app-book-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
+ + + @if (loading()) { +
Loading book details...
+ } @else if (error()) { +
+

Book Not Found

+

{{ error() }}

+ Return to Books +
+ } @else if (book()) { +
+ @if (book()!.coverImage) { +
+ +
+ } + +
+
+

{{ book()!.title }}

+ + {{ getStatusLabel(book()!.status) }} + +
+ +

by {{ book()!.author }}

+ + @if (book()!.series) { +

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

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

Notes

+

{{ book()!.notes }}

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

Tags

+
+ @for (tag of book()!.tags; track tag) { + {{ tag }} + } +
+
+ } + + @if (book()!.links && book()!.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: #8b6f47; + 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; + } + + .book-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .book-cover-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .book-cover-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .book-content { + padding: 2rem; + } + + .book-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .book-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .author { + color: #6b7280; + font-style: italic; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-reading { + background: #fef3c7; + color: #92400e; + } + + .status-finished { + background: #d1fae5; + color: #065f46; + } + + .status-toRead { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .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: #8b6f47; + 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: #8b6f47; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #8b6f47; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #8b6f47; + 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: #8b6f47; + } + + .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: #8b6f47; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .book-header { + flex-direction: column; + align-items: flex-start; + } + + .book-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 BookDetailComponent implements OnInit { + private readonly booksService = inject(BooksService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + book = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const bookId = this.route.snapshot.paramMap.get('id'); + if (!bookId) { + this.error.set('No book ID provided'); + this.loading.set(false); + return; + } + + this.loadBook(bookId); + this.loadComments(bookId); + } + + private loadBook(bookId: string) { + this.loading.set(true); + this.booksService.getBookById(bookId).subscribe({ + next: (book) => { + if (!book) { + this.error.set('Book not found'); + } else { + this.book.set(book); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load book. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(bookId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForBook(bookId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const book = this.book(); + if (!book || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToBook(book.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const book = this.book(); + if (!book) return; + + this.commentsService.updateCommentOnBook(book.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 book = this.book(); + if (!book || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromBook(book.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getStatusLabel(status: BookStatus): string { + switch (status) { + case BookStatus.reading: return 'Currently Reading'; + case BookStatus.finished: return 'Finished'; + case BookStatus.toRead: return 'To Read'; + case BookStatus.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'}`; + } + } +} diff --git a/apps/frontend/src/app/components/games/game-detail.component.ts b/apps/frontend/src/app/components/games/game-detail.component.ts new file mode 100644 index 0000000..0b7d43e --- /dev/null +++ b/apps/frontend/src/app/components/games/game-detail.component.ts @@ -0,0 +1,645 @@ +/** + * @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 { Game, Comment, GameStatus } from '@library/shared-types'; + +@Component({ + selector: 'app-game-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
+ + + @if (loading()) { +
Loading game details...
+ } @else if (error()) { +
+

Game Not Found

+

{{ error() }}

+ Return to Games +
+ } @else if (game()) { +
+ @if (game()!.coverImage) { +
+ +
+ } + +
+
+

{{ game()!.title }}

+ + {{ getStatusLabel(game()!.status) }} + +
+ + @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; + } + + .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 = ''; + + 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'}`; + } + } +} diff --git a/apps/frontend/src/app/components/home/home.component.ts b/apps/frontend/src/app/components/home/home.component.ts index d3a3506..cea8617 100644 --- a/apps/frontend/src/app/components/home/home.component.ts +++ b/apps/frontend/src/app/components/home/home.component.ts @@ -92,7 +92,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
    @for (game of recentGames(); track game.id) {
  • - {{ game.title }} + {{ game.title }} @if (game.platform) { ({{ game.platform }}) } @@ -108,7 +108,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
      @for (book of recentBooks(); track book.id) {
    • - {{ book.title }} + {{ book.title }} by {{ book.author }}
    • } @@ -122,7 +122,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
        @for (music of recentMusic(); track music.id) {
      • - {{ music.title }} + {{ music.title }} by {{ music.artist }}
      • } @@ -136,7 +136,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
          @for (manga of recentManga(); track manga.id) {
        • - {{ manga.title }} + {{ manga.title }} by {{ manga.author }}
        • } @@ -150,7 +150,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
            @for (show of recentShows(); track show.id) {
          • - {{ show.title }} + {{ show.title }} {{ formatShowType(show.type) }}
          • } @@ -164,7 +164,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
              @for (art of recentArt(); track art.id) {
            • - {{ art.title }} + {{ art.title }} by {{ art.artist }}
            • } diff --git a/apps/frontend/src/app/components/manga/manga-detail.component.ts b/apps/frontend/src/app/components/manga/manga-detail.component.ts new file mode 100644 index 0000000..c26a960 --- /dev/null +++ b/apps/frontend/src/app/components/manga/manga-detail.component.ts @@ -0,0 +1,633 @@ +/** + * @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 { MangaService } from '../../services/manga.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 { Manga, Comment, MangaStatus } from '@library/shared-types'; + +@Component({ + selector: 'app-manga-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
              + + + @if (loading()) { +
              Loading manga details...
              + } @else if (error()) { +
              +

              Manga Not Found

              +

              {{ error() }}

              + Return to Manga +
              + } @else if (manga()) { +
              + @if (manga()!.coverImage) { +
              + +
              + } + +
              +
              +

              {{ manga()!.title }}

              + + {{ getStatusLabel(manga()!.status) }} + +
              + +

              by {{ manga()!.author }}

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

              Notes

              +

              {{ manga()!.notes }}

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

              Tags

              +
              + @for (tag of manga()!.tags; track tag) { + {{ tag }} + } +
              +
              + } + + @if (manga()!.links && manga()!.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: #f59e0b; + 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; + } + + .manga-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .manga-cover-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .manga-cover-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .manga-content { + padding: 2rem; + } + + .manga-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .manga-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .author { + color: #6b7280; + font-style: italic; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-reading { + background: #fef3c7; + color: #92400e; + } + + .status-completed { + background: #d1fae5; + color: #065f46; + } + + .status-wantToRead { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .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: #f59e0b; + 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: #f59e0b; + 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: #f59e0b; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #f59e0b; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #f59e0b; + 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: #f59e0b; + } + + .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: #f59e0b; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .manga-header { + flex-direction: column; + align-items: flex-start; + } + + .manga-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 MangaDetailComponent implements OnInit { + private readonly mangaService = inject(MangaService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + manga = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const mangaId = this.route.snapshot.paramMap.get('id'); + if (!mangaId) { + this.error.set('No manga ID provided'); + this.loading.set(false); + return; + } + + this.loadManga(mangaId); + this.loadComments(mangaId); + } + + private loadManga(mangaId: string) { + this.loading.set(true); + this.mangaService.getMangaById(mangaId).subscribe({ + next: (manga) => { + if (!manga) { + this.error.set('Manga not found'); + } else { + this.manga.set(manga); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load manga. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(mangaId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForManga(mangaId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const manga = this.manga(); + if (!manga || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToManga(manga.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const manga = this.manga(); + if (!manga) return; + + this.commentsService.updateCommentOnManga(manga.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 manga = this.manga(); + if (!manga || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromManga(manga.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getStatusLabel(status: MangaStatus): string { + switch (status) { + case MangaStatus.reading: return 'Currently Reading'; + case MangaStatus.completed: return 'Completed'; + case MangaStatus.wantToRead: return 'Want to Read'; + case MangaStatus.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'}`; + } + } +} diff --git a/apps/frontend/src/app/components/music/music-detail.component.ts b/apps/frontend/src/app/components/music/music-detail.component.ts new file mode 100644 index 0000000..8e933a3 --- /dev/null +++ b/apps/frontend/src/app/components/music/music-detail.component.ts @@ -0,0 +1,667 @@ +/** + * @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 { MusicService } from '../../services/music.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 { Music, Comment, MusicStatus, MusicType } from '@library/shared-types'; + +@Component({ + selector: 'app-music-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
              + + + @if (loading()) { +
              Loading music details...
              + } @else if (error()) { +
              +

              Music Not Found

              +

              {{ error() }}

              + Return to Music +
              + } @else if (music()) { +
              + @if (music()!.coverArt) { +
              + +
              + } + +
              +
              +

              {{ music()!.title }}

              + + {{ getTypeLabel(music()!.type) }} + + + {{ getStatusLabel(music()!.status) }} + +
              + +

              by {{ music()!.artist }}

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

              Notes

              +

              {{ music()!.notes }}

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

              Tags

              +
              + @for (tag of music()!.tags; track tag) { + {{ tag }} + } +
              +
              + } + + @if (music()!.links && music()!.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: #74b9ff; + 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; + } + + .music-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .music-cover-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .music-cover-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .music-content { + padding: 2rem; + } + + .music-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .music-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .artist { + color: #6b7280; + font-weight: 500; + font-size: 1.1rem; + margin: 0 0 1rem 0; + } + + .type-badge { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .type-album { + background: #a78bfa; + color: white; + } + + .type-single { + background: #fb923c; + color: white; + } + + .type-ep { + background: #60a5fa; + color: white; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-listening { + background: #fef3c7; + color: #92400e; + } + + .status-completed { + background: #d1fae5; + color: #065f46; + } + + .status-wantToListen { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .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: #8b5cf6; + 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: #74b9ff; + 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: #74b9ff; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #74b9ff; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #74b9ff; + 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: #74b9ff; + } + + .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: #74b9ff; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .music-header { + flex-direction: column; + align-items: flex-start; + } + + .music-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 MusicDetailComponent implements OnInit { + private readonly musicService = inject(MusicService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + music = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const musicId = this.route.snapshot.paramMap.get('id'); + if (!musicId) { + this.error.set('No music ID provided'); + this.loading.set(false); + return; + } + + this.loadMusic(musicId); + this.loadComments(musicId); + } + + private loadMusic(musicId: string) { + this.loading.set(true); + this.musicService.getMusicById(musicId).subscribe({ + next: (music) => { + if (!music) { + this.error.set('Music not found'); + } else { + this.music.set(music); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load music. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(musicId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForMusic(musicId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const music = this.music(); + if (!music || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToMusic(music.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const music = this.music(); + if (!music) return; + + this.commentsService.updateCommentOnMusic(music.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 music = this.music(); + if (!music || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromMusic(music.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getTypeLabel(type: MusicType): string { + switch (type) { + case MusicType.album: return 'Album'; + case MusicType.single: return 'Single'; + case MusicType.ep: return 'EP'; + } + } + + getStatusLabel(status: MusicStatus): string { + switch (status) { + case MusicStatus.listening: return 'Currently Listening'; + case MusicStatus.completed: return 'Completed'; + case MusicStatus.wantToListen: return 'Want to Listen'; + case MusicStatus.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'}`; + } + } +} diff --git a/apps/frontend/src/app/components/shows/show-detail.component.ts b/apps/frontend/src/app/components/shows/show-detail.component.ts new file mode 100644 index 0000000..c657443 --- /dev/null +++ b/apps/frontend/src/app/components/shows/show-detail.component.ts @@ -0,0 +1,664 @@ +/** + * @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 { ShowsService } from '../../services/shows.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 { Show, Comment, ShowStatus, ShowType } from '@library/shared-types'; + +@Component({ + selector: 'app-show-detail', + standalone: true, + imports: [CommonModule, RouterLink, FormsModule, CommentDisplayComponent, LikeButtonComponent], + template: ` +
              + + + @if (loading()) { +
              Loading show details...
              + } @else if (error()) { +
              +

              Show Not Found

              +

              {{ error() }}

              + Return to Shows +
              + } @else if (show()) { +
              + @if (show()!.coverImage) { +
              + +
              + } + +
              +
              +

              {{ show()!.title }}

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

              Notes

              +

              {{ show()!.notes }}

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

              Tags

              +
              + @for (tag of show()!.tags; track tag) { + {{ tag }} + } +
              +
              + } + + @if (show()!.links && show()!.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: #10b981; + 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; + } + + .show-detail-card { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + .show-poster-section { + width: 100%; + background: #f3f4f6; + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + } + + .show-poster-large { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 4px; + } + + .show-content { + padding: 2rem; + } + + .show-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .show-header h1 { + margin: 0; + font-size: 2rem; + color: #1f2937; + } + + .type-badge { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .type-tvSeries { + background: #3b82f6; + color: white; + } + + .type-anime { + background: #ec4899; + color: white; + } + + .type-film { + background: #8b5cf6; + color: white; + } + + .type-documentary { + background: #14b8a6; + color: white; + } + + .status { + display: inline-block; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + } + + .status-watching { + background: #fef3c7; + color: #92400e; + } + + .status-completed { + background: #d1fae5; + color: #065f46; + } + + .status-wantToWatch { + background: #e0e7ff; + color: #3730a3; + } + + .status-retired { + background: #f3f4f6; + color: #4b5563; + } + + .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: #10b981; + 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: #10b981; + text-decoration: none; + font-size: 0.95rem; + padding: 0.5rem 1rem; + border: 2px solid #10b981; + border-radius: 4px; + transition: all 0.2s; + font-weight: 500; + } + + .external-link:hover { + background: #10b981; + 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: #10b981; + } + + .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: #10b981; + color: white; + } + + @media (max-width: 640px) { + .container { + padding: 1rem; + } + + .show-header { + flex-direction: column; + align-items: flex-start; + } + + .show-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 ShowDetailComponent implements OnInit { + private readonly showsService = inject(ShowsService); + private readonly commentsService = inject(CommentsService); + readonly authService = inject(AuthService); + private readonly sanitizeService = inject(SanitizeService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + show = signal(null); + comments = signal([]); + loading = signal(true); + commentsLoading = signal(false); + error = signal(null); + newCommentContent = ''; + + ngOnInit() { + const showId = this.route.snapshot.paramMap.get('id'); + if (!showId) { + this.error.set('No show ID provided'); + this.loading.set(false); + return; + } + + this.loadShow(showId); + this.loadComments(showId); + } + + private loadShow(showId: string) { + this.loading.set(true); + this.showsService.getShowById(showId).subscribe({ + next: (show) => { + if (!show) { + this.error.set('Show not found'); + } else { + this.show.set(show); + } + this.loading.set(false); + }, + error: () => { + this.error.set('Failed to load show. It may not exist or there was an error.'); + this.loading.set(false); + } + }); + } + + private loadComments(showId: string) { + this.commentsLoading.set(true); + this.commentsService.getCommentsForShow(showId).subscribe({ + next: (comments) => { + this.comments.set(comments); + this.commentsLoading.set(false); + }, + error: () => { + this.commentsLoading.set(false); + } + }); + } + + addComment() { + const show = this.show(); + if (!show || !this.newCommentContent.trim()) return; + + this.commentsService.addCommentToShow(show.id, { content: this.newCommentContent }).subscribe({ + next: (comment) => { + this.comments.set([comment, ...this.comments()]); + this.newCommentContent = ''; + } + }); + } + + handleCommentEdit(event: { commentId: string; content: string }) { + const show = this.show(); + if (!show) return; + + this.commentsService.updateCommentOnShow(show.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 show = this.show(); + if (!show || !confirm('Are you sure you want to delete this comment?')) return; + + this.commentsService.deleteCommentFromShow(show.id, commentId).subscribe({ + next: () => { + this.comments.set(this.comments().filter(c => c.id !== commentId)); + } + }); + } + + getTypeLabel(type: ShowType): string { + switch (type) { + case ShowType.tvSeries: return 'TV Series'; + case ShowType.anime: return 'Anime'; + case ShowType.film: return 'Film'; + case ShowType.documentary: return 'Documentary'; + } + } + + getStatusLabel(status: ShowStatus): string { + switch (status) { + case ShowStatus.watching: return 'Currently Watching'; + case ShowStatus.completed: return 'Completed'; + case ShowStatus.wantToWatch: return 'Want to Watch'; + case ShowStatus.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'}`; + } + } +}