generated from nhcarrigan/template
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~ 🌸
This commit is contained in:
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user