/** * @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 { BooksService } from '../../services/books.service'; import { AuthService } from '../../services/auth.service'; import { CommentsService } from '../../services/comments.service'; import { SanitizeService } from '../../services/sanitize.service'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@library/shared-types'; @Component({ selector: 'app-books-list', standalone: true, imports: [CommonModule, FormsModule], template: `

My Book Collection

@if (authService.isAdmin()) { }
@if (showAddForm() && authService.isAdmin()) {

Add New Book

@if (newBookImagePreview()) {
Cover preview
} @if (imageError()) { {{ imageError() }} }
} @if (editingBook() && authService.isAdmin()) {

Edit Book

@if (editBookImagePreview()) {
Cover preview
} @if (imageError()) { {{ imageError() }} }
}
@if (loading()) {
Loading books...
} @else if (filteredBooks().length === 0) {

No books found in this category.

} @else {
@for (book of filteredBooks(); track book.id) {
@if (book.coverImage) { } @else {
📚
}

{{ book.title }}

by {{ book.author }}

{{ getStatusLabel(book.status) }} @if (book.rating) {
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { ★ }
} @if (book.isbn) {

ISBN: {{ book.isbn }}

} @if (book.notes) {

{{ book.notes }}

} @if (book.dateFinished) {

Finished: {{ formatDate(book.dateFinished) }}

} @if (authService.isAdmin()) {
}
@if (expandedComments()[book.id]) {
@if (authService.isAuthenticated()) { @if (authService.user()?.isBanned) {
You have been banned from commenting.
} @else {
} } @if (commentsLoading()[book.id]) {
Loading comments...
} @else { @for (comment of comments()[book.id] || []; track comment.id) {
@if (comment.user.avatar) { } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} @if (canEditComment(comment)) { } @if (canDeleteComment(comment)) { }
@if (editingCommentId() === comment.id) {
} @else {
}
} @empty {
No comments yet. Be the first to comment!
} }
}
}
}
`, 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: rgba(255, 255, 255, 0.95); padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem; border: 2px solid var(--witch-lavender); backdrop-filter: blur(10px); } .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: 2px solid var(--witch-lavender); border-radius: 4px; font-size: 1rem; background-color: var(--witch-moon); color: var(--witch-purple); transition: border-color 0.3s; } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--witch-rose); box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2); } .form-actions { display: flex; gap: 1rem; margin-top: 1rem; } .filters { display: flex; gap: 1rem; margin-bottom: 2rem; } .filter-btn { padding: 0.5rem 1rem; background: var(--witch-lavender); color: var(--witch-purple); border: none; border-radius: 4px; cursor: pointer; transition: all 0.3s; } .filter-btn:hover { background: var(--witch-mauve); transform: translateY(-2px); box-shadow: 0 4px 8px var(--witch-shadow); } .filter-btn.active { background: var(--witch-plum); color: var(--witch-moon); } .loading { text-align: center; padding: 2rem; color: var(--witch-plum); } .empty-state { text-align: center; padding: 3rem; color: var(--witch-plum); } .books-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; } .book-card { background: rgba(255, 255, 255, 0.95); border: 2px solid var(--witch-lavender); border-radius: 8px; overflow: hidden; transition: all 0.3s; backdrop-filter: blur(10px); } .book-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px var(--witch-shadow); border-color: var(--witch-mauve); } .book-card.finished { opacity: 0.9; border-color: var(--witch-mauve); } .book-cover { width: 100%; height: 250px; object-fit: cover; } .book-cover.placeholder { display: flex; align-items: center; justify-content: center; background: var(--witch-lavender); font-size: 4rem; } .book-info { padding: 1rem; } .book-info h3 { margin: 0 0 0.5rem 0; font-size: 1.1rem; } .author { color: var(--witch-plum); font-style: italic; margin: 0.5rem 0; } .status { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: 500; } .status-reading { background: var(--witch-rose); color: var(--witch-moon); } .status-finished { background: var(--witch-mauve); color: var(--witch-purple); } .status-toRead { background: var(--witch-lavender); color: var(--witch-purple); } .rating { margin: 0.5rem 0; } .rating span { color: var(--witch-lavender); font-size: 1.2rem; } .rating span.filled { color: var(--witch-rose); } .isbn { font-size: 0.8rem; color: var(--witch-mauve); margin: 0.5rem 0; } .notes { font-size: 0.9rem; color: var(--witch-plum); margin: 0.5rem 0; } .date-finished { font-size: 0.85rem; color: var(--witch-plum); 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: var(--witch-rose); color: var(--witch-moon); } .btn-primary:hover { background: var(--witch-plum); transform: translateY(-2px); box-shadow: 0 4px 8px var(--witch-shadow); } .btn-secondary { background: var(--witch-mauve); color: var(--witch-purple); } .btn-secondary:hover { background: var(--witch-rose); color: var(--witch-moon); } .btn-danger { background: var(--witch-plum); color: var(--witch-moon); } .btn-danger:hover { background: var(--witch-purple); } .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 var(--witch-lavender); 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: 2px solid var(--witch-lavender); border-radius: 4px; font-size: 0.9rem; background-color: var(--witch-moon); color: var(--witch-purple); resize: vertical; margin-bottom: 0.5rem; } .comment-form textarea:focus { outline: none; border-color: var(--witch-rose); } .comment { background: var(--witch-moon); border: 1px solid var(--witch-lavender); 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: var(--witch-plum); } .comment-date { font-size: 0.75rem; color: var(--witch-mauve); } .comment-content { font-size: 0.9rem; color: var(--witch-purple); } .comment-content :deep(p) { margin: 0.25rem 0; } .comments-loading, .no-comments { text-align: center; padding: 1rem; color: var(--witch-mauve); 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: 2px solid var(--witch-lavender); border-radius: 4px; font-size: 0.9rem; background-color: var(--witch-moon); color: var(--witch-purple); resize: vertical; } .comment-edit-form textarea:focus { outline: none; border-color: var(--witch-rose); } .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 var(--witch-lavender); } .error-text { color: var(--witch-rose); font-size: 0.875rem; display: block; margin-top: 0.25rem; } input[type="file"] { padding: 0.5rem; border: 2px dashed var(--witch-lavender); border-radius: 4px; background: var(--witch-moon); cursor: pointer; } input[type="file"]:hover { border-color: var(--witch-rose); } `] }) export class BooksListComponent implements OnInit { booksService = inject(BooksService); authService = inject(AuthService); commentsService = inject(CommentsService); sanitizeService = inject(SanitizeService); books = signal([]); loading = signal(true); showAddForm = signal(false); editingBook = signal(null); statusFilter = signal<'all' | BookStatus>('all'); // Comments state comments = signal>({}); commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; editingCommentId = signal(null); editCommentContent = ''; // Image upload state newBookImagePreview = signal(null); editBookImagePreview = signal(null); imageError = signal(null); private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB // Expose BookStatus enum to template BookStatus = BookStatus; // Computed signals for reactive count updates readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length); finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length); toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length); filteredBooks = computed(() => { const filter = this.statusFilter(); if (filter === 'all') { return this.books(); } return this.books().filter(book => book.status === filter); }); newBook: Partial = { title: '', author: '', isbn: '', status: BookStatus.toRead, rating: undefined, notes: '' }; editBook: Partial = {}; ngOnInit() { this.loadBooks(); } loadBooks() { this.loading.set(true); this.booksService.getAllBooks().subscribe({ next: (books) => { this.books.set(books); this.loading.set(false); }, error: () => { this.loading.set(false); } }); } setFilter(filter: 'all' | BookStatus) { this.statusFilter.set(filter); } getStatusLabel(status: BookStatus): string { switch (status) { case BookStatus.reading: return 'Currently Reading'; case BookStatus.finished: return 'Finished'; case BookStatus.toRead: return 'To Read'; } } toggleAddForm() { this.showAddForm.update(v => !v); if (!this.showAddForm()) { this.resetForm(); } } resetForm() { this.newBook = { title: '', author: '', isbn: '', status: BookStatus.toRead, rating: undefined, notes: '', coverImage: undefined }; this.newBookImagePreview.set(null); this.imageError.set(null); } addBook() { if (!this.newBook.title || !this.newBook.author || !this.newBook.status) return; const bookToAdd: CreateBookDto = { title: this.newBook.title, author: this.newBook.author, isbn: this.newBook.isbn, status: this.newBook.status, rating: this.newBook.rating, notes: this.newBook.notes, coverImage: this.newBook.coverImage }; this.booksService.createBook(bookToAdd).subscribe(() => { this.loadBooks(); this.toggleAddForm(); }); } deleteBook(book: Book) { if (confirm(`Are you sure you want to delete "${book.title}"?`)) { this.booksService.deleteBook(book.id).subscribe(() => { this.loadBooks(); }); } } startEdit(book: Book) { this.editingBook.set(book); this.editBook = { title: book.title, author: book.author, isbn: book.isbn, status: book.status, rating: book.rating, notes: book.notes, coverImage: book.coverImage }; this.editBookImagePreview.set(book.coverImage || null); this.showAddForm.set(false); this.imageError.set(null); } cancelEdit() { this.editingBook.set(null); this.editBook = {}; this.editBookImagePreview.set(null); this.imageError.set(null); } saveEdit() { const book = this.editingBook(); if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return; this.booksService.updateBook(book.id, this.editBook).subscribe(() => { this.loadBooks(); this.cancelEdit(); }); } formatDate(date: Date | string): string { return new Date(date).toLocaleDateString(); } // Image handling methods 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.newBookImagePreview.set(base64); this.newBook.coverImage = base64; } else { this.editBookImagePreview.set(base64); this.editBook.coverImage = base64; } }; reader.readAsDataURL(file); } clearImage(target: 'new' | 'edit') { if (target === 'new') { this.newBookImagePreview.set(null); this.newBook.coverImage = undefined; } else { this.editBookImagePreview.set(null); this.editBook.coverImage = undefined; } this.imageError.set(null); } // Comments methods toggleComments(bookId: string) { const expanded = this.expandedComments(); const isCurrentlyExpanded = expanded[bookId]; this.expandedComments.set({ ...expanded, [bookId]: !isCurrentlyExpanded }); if (!isCurrentlyExpanded && !this.comments()[bookId]) { this.loadComments(bookId); } } loadComments(bookId: string) { this.commentsLoading.set({ ...this.commentsLoading(), [bookId]: true }); this.commentsService.getCommentsForBook(bookId).subscribe({ next: (comments) => { this.comments.set({ ...this.comments(), [bookId]: comments }); this.commentsLoading.set({ ...this.commentsLoading(), [bookId]: false }); }, error: () => { this.commentsLoading.set({ ...this.commentsLoading(), [bookId]: false }); } }); } getCommentCount(bookId: string): number { return this.comments()[bookId]?.length || 0; } addComment(bookId: string) { const content = this.newCommentContent[bookId]; if (!content?.trim()) return; this.commentsService.addCommentToBook(bookId, { content }).subscribe({ next: (comment) => { this.comments.set({ ...this.comments(), [bookId]: [comment, ...(this.comments()[bookId] || [])] }); this.newCommentContent[bookId] = ''; } }); } deleteComment(bookId: string, commentId: string) { if (!confirm('Are you sure you want to delete this comment?')) return; this.commentsService.deleteCommentFromBook(bookId, commentId).subscribe({ next: () => { this.comments.set({ ...this.comments(), [bookId]: (this.comments()[bookId] || []).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(bookId: string, comment: Comment) { this.editingCommentId.set(comment.id); this.editCommentContent = comment.rawContent ?? comment.content; } cancelCommentEdit() { this.editingCommentId.set(null); this.editCommentContent = ''; } saveCommentEdit(bookId: string, commentId: string) { if (!this.editCommentContent.trim()) return; this.commentsService.updateCommentOnBook(bookId, commentId, this.editCommentContent).subscribe({ next: (updatedComment) => { this.comments.set({ ...this.comments(), [bookId]: (this.comments()[bookId] || []).map(c => c.id === commentId ? updatedComment : c ) }); this.cancelCommentEdit(); } }); } }