Compare commits

..

5 Commits

Author SHA1 Message Date
hikari 7606a18e38 feat: implement entity detail pages with full navigation
Implements issue #56 (activity feed) followup - adds complete detail pages for all entity types so activity feed and homepage links work properly.

Created Detail Components:
- game-detail.component.ts - Shows full game details with platform, series, time played
- book-detail.component.ts - Shows book details with author, ISBN, series, reading time
- music-detail.component.ts - Shows music details with artist, type (album/single), listening time
- art-detail.component.ts - Shows art details with artist, description, large image display
- show-detail.component.ts - Shows show details with type (TV/anime/film), watch time
- manga-detail.component.ts - Shows manga details with author, reading time

Features per Detail Page:
- Read-only display of full entity information
- Cover/poster/art image display (when available)
- Rating display with 10-star system (when applicable)
- Time spent formatting (hours and minutes)
- Date formatting (British English locale)
- Tags display with entity-specific colour schemes
- External links with hover effects
- Comments section with add/edit/delete functionality
- Like button integration
- Breadcrumb navigation back to list page
- Loading states with spinner
- Error handling for not found cases
- Responsive design for all screen sizes

Routing Updates:
- Added 6 new routes: /games/:id, /books/:id, /music/:id, /art/:id, /shows/:id, /manga/:id
- All routes use lazy loading with loadComponent

Navigation Fixes:
- Fixed activity feed like links to point to entity detail pages (was pointing to list)
- Updated homepage recent items to link directly to entity detail pages (was pointing to list)

Technical Fixes:
- Corrected Art component to only use existing fields (removed non-existent medium, rating, notes)
- Fixed Show component to use coverImage instead of non-existent posterImage
- Added proper null checks for optional fields (timeSpent, dateStarted, dateFinished)
- Added non-null assertions where values are guaranteed to exist within conditionals

Each detail component follows the established pattern from the list components whilst adapting for single-item display. All components use Angular signals for reactive state management and integrate with existing services.

 This issue was created with help from Hikari~ 🌸
2026-02-20 00:33:28 -08:00
naomi b8d3365158 fix: improve comment display in activity feed
Changed comment layout to use block-level elements and fixed entity link
to navigate to the specific item instead of the list page.

Template changes:
- Wrapped icon and text in activity-comment-header div
- Changed entity link to include entityId: ['/' + entityType + 's', entityId]
- Comment preview now displays as a block element below the header

CSS changes:
- Added flex layout to activity-comment-header (inline display)
- Changed activity-comment to flex column layout (block display)
- Added left margin to comment-preview to align with content above
- Removed italic style from comment-preview

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-20 00:15:02 -08:00
naomi 6ef787a3b8 fix: render HTML comments properly in activity feed
Changed approach from stripping HTML on backend to rendering HTML with
sanitization on frontend, matching the pattern used in comment-display
component. This preserves HTML formatting (bold, italics, etc.) in
comment previews whilst still protecting against XSS attacks.

Backend changes:
- Reverted stripHtml() method (no longer needed)
- Keep full HTML content in commentPreview field

Frontend changes:
- Import and inject SanitizeService
- Changed from text interpolation to [innerHTML] with sanitization
- Changed <p> to <div> for comment preview container

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-20 00:13:00 -08:00
naomi 512e7eec13 fix: strip HTML tags from comment previews in activity feed
Added stripHtml() helper method to ActivityService to remove HTML tags
from comment content before creating the preview text. This ensures that
comments display as plain text in the activity feed instead of showing
raw HTML like "<p>test</p>".

The regex pattern /<[^>]*>/g removes all HTML tags whilst preserving
the actual text content, which is then trimmed and truncated to 100
characters for the preview.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-20 00:11:08 -08:00
naomi f0052cad76 fix: add explicit type annotations to activity service methods
Added explicit return type annotations (SuggestionActivity[], LikeActivity[],
CommentActivity[], AchievementActivity[]) to all private activity service
methods. This ensures TypeScript properly narrows the discriminated union
types based on the ActivityType enum values, resolving compilation errors
where the type system couldn't infer the specific activity subtypes.

