generated from nhcarrigan/template
1124 lines
30 KiB
TypeScript
1124 lines
30 KiB
TypeScript
/**
|
|
* @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: `
|
|
<div class="container">
|
|
<div class="header-section">
|
|
<h2>My Book Collection</h2>
|
|
@if (authService.isAdmin()) {
|
|
<button (click)="toggleAddForm()" class="btn btn-primary">
|
|
{{ showAddForm() ? 'Cancel' : 'Add Book' }}
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
@if (showAddForm() && authService.isAdmin()) {
|
|
<form (ngSubmit)="addBook()" class="add-form">
|
|
<h3>Add New Book</h3>
|
|
<div class="form-group">
|
|
<label for="title">Title</label>
|
|
<input
|
|
type="text"
|
|
id="title"
|
|
[(ngModel)]="newBook.title"
|
|
name="title"
|
|
required
|
|
placeholder="Enter book title"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="author">Author</label>
|
|
<input
|
|
type="text"
|
|
id="author"
|
|
[(ngModel)]="newBook.author"
|
|
name="author"
|
|
required
|
|
placeholder="Enter author name"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="isbn">ISBN</label>
|
|
<input
|
|
type="text"
|
|
id="isbn"
|
|
[(ngModel)]="newBook.isbn"
|
|
name="isbn"
|
|
placeholder="ISBN (optional)"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="status">Status</label>
|
|
<select id="status" [(ngModel)]="newBook.status" name="status" required>
|
|
<option [value]="BookStatus.reading">Currently Reading</option>
|
|
<option [value]="BookStatus.finished">Finished</option>
|
|
<option [value]="BookStatus.toRead">To Read</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="rating">Rating (1-10)</label>
|
|
<input
|
|
type="number"
|
|
id="rating"
|
|
[(ngModel)]="newBook.rating"
|
|
name="rating"
|
|
min="1"
|
|
max="10"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="notes">Notes</label>
|
|
<textarea
|
|
id="notes"
|
|
[(ngModel)]="newBook.notes"
|
|
name="notes"
|
|
rows="3"
|
|
placeholder="Your thoughts about the book..."
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="coverImage">Cover Image (max 500KB)</label>
|
|
<input
|
|
type="file"
|
|
id="coverImage"
|
|
name="coverImage"
|
|
accept="image/*"
|
|
(change)="onImageSelected($event, 'new')"
|
|
>
|
|
@if (newBookImagePreview()) {
|
|
<div class="image-preview">
|
|
<img [src]="newBookImagePreview()" 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 Book</button>
|
|
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
|
</div>
|
|
</form>
|
|
}
|
|
|
|
@if (editingBook() && authService.isAdmin()) {
|
|
<form (ngSubmit)="saveEdit()" class="add-form">
|
|
<h3>Edit Book</h3>
|
|
<div class="form-group">
|
|
<label for="edit-title">Title</label>
|
|
<input
|
|
type="text"
|
|
id="edit-title"
|
|
[(ngModel)]="editBook.title"
|
|
name="title"
|
|
required
|
|
placeholder="Enter book title"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-author">Author</label>
|
|
<input
|
|
type="text"
|
|
id="edit-author"
|
|
[(ngModel)]="editBook.author"
|
|
name="author"
|
|
required
|
|
placeholder="Enter author name"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-isbn">ISBN</label>
|
|
<input
|
|
type="text"
|
|
id="edit-isbn"
|
|
[(ngModel)]="editBook.isbn"
|
|
name="isbn"
|
|
placeholder="ISBN (optional)"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-status">Status</label>
|
|
<select id="edit-status" [(ngModel)]="editBook.status" name="status" required>
|
|
<option [value]="BookStatus.reading">Currently Reading</option>
|
|
<option [value]="BookStatus.finished">Finished</option>
|
|
<option [value]="BookStatus.toRead">To Read</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-rating">Rating (1-10)</label>
|
|
<input
|
|
type="number"
|
|
id="edit-rating"
|
|
[(ngModel)]="editBook.rating"
|
|
name="rating"
|
|
min="1"
|
|
max="10"
|
|
>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-notes">Notes</label>
|
|
<textarea
|
|
id="edit-notes"
|
|
[(ngModel)]="editBook.notes"
|
|
name="notes"
|
|
rows="3"
|
|
placeholder="Your thoughts about the book..."
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="edit-coverImage">Cover Image (max 500KB)</label>
|
|
<input
|
|
type="file"
|
|
id="edit-coverImage"
|
|
name="coverImage"
|
|
accept="image/*"
|
|
(change)="onImageSelected($event, 'edit')"
|
|
>
|
|
@if (editBookImagePreview()) {
|
|
<div class="image-preview">
|
|
<img [src]="editBookImagePreview()" 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 ({{ books().length }})
|
|
</button>
|
|
<button
|
|
(click)="setFilter(BookStatus.reading)"
|
|
[class.active]="statusFilter() === BookStatus.reading"
|
|
class="filter-btn"
|
|
>
|
|
Reading ({{ readingCount() }})
|
|
</button>
|
|
<button
|
|
(click)="setFilter(BookStatus.finished)"
|
|
[class.active]="statusFilter() === BookStatus.finished"
|
|
class="filter-btn"
|
|
>
|
|
Finished ({{ finishedCount() }})
|
|
</button>
|
|
<button
|
|
(click)="setFilter(BookStatus.toRead)"
|
|
[class.active]="statusFilter() === BookStatus.toRead"
|
|
class="filter-btn"
|
|
>
|
|
To Read ({{ toReadCount() }})
|
|
</button>
|
|
</div>
|
|
|
|
@if (loading()) {
|
|
<div class="loading">Loading books...</div>
|
|
} @else if (filteredBooks().length === 0) {
|
|
<div class="empty-state">
|
|
<p>No books found in this category.</p>
|
|
</div>
|
|
} @else {
|
|
<div class="books-grid">
|
|
@for (book of filteredBooks(); track book.id) {
|
|
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
|
|
@if (book.coverImage) {
|
|
<img [src]="book.coverImage" [alt]="book.title" class="book-cover">
|
|
} @else {
|
|
<div class="book-cover placeholder">📚</div>
|
|
}
|
|
|
|
<div class="book-info">
|
|
<h3>{{ book.title }}</h3>
|
|
<p class="author">by {{ book.author }}</p>
|
|
|
|
<span class="status status-{{ book.status }}">
|
|
{{ getStatusLabel(book.status) }}
|
|
</span>
|
|
|
|
@if (book.rating) {
|
|
<div class="rating">
|
|
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
|
<span [class.filled]="star <= book.rating!">★</span>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
@if (book.isbn) {
|
|
<p class="isbn">ISBN: {{ book.isbn }}</p>
|
|
}
|
|
|
|
@if (book.notes) {
|
|
<p class="notes">{{ book.notes }}</p>
|
|
}
|
|
|
|
@if (book.dateFinished) {
|
|
<p class="date-finished">
|
|
Finished: {{ formatDate(book.dateFinished) }}
|
|
</p>
|
|
}
|
|
|
|
@if (authService.isAdmin()) {
|
|
<div class="actions">
|
|
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
|
|
Edit
|
|
</button>
|
|
<button (click)="deleteBook(book)" class="btn btn-danger btn-sm">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
<div class="comments-section">
|
|
<button (click)="toggleComments(book.id)" class="btn btn-secondary btn-sm comments-toggle">
|
|
{{ expandedComments()[book.id] ? 'Hide' : 'Show' }} Comments{{ comments()[book.id] ? ' (' + getCommentCount(book.id) + ')' : '' }}
|
|
</button>
|
|
|
|
@if (expandedComments()[book.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(book.id)" class="comment-form">
|
|
<textarea
|
|
[(ngModel)]="newCommentContent[book.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()[book.id]) {
|
|
<div class="comments-loading">Loading comments...</div>
|
|
} @else {
|
|
@for (comment of comments()[book.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 (canEditComment(comment)) {
|
|
<button (click)="startEditComment(book.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
}
|
|
@if (canDeleteComment(comment)) {
|
|
<button (click)="deleteComment(book.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(book.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>
|
|
}
|
|
</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: 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<Book[]>([]);
|
|
loading = signal(true);
|
|
showAddForm = signal(false);
|
|
editingBook = signal<Book | null>(null);
|
|
statusFilter = signal<'all' | BookStatus>('all');
|
|
|
|
// Comments state
|
|
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 = '';
|
|
|
|
// Image upload state
|
|
newBookImagePreview = signal<string | null>(null);
|
|
editBookImagePreview = signal<string | null>(null);
|
|
imageError = signal<string | null>(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<CreateBookDto> = {
|
|
title: '',
|
|
author: '',
|
|
isbn: '',
|
|
status: BookStatus.toRead,
|
|
rating: undefined,
|
|
notes: ''
|
|
};
|
|
|
|
editBook: Partial<UpdateBookDto> = {};
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|
|
} |