Files
library/apps/frontend/src/app/components/shows/shows-list.component.ts
T
naomi 7579f1ec97
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s
feat: multiple improvements to library functionality (#50)
## Summary

This PR implements several improvements to the library application:

- Added start and finish date tracking for media items
- Added "Retired" category for abandoned media
- Implemented avatar-based user menu with dropdown navigation
- Added automatic background token refresh to prevent session expiry
- Created centralised logging system with frontend-to-API log forwarding
- Added toast notifications for error handling

## Changes

### Media Tracking (#41)
- Added `dateStarted` and `dateFinished` fields to Books, Games, Manga, Music, and Shows
- Updated TypeScript types, Prisma schema, and API services
- Added manual date input fields to frontend forms
- Properly converts HTML date strings to Date objects before API submission

### Retired Category (#43)
- Added `RETIRED` status to all media type enums
- Updated Prisma schema, frontend dropdowns, and filter buttons
- Added status label handling for retired items

### User Menu (#46)
- Replaced username text with avatar image in header
- Created dropdown menu with navigation items (Users, Audit, Suggestions)
- Added logout button to menu
- Implemented keyboard accessibility (tabindex, role, keyup handlers)

### Token Refresh (#44)
- Implemented automatic token refresh every 13 minutes in background
- Added proactive refresh to prevent token expiry during form filling
- Prevents users from losing form data due to expired sessions

### Centralised Logging (#1)
- Created `/log` endpoint on API to receive frontend logs
- Replaced API console.log calls with @nhcarrigan/logger
- Created ConsoleLoggerService to intercept all console methods on frontend
- Added global error handlers (window.error, unhandledrejection) on frontend
- Added process error handlers (uncaughtException, unhandledRejection, SIGTERM, SIGINT) on API
- All frontend console activity now forwarded to centralised logging

### Error Handling
- Created ToastService and ToastComponent for displaying errors
- Integrated with GlobalErrorHandler and HTTP interceptor
- Added accessibility features (keyboard navigation, ARIA attributes)
- Set toast opacity to 40% for optimal readability

### Testing & Build
- Fixed pre-existing test failure for GET / route (now returns version info)
- Added ESM module mocking (jsdom, marked, dompurify, @nhcarrigan/logger)
- Configured Jest with isolatedModules to handle TypeScript errors
- Excluded test-setup.ts from production build
- All tests passing (123 total)
- Build passing with no errors

## Test Plan

- [x] All tests pass (123 tests)
- [x] Build passes without errors
- [x] Lint passes (only pre-existing warnings)
- [x] Date fields work correctly on all media types
- [x] Retired status displays and filters properly
- [x] Avatar menu opens/closes correctly with keyboard and mouse
- [x] Token refresh prevents session expiry
- [x] Toast notifications appear for errors
- [x] Frontend logs forward to API successfully
- [x] Root route returns version information

Closes #41
Closes #43
Closes #44
Closes #46
Closes #1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-19 16:52:43 -08:00

1787 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ShowsService } from '../../services/shows.service';
import { AuthService } from '../../services/auth.service';
import { CommentsService } from '../../services/comments.service';
import { SanitizeService } from '../../services/sanitize.service';
import { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-shows-list',
standalone: true,
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="header-section">
<h2>My Shows &amp; Films</h2>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Show' }}
</button>
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
<button (click)="toggleSuggestForm()" class="btn btn-primary">
{{ showSuggestForm() ? 'Cancel' : 'Suggest a Show' }}
</button>
}
</div>
@if (showAddForm() && authService.isAdmin()) {
<form (ngSubmit)="addShow()" class="add-form">
<h3>Add New Show</h3>
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
[(ngModel)]="newShow.title"
name="title"
required
placeholder="Enter show title"
>
</div>
<div class="form-group">
<label for="type">Type</label>
<select id="type" [(ngModel)]="newShow.type" name="type" required>
<option [value]="ShowType.tvSeries">TV Series</option>
<option [value]="ShowType.anime">Anime</option>
<option [value]="ShowType.film">Film</option>
<option [value]="ShowType.documentary">Documentary</option>
</select>
</div>
<div class="form-group">
<label for="status">Status</label>
<select id="status" [(ngModel)]="newShow.status" name="status" required>
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
type="number"
id="rating"
[(ngModel)]="newShow.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
[(ngModel)]="newShow.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the show..."
></textarea>
</div>
<div class="form-group">
<label for="coverImage">Poster/Cover Art (max 500KB)</label>
<input
type="file"
id="coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'new')"
>
@if (newShowImagePreview()) {
<div class="image-preview">
<img [src]="newShowImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('new')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'new')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="newTagInput"
name="newTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('new'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of newShow.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'new')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="newLinkTitle"
name="newLinkTitle"
placeholder="Link title (e.g., IMDB)"
>
<input
type="url"
[(ngModel)]="newLinkUrl"
name="newLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('new')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Show</button>
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (editingShow() && authService.isAdmin()) {
<form (ngSubmit)="saveEdit()" class="add-form">
<h3>Edit Show</h3>
<div class="form-group">
<label for="edit-title">Title</label>
<input
type="text"
id="edit-title"
[(ngModel)]="editShow.title"
name="title"
required
placeholder="Enter show title"
>
</div>
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editShow.type" name="type" required>
<option [value]="ShowType.tvSeries">TV Series</option>
<option [value]="ShowType.anime">Anime</option>
<option [value]="ShowType.film">Film</option>
<option [value]="ShowType.documentary">Documentary</option>
</select>
</div>
<div class="form-group">
<label for="edit-status">Status</label>
<select id="edit-status" [(ngModel)]="editShow.status" name="status" required>
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
type="number"
id="edit-rating"
[(ngModel)]="editShow.rating"
name="rating"
min="1"
max="10"
>
</div>
<div class="form-group">
<label for="edit-notes">Notes</label>
<textarea
id="edit-notes"
[(ngModel)]="editShow.notes"
name="notes"
rows="3"
placeholder="Any thoughts about the show..."
></textarea>
</div>
<div class="form-group">
<label for="edit-coverImage">Poster/Cover Art (max 500KB)</label>
<input
type="file"
id="edit-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'edit')"
>
@if (editShowImagePreview()) {
<div class="image-preview">
<img [src]="editShowImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('edit')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
<button type="button" (click)="removeTag(i, 'edit')" class="tag-remove">×</button>
</span>
}
<input
type="text"
[(ngModel)]="editTagInput"
name="editTagInput"
placeholder="Add a tag and press Enter"
(keydown.enter)="addTag('edit'); $event.preventDefault()"
>
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="links-list">
@for (link of editShow.links; track link.url; let i = $index) {
<div class="link-item">
<span>{{ link.title }}: {{ link.url }}</span>
<button type="button" (click)="removeLink(i, 'edit')" class="btn btn-danger btn-sm">×</button>
</div>
}
</div>
<div class="link-add-form">
<input
type="text"
[(ngModel)]="editLinkTitle"
name="editLinkTitle"
placeholder="Link title (e.g., IMDB)"
>
<input
type="url"
[(ngModel)]="editLinkUrl"
name="editLinkUrl"
placeholder="https://..."
>
<button type="button" (click)="addLink('edit')" class="btn btn-secondary btn-sm">Add Link</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
@if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {
<form (ngSubmit)="submitSuggestion()" class="add-form suggest-form">
<h3>Suggest a Show</h3>
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the watch list!</p>
<div class="form-group">
<label for="suggest-title">Title</label>
<input
type="text"
id="suggest-title"
[(ngModel)]="suggestedShow.title"
name="title"
required
placeholder="Enter show title"
>
</div>
<div class="form-group">
<label for="suggest-type">Type</label>
<select id="suggest-type" [(ngModel)]="suggestedShow.type" name="type" required>
<option [value]="ShowType.tvSeries">TV Series</option>
<option [value]="ShowType.anime">Anime</option>
<option [value]="ShowType.film">Film</option>
<option [value]="ShowType.documentary">Documentary</option>
</select>
</div>
<div class="form-group">
<label for="suggest-notes">Notes (why should Naomi watch this?)</label>
<textarea
id="suggest-notes"
[(ngModel)]="suggestedShow.notes"
name="notes"
rows="3"
placeholder="Tell Naomi why this show is worth watching..."
></textarea>
</div>
<div class="form-group">
<label for="suggest-coverImage">Poster/Cover Art (max 500KB)</label>
<input
type="file"
id="suggest-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'suggest')"
>
@if (suggestShowImagePreview()) {
<div class="image-preview">
<img [src]="suggestShowImagePreview()" alt="Cover preview">
<button type="button" (click)="clearImage('suggest')" class="btn btn-danger btn-sm">Remove</button>
</div>
}
@if (imageError()) {
<span class="error-text">{{ imageError() }}</span>
}
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit Suggestion</button>
<button type="button" (click)="toggleSuggestForm()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<div class="search-section">
<input
type="text"
[value]="searchQuery()"
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
name="search"
placeholder="Search by title, type, or notes..."
class="search-input"
>
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
@if (selectedTags().length > 0) {
({{ selectedTags().length }})
}
</button>
@if (searchQuery() || selectedTags().length > 0) {
<button (click)="clearFilters()" class="btn btn-secondary btn-sm">
Clear All Filters
</button>
}
</div>
@if (showFilters()) {
<div class="advanced-filters">
<div class="filter-group">
<h4>Filter by Tags</h4>
<div class="tags-filter">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
type="checkbox"
[checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)"
>
<span>{{ tag }}</span>
</label>
}
@empty {
<p class="no-tags">No tags available</p>
}
</div>
</div>
</div>
}
<div class="filters">
<button
(click)="setFilter('all')"
[class.active]="statusFilter() === 'all'"
class="filter-btn"
>
All ({{ shows().length }})
</button>
<button
(click)="setFilter(ShowStatus.watching)"
[class.active]="statusFilter() === ShowStatus.watching"
class="filter-btn"
>
Watching ({{ watchingCount() }})
</button>
<button
(click)="setFilter(ShowStatus.completed)"
[class.active]="statusFilter() === ShowStatus.completed"
class="filter-btn"
>
Completed ({{ completedCount() }})
</button>
<button
(click)="setFilter(ShowStatus.wantToWatch)"
[class.active]="statusFilter() === ShowStatus.wantToWatch"
class="filter-btn"
>
Want to Watch ({{ wantToWatchCount() }})
</button>
<button
(click)="setFilter(ShowStatus.retired)"
[class.active]="statusFilter() === ShowStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
<div class="loading">Loading shows...</div>
} @else if (filteredShows().length === 0) {
<div class="empty-state">
<p>No shows found in this category.</p>
</div>
} @else {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredShows()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="shows-grid">
@for (show of paginatedShows(); track show.id) {
<div class="show-card" [class.completed]="show.status === ShowStatus.completed">
@if (show.coverImage) {
<img [src]="show.coverImage" [alt]="show.title" class="show-cover">
}
<div class="show-info">
<h3>{{ show.title }}</h3>
<p class="type">{{ getTypeLabel(show.type) }}</p>
<span class="status status-{{ show.status }}">
{{ getStatusLabel(show.status) }}
</span>
@if (show.rating) {
<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>
}
</div>
}
<app-like-button
entityType="show"
[entityId]="show.id"
></app-like-button>
@if (show.notes) {
<p class="notes">{{ show.notes }}</p>
}
@if (show.tags && show.tags.length > 0) {
<div class="tags-display">
@for (tag of show.tags; track tag) {
<span class="tag-chip">{{ tag }}</span>
}
</div>
}
@if (show.links && show.links.length > 0) {
<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>
}
@if (show.dateStarted) {
<p class="date-started">
Started: {{ formatDate(show.dateStarted) }}
</p>
}
@if (show.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(show.dateFinished) }}
</p>
}
@if (show.createdAt) {
<p class="date-added">
Added: {{ formatDate(show.createdAt) }}
</p>
}
@if (show.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(show.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
Edit
</button>
<button (click)="deleteShow(show)" class="btn btn-danger btn-sm">
Delete
</button>
</div>
}
<div class="comments-section">
<button (click)="toggleComments(show.id)" class="btn btn-secondary btn-sm comments-toggle">
{{ expandedComments()[show.id] ? 'Hide' : 'Show' }} Comments{{ comments()[show.id] ? ' (' + getCommentCount(show.id) + ')' : '' }}
</button>
@if (expandedComments()[show.id]) {
<div class="comments-container">
@if (authService.isAuthenticated()) {
@if (authService.user()?.isBanned) {
<div class="banned-notice">
You have been banned from commenting.
</div>
} @else {
<form (ngSubmit)="addComment(show.id)" class="comment-form">
<textarea
[(ngModel)]="newCommentContent[show.id]"
name="comment"
placeholder="Add a comment (Markdown supported)..."
rows="2"
></textarea>
<button type="submit" class="btn btn-primary btn-sm">Post Comment</button>
</form>
}
}
@if (commentsLoading()[show.id]) {
<div class="comments-loading">Loading comments...</div>
} @else {
@for (comment of comments()[show.id] || []; track comment.id) {
<div class="comment">
<div class="comment-header">
@if (comment.user.avatar) {
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
}
<span class="comment-author">{{ comment.user.username }}</span>
@if (comment.user.inDiscord) {
<span class="discord-badge">Discord</span>
}
@if (comment.user.isVip) {
<span class="vip-badge">VIP</span>
}
@if (comment.user.isMod) {
<span class="mod-badge">Mod</span>
}
@if (comment.user.isStaff) {
<span class="staff-badge">Staff</span>
}
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (canEditComment(comment)) {
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
}
@if (canDeleteComment(comment)) {
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
@if (editingCommentId() === comment.id) {
<div class="comment-edit-form">
<textarea
[(ngModel)]="editCommentContent"
name="editComment"
rows="3"
></textarea>
<div class="comment-edit-actions">
<button (click)="saveCommentEdit(show.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
</div>
</div>
} @else {
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
}
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredShows()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h2 {
margin: 0;
}
.add-form {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.suggest-form {
border: 2px solid #e84393;
background: #fff5f9;
}
.suggest-note {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
font-style: italic;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.search-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: #e84393;
}
.advanced-filters {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-group h4 {
margin: 0 0 0.75rem 0;
color: #374151;
font-size: 0.95rem;
font-weight: 600;
}
.tags-filter {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.tag-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 20px;
background: white;
transition: all 0.2s;
}
.tag-checkbox:hover {
border-color: #e84393;
background: #fff5f9;
}
.tag-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.tag-checkbox span {
font-size: 0.875rem;
color: #374151;
}
.no-tags {
color: #6b7280;
font-style: italic;
font-size: 0.875rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
background: #e5e7eb;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn:hover {
background: #d1d5db;
}
.filter-btn.active {
background: #e84393;
color: white;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.shows-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.show-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s, box-shadow 0.3s;
}
.show-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.show-card.completed {
opacity: 0.8;
}
.show-cover {
width: 100%;
height: 200px;
object-fit: cover;
}
.show-info {
padding: 1rem;
}
.show-info h3 {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
}
.type {
color: #666;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.status {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.status-WATCHING { background: #fef3c7; color: #92400e; }
.status-COMPLETED { background: #d1fae5; color: #065f46; }
.status-WANT_TO_WATCH { background: #e0e7ff; color: #3730a3; }
.rating {
margin: 0.5rem 0;
}
.rating span {
color: #e5e7eb;
font-size: 1rem;
}
.rating span.filled {
color: #8b5cf6;
}
.notes {
font-size: 0.9rem;
color: #4b5563;
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: opacity 0.3s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary { background: #e84393; color: white; }
.btn-secondary { background: #6b7280; color: white; }
.btn-danger { background: #ef4444; color: white; }
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.comments-section {
margin-top: 1rem;
border-top: 1px solid #e5e7eb;
padding-top: 1rem;
}
.comments-toggle {
width: 100%;
}
.comments-container {
margin-top: 1rem;
}
.comment-form {
margin-bottom: 1rem;
}
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 0.5rem;
}
.comment {
background: #f8f9fa;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.comment-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.comment-author {
font-weight: 500;
color: #374151;
}
.discord-badge {
background: #5865f2;
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 500;
}
.vip-badge {
background: linear-gradient(135deg, #ffd700, #ffaa00);
color: #1a1a1a;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.mod-badge {
background: linear-gradient(135deg, #00b894, #00cec9);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.staff-badge {
background: linear-gradient(135deg, #e84393, #fd79a8);
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
}
.comment-date {
font-size: 0.75rem;
color: #6b7280;
}
.comment-content {
font-size: 0.9rem;
color: #4b5563;
}
.comments-loading,
.no-comments {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.9rem;
}
.banned-notice {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 4px;
text-align: center;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.comment-edit-form {
margin-top: 0.5rem;
}
.comment-edit-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
resize: vertical;
}
.comment-edit-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.image-preview {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.image-preview img {
max-width: 100px;
max-height: 150px;
border-radius: 4px;
border: 2px solid #e5e7eb;
}
.error-text {
color: #ef4444;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
input[type="file"] {
padding: 0.5rem;
border: 2px dashed #e5e7eb;
border-radius: 4px;
background: #f9fafb;
cursor: pointer;
}
input[type="file"]:hover {
border-color: #8b5cf6;
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: #f9fafb;
}
.tags-input-container input {
flex: 1;
min-width: 150px;
border: none;
padding: 0.25rem;
font-size: 0.9rem;
background: transparent;
}
.tags-input-container input:focus {
outline: none;
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: #e84393;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.tag-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.tag-remove:hover {
opacity: 0.8;
}
.tags-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag-chip {
background: rgba(232, 67, 147, 0.2);
color: #e84393;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: #f9fafb;
border-radius: 4px;
margin-bottom: 0.25rem;
}
.link-add-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.link-add-form input {
flex: 1;
min-width: 120px;
}
.links-display {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.5rem 0;
}
.external-link {
color: #e84393;
text-decoration: none;
font-size: 0.85rem;
padding: 0.2rem 0.5rem;
background: rgba(232, 67, 147, 0.1);
border-radius: 4px;
transition: background 0.2s;
}
.external-link:hover {
background: rgba(232, 67, 147, 0.2);
text-decoration: underline;
}
`]
})
export class ShowsListComponent implements OnInit {
showsService = inject(ShowsService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
suggestionService = inject(SuggestionService);
shows = signal<Show[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingShow = signal<Show | null>(null);
statusFilter = signal<'all' | ShowStatus>('all');
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// Search and filter state
searchQuery = signal('');
selectedTags = signal<string[]>([]);
showFilters = signal(false);
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
editingCommentId = signal<string | null>(null);
editCommentContent = '';
newShowImagePreview = signal<string | null>(null);
editShowImagePreview = signal<string | null>(null);
suggestShowImagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
private readonly MAX_IMAGE_SIZE = 500 * 1024;
// Suggestion state
showSuggestForm = signal(false);
suggestedShow: { title: string; type: ShowType; notes?: string; coverImage?: string } = {
title: '',
type: ShowType.tvSeries,
notes: '',
coverImage: undefined
};
ShowStatus = ShowStatus;
ShowType = ShowType;
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length);
wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length);
retiredCount = computed(() => this.shows().filter(show => show.status === ShowStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
this.shows().forEach(show => {
show.tags?.forEach(tag => tagsSet.add(tag));
});
return Array.from(tagsSet).sort();
});
filteredShows = computed(() => {
let shows = this.shows();
// Apply status filter
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
shows = shows.filter(show => show.status === statusFilter);
}
// Apply search filter
const searchQuery = this.searchQuery().toLowerCase().trim();
if (searchQuery) {
shows = shows.filter(show =>
show.title.toLowerCase().includes(searchQuery) ||
show.notes?.toLowerCase().includes(searchQuery) ||
this.getTypeLabel(show.type).toLowerCase().includes(searchQuery)
);
}
// Apply tag filter
const selectedTags = this.selectedTags();
if (selectedTags.length > 0) {
shows = shows.filter(show =>
selectedTags.every(tag => show.tags?.includes(tag))
);
}
return shows;
});
paginatedShows = computed(() => {
const shows = this.filteredShows();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return shows.slice(start, end);
});
totalFilteredShows = computed(() => this.filteredShows().length);
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
rating: undefined,
notes: '',
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
editShow: Partial<UpdateShowDto> = {};
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
ngOnInit() {
this.loadShows();
}
loadShows() {
this.loading.set(true);
this.showsService.getAllShows().subscribe({
next: (shows) => {
this.shows.set(shows);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
setFilter(filter: 'all' | ShowStatus) {
this.statusFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filter changes
}
toggleTag(tag: string) {
const current = this.selectedTags();
if (current.includes(tag)) {
this.selectedTags.set(current.filter(t => t !== tag));
} else {
this.selectedTags.set([...current, tag]);
}
this.currentPage.set(1); // Reset to first page when tags change
}
clearFilters() {
this.searchQuery.set('');
this.selectedTags.set([]);
this.statusFilter.set('all');
this.currentPage.set(1);
}
toggleFilters() {
this.showFilters.update(v => !v);
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(pageSize: number) {
this.pageSize.set(pageSize);
// Calculate new current page to stay on approximately the same content
const firstItemIndex = (this.currentPage() - 1) * this.pageSize();
const newPage = Math.floor(firstItemIndex / pageSize) + 1;
this.currentPage.set(newPage);
}
getStatusLabel(status: 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';
}
}
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';
}
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newShow = {
title: '',
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
rating: undefined,
notes: '',
coverImage: undefined,
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
this.newShowImagePreview.set(null);
this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newShow.tags = [...(this.newShow.tags || []), input];
this.newTagInput = '';
} else {
this.editShow.tags = [...(this.editShow.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newShow.tags = (this.newShow.tags || []).filter((_, i) => i !== index);
} else {
this.editShow.tags = (this.editShow.tags || []).filter((_, i) => i !== index);
}
}
addLink(target: 'new' | 'edit') {
const title = target === 'new' ? this.newLinkTitle.trim() : this.editLinkTitle.trim();
const url = target === 'new' ? this.newLinkUrl.trim() : this.editLinkUrl.trim();
if (!title || !url) return;
if (target === 'new') {
this.newShow.links = [...(this.newShow.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editShow.links = [...(this.editShow.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newShow.links = (this.newShow.links || []).filter((_, i) => i !== index);
} else {
this.editShow.links = (this.editShow.links || []).filter((_, i) => i !== index);
}
}
addShow() {
if (!this.newShow.title || !this.newShow.type || !this.newShow.status) return;
const showToAdd: CreateShowDto = {
title: this.newShow.title,
type: this.newShow.type,
status: this.newShow.status,
dateStarted: this.newShow.dateStarted ? new Date(this.newShow.dateStarted) : undefined,
dateFinished: this.newShow.dateFinished ? new Date(this.newShow.dateFinished) : undefined,
rating: this.newShow.rating,
notes: this.newShow.notes,
coverImage: this.newShow.coverImage,
tags: this.newShow.tags || [],
links: this.newShow.links || []
};
this.showsService.createShow(showToAdd).subscribe(() => {
this.loadShows();
this.toggleAddForm();
});
}
deleteShow(show: Show) {
if (confirm(`Are you sure you want to delete "${show.title}"?`)) {
this.showsService.deleteShow(show.id).subscribe(() => {
this.loadShows();
});
}
}
startEdit(show: Show) {
this.editingShow.set(show);
this.editShow = {
title: show.title,
type: show.type,
status: show.status,
dateStarted: show.dateStarted,
dateFinished: show.dateFinished,
rating: show.rating,
notes: show.notes,
coverImage: show.coverImage,
tags: [...(show.tags || [])],
links: [...(show.links || [])]
};
this.editShowImagePreview.set(show.coverImage || null);
this.showAddForm.set(false);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
cancelEdit() {
this.editingShow.set(null);
this.editShow = {};
this.editShowImagePreview.set(null);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
saveEdit() {
const show = this.editingShow();
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
const updateData = {
...this.editShow,
dateStarted: this.editShow.dateStarted ? new Date(this.editShow.dateStarted) : undefined,
dateFinished: this.editShow.dateFinished ? new Date(this.editShow.dateFinished) : undefined,
};
this.showsService.updateShow(show.id, updateData).subscribe(() => {
this.loadShows();
this.cancelEdit();
});
}
onImageSelected(event: Event, target: 'new' | 'edit' | 'suggest') {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.imageError.set(null);
if (file.size > this.MAX_IMAGE_SIZE) {
this.imageError.set(`Image too large. Maximum size is ${this.MAX_IMAGE_SIZE / 1024}KB.`);
input.value = '';
return;
}
if (!file.type.startsWith('image/')) {
this.imageError.set('Please select an image file.');
input.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
if (target === 'new') {
this.newShowImagePreview.set(base64);
this.newShow.coverImage = base64;
} else if (target === 'edit') {
this.editShowImagePreview.set(base64);
this.editShow.coverImage = base64;
} else {
this.suggestShowImagePreview.set(base64);
this.suggestedShow.coverImage = base64;
}
};
reader.readAsDataURL(file);
}
clearImage(target: 'new' | 'edit' | 'suggest') {
if (target === 'new') {
this.newShowImagePreview.set(null);
this.newShow.coverImage = undefined;
} else if (target === 'edit') {
this.editShowImagePreview.set(null);
this.editShow.coverImage = undefined;
} else {
this.suggestShowImagePreview.set(null);
this.suggestedShow.coverImage = undefined;
}
this.imageError.set(null);
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
toggleComments(showId: string) {
const expanded = this.expandedComments();
const isCurrentlyExpanded = expanded[showId];
this.expandedComments.set({
...expanded,
[showId]: !isCurrentlyExpanded
});
if (!isCurrentlyExpanded && !this.comments()[showId]) {
this.loadComments(showId);
}
}
loadComments(showId: string) {
this.commentsLoading.set({
...this.commentsLoading(),
[showId]: true
});
this.commentsService.getCommentsForShow(showId).subscribe({
next: (comments) => {
this.comments.set({
...this.comments(),
[showId]: comments
});
this.commentsLoading.set({
...this.commentsLoading(),
[showId]: false
});
},
error: () => {
this.commentsLoading.set({
...this.commentsLoading(),
[showId]: false
});
}
});
}
getCommentCount(showId: string): number {
return this.comments()[showId]?.length || 0;
}
addComment(showId: string) {
const content = this.newCommentContent[showId];
if (!content?.trim()) return;
this.commentsService.addCommentToShow(showId, { content }).subscribe({
next: (comment) => {
this.comments.set({
...this.comments(),
[showId]: [comment, ...(this.comments()[showId] || [])]
});
this.newCommentContent[showId] = '';
}
});
}
deleteComment(showId: string, commentId: string) {
if (!confirm('Are you sure you want to delete this comment?')) return;
this.commentsService.deleteCommentFromShow(showId, commentId).subscribe({
next: () => {
this.comments.set({
...this.comments(),
[showId]: (this.comments()[showId] || []).filter(c => c.id !== commentId)
});
}
});
}
canEditComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
canDeleteComment(comment: Comment): boolean {
const user = this.authService.user();
if (!user) return false;
return comment.userId === user.id || this.authService.isAdmin();
}
startEditComment(showId: string, comment: Comment) {
this.editingCommentId.set(comment.id);
this.editCommentContent = comment.rawContent ?? comment.content;
}
cancelCommentEdit() {
this.editingCommentId.set(null);
this.editCommentContent = '';
}
saveCommentEdit(showId: string, commentId: string) {
if (!this.editCommentContent.trim()) return;
this.commentsService.updateCommentOnShow(showId, commentId, this.editCommentContent).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[showId]: (this.comments()[showId] || []).map(c =>
c.id === commentId ? updatedComment : c
)
});
this.cancelCommentEdit();
}
});
}
// Suggestion methods
toggleSuggestForm() {
this.showSuggestForm.update(v => !v);
if (!this.showSuggestForm()) {
this.resetSuggestForm();
}
}
resetSuggestForm() {
this.suggestedShow = {
title: '',
type: ShowType.tvSeries,
notes: '',
coverImage: undefined
};
this.suggestShowImagePreview.set(null);
this.imageError.set(null);
}
async submitSuggestion() {
if (!this.suggestedShow.title) return;
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.show,
title: this.suggestedShow.title,
type: this.suggestedShow.type,
notes: this.suggestedShow.notes,
coverImage: this.suggestedShow.coverImage
});
alert('Thank you for your suggestion! It will be reviewed soon.');
this.toggleSuggestForm();
} catch {
alert('Failed to submit suggestion. Please try again.');
}
}
}