Also added type annotation to the async map callback in getLikeActivities
to ensure the returned Promise resolves to the correct LikeActivity type.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-20 00:09:00 -08:00
10 changed files with 3865 additions and 22 deletions
+9 -5
View File
@@ -8,6 +8,10 @@ import type {
Activity,
ActivityFeedResponse,
ActivityUser,
SuggestionActivity,
LikeActivity,
CommentActivity,
AchievementActivity,
} from "@library/shared-types";
import { ACHIEVEMENTS, ActivityType } from "@library/shared-types";
import { prisma } from "../lib/prisma";
@@ -61,7 +65,7 @@ export class ActivityService {
limit: number,
offset: number,
userId?: string
) {
): Promise<SuggestionActivity[]> {
const where = userId
? { userId, user: { profilePublic: true, isBanned: false } }
: { user: { profilePublic: true, isBanned: false } };
@@ -104,7 +108,7 @@ export class ActivityService {
limit: number,
offset: number,
userId?: string
) {
): Promise<LikeActivity[]> {
const where = userId
? { userId, user: { profilePublic: true, isBanned: false } }
: { user: { profilePublic: true, isBanned: false } };
@@ -131,7 +135,7 @@ export class ActivityService {
// For each like, fetch the entity title
const likesWithTitles = await Promise.all(
likes.map(async (like) => {
likes.map(async (like): Promise<LikeActivity> => {
const entityTitle = await this.getEntityTitle(
like.entityType,
like.entityId
@@ -158,7 +162,7 @@ export class ActivityService {
limit: number,
offset: number,
userId?: string
) {
): Promise<CommentActivity[]> {
const where = userId
? { userId, user: { profilePublic: true, isBanned: false } }
: { user: { profilePublic: true, isBanned: false } };
@@ -246,7 +250,7 @@ export class ActivityService {
limit: number,
offset: number,
userId?: string
) {
): Promise<AchievementActivity[]> {
const where = userId
? {
userId,
+24
View File
@@ -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)
@@ -10,6 +10,7 @@ import { RouterLink } from '@angular/router';
import type { Activity } from '@library/shared-types';
import { ActivityType } from '@library/shared-types';
import { ActivityService } from '../../services/activity.service';
import { SanitizeService } from '../../services/sanitize.service';
@Component({
selector: 'app-activity-feed',
@@ -82,7 +83,7 @@ import { ActivityService } from '../../services/activity.service';
<span class="activity-icon">❤️</span>
<span class="activity-text">
liked
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
<a [routerLink]="['/' + activity.entityType + 's', activity.entityId]" class="entity-link">
{{ activity.entityTitle }}
</a>
</span>
@@ -90,14 +91,16 @@ import { ActivityService } from '../../services/activity.service';
}
@case (ActivityType.comment) {
<div class="activity-comment">
<span class="activity-icon">💬</span>
<span class="activity-text">
commented on
<a [routerLink]="['/' + activity.entityType + 's']" class="entity-link">
{{ activity.entityTitle }}
</a>
</span>
<p class="comment-preview">"{{ activity.commentPreview }}"</p>
<div class="activity-comment-header">
<span class="activity-icon">💬</span>
<span class="activity-text">
commented on
<a [routerLink]="['/' + activity.entityType + 's', activity.entityId]" class="entity-link">
{{ activity.entityTitle }}
</a>
</span>
</div>
<div class="comment-preview" [innerHTML]="sanitizeService.sanitizeHtml(activity.commentPreview)"></div>
</div>
}
@case (ActivityType.achievement) {
@@ -255,13 +258,19 @@ import { ActivityService } from '../../services/activity.service';
.activity-suggestion,
.activity-like,
.activity-comment,
.activity-comment-header,
.activity-achievement {
display: flex;
align-items: start;
gap: 0.75rem;
}
.activity-comment {
display: flex;
flex-direction: column;
gap: 0;
}
.activity-icon {
font-size: 1.5rem;
line-height: 1;
@@ -289,12 +298,12 @@ import { ActivityService } from '../../services/activity.service';
.comment-preview {
margin-top: 0.5rem;
margin-left: 55px;
padding: 0.75rem;
background: #f9fafb;
border-left: 3px solid #10b981;
border-radius: 4px;
color: #4b5563;
font-style: italic;
}
.status-badge {
@@ -360,6 +369,7 @@ import { ActivityService } from '../../services/activity.service';
})
export class ActivityFeedComponent implements OnInit {
private activityService = inject(ActivityService);
public sanitizeService = inject(SanitizeService);
// Make ActivityType accessible in template
ActivityType = ActivityType;
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/art" class="breadcrumb-link">← Back to Art</a>
</div>
@if (loading()) {
<div class="loading">Loading artwork details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Artwork Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/art" class="btn btn-primary">Return to Art Gallery</a>
</div>
} @else if (art()) {
<div class="art-detail-card">
@if (art()!.imageUrl) {
<div class="art-image-section">
<img [src]="art()!.imageUrl" [alt]="art()!.description || art()!.title" class="art-image-large">
</div>
}
<div class="art-content">
<div class="art-header">
<h1>{{ art()!.title }}</h1>
</div>
<p class="artist">by {{ art()!.artist }}</p>
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(art()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(art()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="art"
[entityId]="art()!.id"
></app-like-button>
</div>
@if (art()!.description) {
<div class="notes-section">
<h3>Description</h3>
<p class="notes">{{ art()!.description }}</p>
</div>
}
@if (art()!.tags && art()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of art()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (art()!.links && art()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of art()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Art | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'
});
}
}
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/books" class="breadcrumb-link">← Back to Books</a>
</div>
@if (loading()) {
<div class="loading">Loading book details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Book Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/books" class="btn btn-primary">Return to Books</a>
</div>
} @else if (book()) {
<div class="book-detail-card">
@if (book()!.coverImage) {
<div class="book-cover-section">
<img [src]="book()!.coverImage" [alt]="book()!.title" class="book-cover-large">
</div>
}
<div class="book-content">
<div class="book-header">
<h1>{{ book()!.title }}</h1>
<span class="status status-{{ book()!.status }}">
{{ getStatusLabel(book()!.status) }}
</span>
</div>
<p class="author">by {{ book()!.author }}</p>
@if (book()!.series) {
<p class="series">
📚 {{ book()!.series }}@if (book()!.seriesOrder) { #{{ book()!.seriesOrder }}}
</p>
}
@if (book()!.isbn) {
<div class="info-row">
<span class="info-label">ISBN:</span>
<span class="info-value">{{ book()!.isbn }}</span>
</div>
}
@if (book()!.rating) {
<div class="info-row">
<span class="info-label">Rating:</span>
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= book()!.rating!">★</span>
}
<span class="rating-text">({{ book()!.rating }}/10)</span>
</div>
</div>
}
@if (book()!.timeSpent) {
<div class="info-row">
<span class="info-label">Reading Time:</span>
<span class="info-value time-spent">{{ formatTimeSpent(book()!.timeSpent!) }}</span>
</div>
}
@if (book()!.dateStarted) {
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ formatDate(book()!.dateStarted!) }}</span>
</div>
}
@if (book()!.dateFinished) {
<div class="info-row">
<span class="info-label">Finished:</span>
<span class="info-value">{{ formatDate(book()!.dateFinished!) }}</span>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(book()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(book()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="book"
[entityId]="book()!.id"
></app-like-button>
</div>
@if (book()!.notes) {
<div class="notes-section">
<h3>Notes</h3>
<p class="notes">{{ book()!.notes }}</p>
</div>
}
@if (book()!.tags && book()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of book()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (book()!.links && book()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of book()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Book | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'}`;
}
}
}
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/games" class="breadcrumb-link">← Back to Games</a>
</div>
@if (loading()) {
<div class="loading">Loading game details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Game Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/games" class="btn btn-primary">Return to Games</a>
</div>
} @else if (game()) {
<div class="game-detail-card">
@if (game()!.coverImage) {
<div class="game-cover-section">
<img [src]="game()!.coverImage" [alt]="game()!.title" class="game-cover-large">
</div>
}
<div class="game-content">
<div class="game-header">
<h1>{{ game()!.title }}</h1>
<span class="status status-{{ game()!.status }}">
{{ getStatusLabel(game()!.status) }}
</span>
</div>
@if (game()!.series) {
<p class="series">
📚 {{ game()!.series }}@if (game()!.seriesOrder) { #{{ game()!.seriesOrder }}}
</p>
}
@if (game()!.platform) {
<div class="info-row">
<span class="info-label">Platform:</span>
<span class="info-value">{{ game()!.platform }}</span>
</div>
}
@if (game()!.rating) {
<div class="info-row">
<span class="info-label">Rating:</span>
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= game()!.rating!">★</span>
}
<span class="rating-text">({{ game()!.rating }}/10)</span>
</div>
</div>
}
@if (game()!.timeSpent) {
<div class="info-row">
<span class="info-label">Time Played:</span>
<span class="info-value time-spent">{{ formatTimeSpent(game()!.timeSpent!) }}</span>
</div>
}
@if (game()!.dateStarted) {
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ formatDate(game()!.dateStarted!) }}</span>
</div>
}
@if (game()!.dateFinished) {
<div class="info-row">
<span class="info-label">Finished:</span>
<span class="info-value">{{ formatDate(game()!.dateFinished!) }}</span>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(game()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(game()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="game"
[entityId]="game()!.id"
></app-like-button>
</div>
@if (game()!.notes) {
<div class="notes-section">
<h3>Notes</h3>
<p class="notes">{{ game()!.notes }}</p>
</div>
}
@if (game()!.tags && game()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of game()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (game()!.links && game()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of game()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Game | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'}`;
}
}
}
@@ -92,7 +92,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (game of recentGames(); track game.id) {
<li>
<a routerLink="/games">{{ game.title }}</a>
<a [routerLink]="['/games', game.id]">{{ game.title }}</a>
@if (game.platform) {
<span class="platform">({{ game.platform }})</span>
}
@@ -108,7 +108,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (book of recentBooks(); track book.id) {
<li>
<a routerLink="/books">{{ book.title }}</a>
<a [routerLink]="['/books', book.id]">{{ book.title }}</a>
<span class="author">by {{ book.author }}</span>
</li>
}
@@ -122,7 +122,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (music of recentMusic(); track music.id) {
<li>
<a routerLink="/music">{{ music.title }}</a>
<a [routerLink]="['/music', music.id]">{{ music.title }}</a>
<span class="artist">by {{ music.artist }}</span>
</li>
}
@@ -136,7 +136,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (manga of recentManga(); track manga.id) {
<li>
<a routerLink="/manga">{{ manga.title }}</a>
<a [routerLink]="['/manga', manga.id]">{{ manga.title }}</a>
<span class="author">by {{ manga.author }}</span>
</li>
}
@@ -150,7 +150,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (show of recentShows(); track show.id) {
<li>
<a routerLink="/shows">{{ show.title }}</a>
<a [routerLink]="['/shows', show.id]">{{ show.title }}</a>
<span class="show-type">{{ formatShowType(show.type) }}</span>
</li>
}
@@ -164,7 +164,7 @@ import { Game, GameStatus, Book, BookStatus, Music, MusicType, Manga, MangaStatu
<ul class="recent-list">
@for (art of recentArt(); track art.id) {
<li>
<a routerLink="/art">{{ art.title }}</a>
<a [routerLink]="['/art', art.id]">{{ art.title }}</a>
<span class="artist">by {{ art.artist }}</span>
</li>
}
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/manga" class="breadcrumb-link">← Back to Manga</a>
</div>
@if (loading()) {
<div class="loading">Loading manga details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Manga Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/manga" class="btn btn-primary">Return to Manga</a>
</div>
} @else if (manga()) {
<div class="manga-detail-card">
@if (manga()!.coverImage) {
<div class="manga-cover-section">
<img [src]="manga()!.coverImage" [alt]="manga()!.title" class="manga-cover-large">
</div>
}
<div class="manga-content">
<div class="manga-header">
<h1>{{ manga()!.title }}</h1>
<span class="status status-{{ manga()!.status }}">
{{ getStatusLabel(manga()!.status) }}
</span>
</div>
<p class="author">by {{ manga()!.author }}</p>
@if (manga()!.rating) {
<div class="info-row">
<span class="info-label">Rating:</span>
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= manga()!.rating!">★</span>
}
<span class="rating-text">({{ manga()!.rating }}/10)</span>
</div>
</div>
}
@if (manga()!.timeSpent) {
<div class="info-row">
<span class="info-label">Reading Time:</span>
<span class="info-value time-spent">{{ formatTimeSpent(manga()!.timeSpent!) }}</span>
</div>
}
@if (manga()!.dateStarted) {
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ formatDate(manga()!.dateStarted!) }}</span>
</div>
}
@if (manga()!.dateFinished) {
<div class="info-row">
<span class="info-label">Finished:</span>
<span class="info-value">{{ formatDate(manga()!.dateFinished!) }}</span>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(manga()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(manga()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="manga"
[entityId]="manga()!.id"
></app-like-button>
</div>
@if (manga()!.notes) {
<div class="notes-section">
<h3>Notes</h3>
<p class="notes">{{ manga()!.notes }}</p>
</div>
}
@if (manga()!.tags && manga()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of manga()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (manga()!.links && manga()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of manga()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Manga | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'}`;
}
}
}
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/music" class="breadcrumb-link">← Back to Music</a>
</div>
@if (loading()) {
<div class="loading">Loading music details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Music Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/music" class="btn btn-primary">Return to Music</a>
</div>
} @else if (music()) {
<div class="music-detail-card">
@if (music()!.coverArt) {
<div class="music-cover-section">
<img [src]="music()!.coverArt" [alt]="music()!.title" class="music-cover-large">
</div>
}
<div class="music-content">
<div class="music-header">
<h1>{{ music()!.title }}</h1>
<span class="type-badge type-{{ music()!.type }}">
{{ getTypeLabel(music()!.type) }}
</span>
<span class="status status-{{ music()!.status }}">
{{ getStatusLabel(music()!.status) }}
</span>
</div>
<p class="artist">by {{ music()!.artist }}</p>
@if (music()!.rating) {
<div class="info-row">
<span class="info-label">Rating:</span>
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= music()!.rating!">★</span>
}
<span class="rating-text">({{ music()!.rating }}/10)</span>
</div>
</div>
}
@if (music()!.timeSpent) {
<div class="info-row">
<span class="info-label">Listening Time:</span>
<span class="info-value time-spent">{{ formatTimeSpent(music()!.timeSpent!) }}</span>
</div>
}
@if (music()!.dateStarted) {
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ formatDate(music()!.dateStarted!) }}</span>
</div>
}
@if (music()!.dateFinished) {
<div class="info-row">
<span class="info-label">Finished:</span>
<span class="info-value">{{ formatDate(music()!.dateFinished!) }}</span>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(music()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(music()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="music"
[entityId]="music()!.id"
></app-like-button>
</div>
@if (music()!.notes) {
<div class="notes-section">
<h3>Notes</h3>
<p class="notes">{{ music()!.notes }}</p>
</div>
}
@if (music()!.tags && music()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of music()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (music()!.links && music()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of music()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Music | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'}`;
}
}
}
@@ -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: `
<div class="container">
<div class="breadcrumb">
<a routerLink="/shows" class="breadcrumb-link">← Back to Shows</a>
</div>
@if (loading()) {
<div class="loading">Loading show details...</div>
} @else if (error()) {
<div class="error-state">
<h2>Show Not Found</h2>
<p>{{ error() }}</p>
<a routerLink="/shows" class="btn btn-primary">Return to Shows</a>
</div>
} @else if (show()) {
<div class="show-detail-card">
@if (show()!.coverImage) {
<div class="show-poster-section">
<img [src]="show()!.coverImage" [alt]="show()!.title" class="show-poster-large">
</div>
}
<div class="show-content">
<div class="show-header">
<h1>{{ show()!.title }}</h1>
<span class="type-badge type-{{ show()!.type }}">
{{ getTypeLabel(show()!.type) }}
</span>
<span class="status status-{{ show()!.status }}">
{{ getStatusLabel(show()!.status) }}
</span>
</div>
@if (show()!.rating) {
<div class="info-row">
<span class="info-label">Rating:</span>
<div class="rating">
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
<span [class.filled]="star <= show()!.rating!">★</span>
}
<span class="rating-text">({{ show()!.rating }}/10)</span>
</div>
</div>
}
@if (show()!.timeSpent) {
<div class="info-row">
<span class="info-label">Watch Time:</span>
<span class="info-value time-spent">{{ formatTimeSpent(show()!.timeSpent!) }}</span>
</div>
}
@if (show()!.dateStarted) {
<div class="info-row">
<span class="info-label">Started:</span>
<span class="info-value">{{ formatDate(show()!.dateStarted!) }}</span>
</div>
}
@if (show()!.dateFinished) {
<div class="info-row">
<span class="info-label">Finished:</span>
<span class="info-value">{{ formatDate(show()!.dateFinished!) }}</span>
</div>
}
<div class="info-row">
<span class="info-label">Added:</span>
<span class="info-value">{{ formatDate(show()!.createdAt) }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Updated:</span>
<span class="info-value">{{ formatDate(show()!.updatedAt) }}</span>
</div>
<div class="like-section">
<app-like-button
entityType="show"
[entityId]="show()!.id"
></app-like-button>
</div>
@if (show()!.notes) {
<div class="notes-section">
<h3>Notes</h3>
<p class="notes">{{ show()!.notes }}</p>
</div>
}
@if (show()!.tags && show()!.tags.length > 0) {
<div class="tags-section">
<h3>Tags</h3>
<div class="tags-display">
@for (tag of show()!.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
</div>
}
@if (show()!.links && show()!.links.length > 0) {
<div class="links-section">
<h3>External Links</h3>
<div class="links-display">
@for (link of show()!.links; track link.url) {
<a [href]="link.url" target="_blank" rel="noopener noreferrer" class="external-link">
{{ link.title }} ↗
</a>
}
</div>
</div>
}
<div class="comments-section">
<h3>Comments</h3>
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment()" class="comment-form">
<textarea
[(ngModel)]="newCommentContent"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="3"
></textarea>
<button type="submit" class="btn btn-primary">Post Comment</button>
</form>
}
} @else {
<div class="auth-prompt">
<p>Please sign in to comment.</p>
</div>
}
@if (commentsLoading()) {
<div class="comments-loading">Loading comments...</div>
} @else {
<app-comment-display
[comments]="comments"
(edit)="handleCommentEdit($event)"
(delete)="deleteComment($event)"
/>
}
</div>
</div>
</div>
}
</div>
`,
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<Show | null>(null);
comments = signal<Comment[]>([]);
loading = signal(true);
commentsLoading = signal(false);
error = signal<string | null>(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'}`;
}
}
}