feat: add manga and shows collections

This commit is contained in:
2026-02-04 15:41:23 -08:00
parent e5b15e02de
commit 11be34cd21
21 changed files with 2518 additions and 24 deletions
@@ -0,0 +1,898 @@
/**
* @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 { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } from '@library/shared-types';
@Component({
selector: 'app-shows-list',
standalone: true,
imports: [CommonModule, FormsModule],
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>
}
</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>
</select>
</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-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>
</select>
</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-actions">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="button" (click)="cancelEdit()" class="btn btn-secondary">Cancel</button>
</div>
</form>
}
<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>
</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 {
<div class="shows-grid">
@for (show of filteredShows(); 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>
}
@if (show.notes) {
<p class="notes">{{ show.notes }}</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()) {
<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>
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
@if (authService.isAdmin()) {
<button (click)="deleteComment(show.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
}
</div>
<div class="comment-content" [innerHTML]="comment.content"></div>
</div>
} @empty {
<div class="no-comments">No comments yet. Be the first to comment!</div>
}
}
</div>
}
</div>
</div>
</div>
}
</div>
}
</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;
}
.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;
}
.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: #8b5cf6;
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;
}
.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: #8b5cf6; 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;
}
.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;
}
.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;
}
`]
})
export class ShowsListComponent implements OnInit {
showsService = inject(ShowsService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
shows = signal<Show[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingShow = signal<Show | null>(null);
statusFilter = signal<'all' | ShowStatus>('all');
comments = signal<Record<string, Comment[]>>({});
commentsLoading = signal<Record<string, boolean>>({});
expandedComments = signal<Record<string, boolean>>({});
newCommentContent: Record<string, string> = {};
newShowImagePreview = signal<string | null>(null);
editShowImagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
private readonly MAX_IMAGE_SIZE = 500 * 1024;
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);
filteredShows = computed(() => {
const filter = this.statusFilter();
if (filter === 'all') {
return this.shows();
}
return this.shows().filter(show => show.status === filter);
});
newShow: Partial<CreateShowDto> = {
title: '',
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
rating: undefined,
notes: ''
};
editShow: Partial<UpdateShowDto> = {};
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);
}
getStatusLabel(status: ShowStatus): string {
switch (status) {
case ShowStatus.watching: return 'Currently Watching';
case ShowStatus.completed: return 'Completed';
case ShowStatus.wantToWatch: return 'Want to Watch';
}
}
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
};
this.newShowImagePreview.set(null);
this.imageError.set(null);
}
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,
rating: this.newShow.rating,
notes: this.newShow.notes,
coverImage: this.newShow.coverImage
};
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,
rating: show.rating,
notes: show.notes,
coverImage: show.coverImage
};
this.editShowImagePreview.set(show.coverImage || null);
this.showAddForm.set(false);
this.imageError.set(null);
}
cancelEdit() {
this.editingShow.set(null);
this.editShow = {};
this.editShowImagePreview.set(null);
this.imageError.set(null);
}
saveEdit() {
const show = this.editingShow();
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
this.loadShows();
this.cancelEdit();
});
}
onImageSelected(event: Event, target: 'new' | 'edit') {
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 {
this.editShowImagePreview.set(base64);
this.editShow.coverImage = base64;
}
};
reader.readAsDataURL(file);
}
clearImage(target: 'new' | 'edit') {
if (target === 'new') {
this.newShowImagePreview.set(null);
this.newShow.coverImage = undefined;
} else {
this.editShowImagePreview.set(null);
this.editShow.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)
});
}
});
}
}