/**
* @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: `
@if (showAddForm() && authService.isAdmin()) {
}
@if (editingBook() && authService.isAdmin()) {
}
@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()) {
}
}
}
`,
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();
}
});
}
}