Files
library/apps/frontend/src/app/components/books/books-list.component.ts
T
hikari ff0ae73fa7
Node.js CI / CI (push) Successful in 1m44s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m16s
feat: themed avatars and branding updates (#67)
## Summary

Added comprehensive avatar and branding updates across the library application:

### 🌸 Updated Main Branding
- New library-themed avatar (with playful "shh" gesture) for navigation icon
- Updated favicon and all PWA icons (10 sizes from 72x72 to 512x512)
- Added hero avatar to home page between title and subtitle
- All branding uses consistent circular styling with elegant hover effects

### 🎨 Media-Specific Avatars
Added unique themed avatars to each media list page:

- **🎮 Games**: Gaming setup with controller and LED lights (red #ff6b6b border)
- **📚 Books**: Reading in cozy library setting (brown #8b6f47 border)
- **🎵 Music**: Joyful with headphones and urban nightscape (blue #74b9ff border)
- **📺 Shows**: Relaxing with remote and theater curtains (pink #e84393 border)
- **📖 Manga**: Reading manga with shelves background (teal #00b894 border)
- **🎨 Art**: Art studio with paintbrush (yellow #fdcb6e border)

### ✨ Features
- 120x120px circular avatars with themed colour borders
- Smooth hover animations (scale + shadow effects)
- Centered hero sections at top of each list view
- Consistent styling across all media types
- Perfect integration with existing colour themes

### 📊 Technical Details
- All icons generated from source images at multiple resolutions
- Static assets served with correct MIME types
- Optimised image formats for performance
- Responsive design with proper accessibility attributes

## Test Plan

- [x] Verify navigation icon displays correctly in header
- [x] Check favicon appears in browser tabs
- [x] Test PWA icons on mobile devices
- [x] Confirm home page hero avatar renders properly
- [x] Verify all 6 media list avatars display with correct borders
- [x] Test hover animations on all avatars
- [x] Verify build succeeds
- [x] Check static assets serve with correct MIME types

🌸 Created with love by Hikari 💖

Reviewed-on: #67
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-20 21:11:05 -08:00

1860 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 { RouterLink } from '@angular/router';
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 { SuggestionService } from '../../services/suggestion.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
@Component({
selector: 'app-books-list',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="page-hero">
<img src="/assets/avatars/books-avatar.jpg" alt="Books avatar" class="page-avatar" />
</div>
<div class="header-section">
<h2>My Book Collection</h2>
@if (authService.isAdmin()) {
<button (click)="toggleAddForm()" class="btn btn-primary">
{{ showAddForm() ? 'Cancel' : 'Add Book' }}
</button>
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
<button (click)="toggleSuggestForm()" class="btn btn-primary">
{{ showSuggestForm() ? 'Cancel' : 'Suggest a 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>
<option [value]="BookStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newBook.dateFinished"
name="dateFinished"
>
</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-row">
<div class="form-group">
<label for="timeHours">Time Spent (Hours)</label>
<input
type="number"
id="timeHours"
[(ngModel)]="newBookTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateNewBookTimeSpent()"
>
</div>
<div class="form-group">
<label for="timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="timeMinutes"
[(ngModel)]="newBookTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateNewBookTimeSpent()"
>
</div>
</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="series">Series (optional)</label>
<input
type="text"
id="series"
[(ngModel)]="newBook.series"
name="series"
placeholder="e.g., Harry Potter"
>
</div>
<div class="form-group">
<label for="seriesOrder">Series Order (optional)</label>
<input
type="number"
id="seriesOrder"
[(ngModel)]="newBook.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</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-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newBook.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 newBook.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., Goodreads)"
>
<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 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>
<option [value]="BookStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editBook.dateFinished"
name="dateFinished"
>
</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-row">
<div class="form-group">
<label for="edit-timeHours">Time Spent (Hours)</label>
<input
type="number"
id="edit-timeHours"
[(ngModel)]="editBookTimeHours"
name="timeHours"
min="0"
placeholder="0"
(ngModelChange)="updateEditBookTimeSpent()"
>
</div>
<div class="form-group">
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
<input
type="number"
id="edit-timeMinutes"
[(ngModel)]="editBookTimeMinutes"
name="timeMinutes"
min="0"
max="59"
placeholder="0"
(ngModelChange)="updateEditBookTimeSpent()"
>
</div>
</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-series">Series (optional)</label>
<input
type="text"
id="edit-series"
[(ngModel)]="editBook.series"
name="series"
placeholder="e.g., Harry Potter"
>
</div>
<div class="form-group">
<label for="edit-seriesOrder">Series Order (optional)</label>
<input
type="number"
id="edit-seriesOrder"
[(ngModel)]="editBook.seriesOrder"
name="seriesOrder"
min="1"
placeholder="Order in series"
>
</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-group">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editBook.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 editBook.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., Goodreads)"
>
<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 Book</h3>
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the reading list!</p>
<div class="form-group">
<label for="suggest-title">Title</label>
<input
type="text"
id="suggest-title"
[(ngModel)]="suggestedBook.title"
name="title"
required
placeholder="Enter book title"
>
</div>
<div class="form-group">
<label for="suggest-author">Author</label>
<input
type="text"
id="suggest-author"
[(ngModel)]="suggestedBook.author"
name="author"
required
placeholder="Enter author name"
>
</div>
<div class="form-group">
<label for="suggest-isbn">ISBN (optional)</label>
<input
type="text"
id="suggest-isbn"
[(ngModel)]="suggestedBook.isbn"
name="isbn"
placeholder="ISBN (optional)"
>
</div>
<div class="form-group">
<label for="suggest-notes">Notes (why should Naomi read this?)</label>
<textarea
id="suggest-notes"
[(ngModel)]="suggestedBook.notes"
name="notes"
rows="3"
placeholder="Tell Naomi why this book is worth reading..."
></textarea>
</div>
<div class="form-group">
<label for="suggest-coverImage">Cover Image (max 500KB)</label>
<input
type="file"
id="suggest-coverImage"
name="coverImage"
accept="image/*"
(change)="onImageSelected($event, 'suggest')"
>
@if (suggestBookImagePreview()) {
<div class="image-preview">
<img [src]="suggestBookImagePreview()" 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"
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange()"
placeholder="Search by title, author, ISBN, or notes..."
class="search-input"
>
<button
(click)="toggleFilters()"
class="btn btn-secondary"
[class.active]="showFilters()"
>
<span>🔧 Advanced Filters</span>
@if (selectedTags().length > 0) {
<span class="filter-badge">{{ selectedTags().length }}</span>
}
</button>
</div>
@if (showFilters()) {
<div class="advanced-filters">
<div class="filter-group">
<div class="tags-filter" aria-label="Filter by Tags">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
type="checkbox"
[checked]="selectedTags().includes(tag)"
(change)="toggleTag(tag)"
>
<span class="tag-label">{{ tag }}</span>
</label>
}
@empty {
<p class="no-tags">No tags available</p>
}
</div>
@if (selectedTags().length > 0) {
<button (click)="clearTags()" class="btn btn-sm btn-secondary clear-tags">
Clear All Tags
</button>
}
</div>
</div>
}
<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>
<button
(click)="setFilter(BookStatus.retired)"
[class.active]="statusFilter() === BookStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</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 {
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredBooks()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="books-grid">
@for (book of paginatedBooks(); track book.id) {
<div class="book-card" [class.finished]="book.status === BookStatus.finished">
<a [routerLink]="['/books', book.id]" class="card-link">
<img [src]="book.coverImage || '/assets/default-cover.jpg'" [alt]="book.title" class="book-cover">
<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>
}
</div>
</a>
<div class="card-actions">
<app-like-button
entityType="book"
[entityId]="book.id"
></app-like-button>
@if (authService.isAdmin()) {
<div class="admin-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>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredBooks()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.page-hero {
text-align: center;
margin-bottom: 2rem;
}
.page-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #8b6f47;
box-shadow: 0 4px 12px rgba(139, 111, 71, 0.3);
transition: all 0.3s;
}
.page-avatar:hover {
transform: scale(1.05);
box-shadow: 0 8px 16px rgba(139, 111, 71, 0.5);
}
.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);
}
.suggest-form {
border: 2px solid #8b6f47;
background: #faf8f5;
}
.suggest-note {
color: #666;
font-size: 0.9rem;
margin-bottom: 1rem;
font-style: italic;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.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;
}
.search-section {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
align-items: center;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
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;
}
.search-input:focus {
outline: none;
border-color: var(--witch-rose);
box-shadow: 0 0 0 3px rgba(168, 87, 126, 0.2);
}
.search-input::placeholder {
color: var(--witch-mauve);
}
.advanced-filters {
background: rgba(255, 255, 255, 0.95);
border: 2px solid var(--witch-lavender);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
backdrop-filter: blur(10px);
}
.filter-group {
margin-bottom: 1rem;
}
.filter-group:last-child {
margin-bottom: 0;
}
.filter-group label {
display: block;
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--witch-purple);
}
.tags-filter {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.tag-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
transition: all 0.2s;
font-weight: 500;
}
.tag-checkbox:hover {
background: var(--witch-lavender);
transform: translateY(-1px);
}
.tag-checkbox input[type="checkbox"] {
cursor: pointer;
}
.tag-checkbox input[type="checkbox"]:checked + .tag-label {
color: #8b6f47;
}
.tag-checkbox:has(input:checked) {
background: var(--witch-lavender);
border-color: #8b6f47;
}
.tag-label {
font-size: 0.9rem;
color: var(--witch-plum);
}
.no-tags {
color: var(--witch-mauve);
font-style: italic;
margin: 0;
}
.clear-tags {
margin-top: 0.5rem;
}
.filter-badge {
background: var(--witch-rose);
color: var(--witch-moon);
padding: 0.1rem 0.4rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.25rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.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: #8b6f47;
color: white;
}
.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);
display: flex;
flex-direction: column;
}
.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);
}
.card-link {
text-decoration: none;
color: inherit;
flex: 1;
display: flex;
flex-direction: column;
}
.card-link:hover h3 {
color: var(--witch-rose);
}
.card-actions {
padding: 1rem;
border-top: 1px solid var(--witch-lavender);
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.admin-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.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;
transition: color 0.2s;
}
.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);
}
.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: #8b6f47;
color: white;
}
.btn-primary:hover {
background: #725a3a;
transform: translateY(-2px);
box-shadow: 0 4px 8px var(--witch-shadow);
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-sm { padding: 0.25rem 0.75rem; font-size: 0.85rem; }
.btn-xs { padding: 0.15rem 0.5rem; font-size: 0.75rem; }
.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);
}
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.5rem;
border: 2px solid var(--witch-lavender);
border-radius: 4px;
background: var(--witch-moon);
}
.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: #8b6f47;
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;
}
.links-list {
margin-bottom: 0.5rem;
}
.link-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--witch-moon);
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;
}
@media (max-width: 768px) {
.search-section {
flex-direction: column;
}
.search-input {
width: 100%;
}
.filters {
justify-content: center;
}
.filter-btn {
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
}
}
`]
})
export class BooksListComponent implements OnInit {
booksService = inject(BooksService);
authService = inject(AuthService);
commentsService = inject(CommentsService);
sanitizeService = inject(SanitizeService);
suggestionService = inject(SuggestionService);
books = signal<Book[]>([]);
loading = signal(true);
showAddForm = signal(false);
editingBook = signal<Book | null>(null);
statusFilter = signal<'all' | BookStatus>('all');
searchQuery = signal('');
selectedTags = signal<string[]>([]);
showFilters = signal(false);
// Pagination state
currentPage = signal(1);
pageSize = signal(25);
// 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);
suggestBookImagePreview = signal<string | null>(null);
imageError = signal<string | null>(null);
private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB
// Suggestion state
showSuggestForm = signal(false);
suggestedBook: { title: string; author: string; isbn?: string; notes?: string; coverImage?: string } = {
title: '',
author: '',
isbn: '',
notes: '',
coverImage: undefined
};
// 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);
retiredCount = computed(() => this.books().filter(book => book.status === BookStatus.retired).length);
// Get all unique tags from all books
allTags = computed(() => {
const tags = new Set<string>();
this.books().forEach(book => {
book.tags?.forEach(tag => tags.add(tag));
});
return Array.from(tags).sort();
});
filteredBooks = computed(() => {
let books = this.books();
// Apply status filter
const statusFilter = this.statusFilter();
if (statusFilter !== 'all') {
books = books.filter(book => book.status === statusFilter);
}
// Apply search filter
const searchQuery = this.searchQuery().toLowerCase().trim();
if (searchQuery) {
books = books.filter(book =>
book.title.toLowerCase().includes(searchQuery) ||
book.author.toLowerCase().includes(searchQuery) ||
book.isbn?.toLowerCase().includes(searchQuery) ||
book.notes?.toLowerCase().includes(searchQuery)
);
}
// Apply tag filter
const selectedTags = this.selectedTags();
if (selectedTags.length > 0) {
books = books.filter(book =>
selectedTags.every(tag => book.tags?.includes(tag))
);
}
return books;
});
paginatedBooks = computed(() => {
const books = this.filteredBooks();
const start = (this.currentPage() - 1) * this.pageSize();
const end = start + this.pageSize();
return books.slice(start, end);
});
totalFilteredBooks = computed(() => this.filteredBooks().length);
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
author: '',
isbn: '',
status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
links: []
};
editBook: Partial<UpdateBookDto> = {};
newBookTimeHours = 0;
newBookTimeMinutes = 0;
editBookTimeHours = 0;
editBookTimeMinutes = 0;
// Tags and links input state
newTagInput = '';
editTagInput = '';
newLinkTitle = '';
newLinkUrl = '';
editLinkTitle = '';
editLinkUrl = '';
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);
this.currentPage.set(1); // Reset to first page when filter changes
}
onSearchChange() {
this.currentPage.set(1); // Reset to first page when search changes
}
toggleFilters() {
this.showFilters.update(show => !show);
}
toggleTag(tag: string) {
this.selectedTags.update(tags => {
const index = tags.indexOf(tag);
if (index === -1) {
return [...tags, tag];
} else {
return tags.filter(t => t !== tag);
}
});
this.currentPage.set(1); // Reset to first page when tags change
}
clearTags() {
this.selectedTags.set([]);
this.currentPage.set(1); // Reset to first page when tags are cleared
}
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: BookStatus): string {
switch (status) {
case BookStatus.reading: return 'Currently Reading';
case BookStatus.finished: return 'Finished';
case BookStatus.toRead: return 'To Read';
case BookStatus.retired: return 'Retired';
}
}
toggleAddForm() {
this.showAddForm.update(v => !v);
if (!this.showAddForm()) {
this.resetForm();
}
}
resetForm() {
this.newBook = {
title: '',
author: '',
isbn: '',
status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
tags: [],
links: []
};
this.newBookImagePreview.set(null);
this.imageError.set(null);
this.newTagInput = '';
this.newLinkTitle = '';
this.newLinkUrl = '';
this.newBookTimeHours = 0;
this.newBookTimeMinutes = 0;
}
addTag(target: 'new' | 'edit') {
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
if (!input) return;
if (target === 'new') {
this.newBook.tags = [...(this.newBook.tags || []), input];
this.newTagInput = '';
} else {
this.editBook.tags = [...(this.editBook.tags || []), input];
this.editTagInput = '';
}
}
removeTag(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newBook.tags = (this.newBook.tags || []).filter((_, i) => i !== index);
} else {
this.editBook.tags = (this.editBook.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.newBook.links = [...(this.newBook.links || []), { title, url }];
this.newLinkTitle = '';
this.newLinkUrl = '';
} else {
this.editBook.links = [...(this.editBook.links || []), { title, url }];
this.editLinkTitle = '';
this.editLinkUrl = '';
}
}
removeLink(index: number, target: 'new' | 'edit') {
if (target === 'new') {
this.newBook.links = (this.newBook.links || []).filter((_, i) => i !== index);
} else {
this.editBook.links = (this.editBook.links || []).filter((_, i) => i !== index);
}
}
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,
dateStarted: this.newBook.dateStarted ? new Date(this.newBook.dateStarted) : undefined,
dateFinished: this.newBook.dateFinished ? new Date(this.newBook.dateFinished) : undefined,
rating: this.newBook.rating,
notes: this.newBook.notes,
coverImage: this.newBook.coverImage,
tags: this.newBook.tags || [],
links: this.newBook.links || []
};
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,
dateStarted: book.dateStarted,
dateFinished: book.dateFinished,
rating: book.rating,
notes: book.notes,
coverImage: book.coverImage,
tags: [...(book.tags || [])],
links: [...(book.links || [])],
series: book.series,
seriesOrder: book.seriesOrder
};
this.editBookImagePreview.set(book.coverImage || null);
this.showAddForm.set(false);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
if (book.timeSpent) {
this.editBookTimeHours = Math.floor(book.timeSpent / 60);
this.editBookTimeMinutes = book.timeSpent % 60;
} else {
this.editBookTimeHours = 0;
this.editBookTimeMinutes = 0;
}
// Scroll to top so the form is visible
window.scrollTo({ top: 0, behavior: 'smooth' });
}
cancelEdit() {
this.editingBook.set(null);
this.editBook = {};
this.editBookImagePreview.set(null);
this.imageError.set(null);
this.editTagInput = '';
this.editLinkTitle = '';
this.editLinkUrl = '';
}
saveEdit() {
const book = this.editingBook();
if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return;
const updateData = {
...this.editBook,
dateStarted: this.editBook.dateStarted ? new Date(this.editBook.dateStarted) : undefined,
dateFinished: this.editBook.dateFinished ? new Date(this.editBook.dateFinished) : undefined,
};
this.booksService.updateBook(book.id, updateData).subscribe(() => {
this.loadBooks();
this.cancelEdit();
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
updateNewBookTimeSpent() {
const totalMinutes = (this.newBookTimeHours * 60) + this.newBookTimeMinutes;
this.newBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
updateEditBookTimeSpent() {
const totalMinutes = (this.editBookTimeHours * 60) + this.editBookTimeMinutes;
this.editBook.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
}
formatTimeSpent(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours === 0) {
return `${mins}m`;
} else if (mins === 0) {
return `${hours}h`;
} else {
return `${hours}h ${mins}m`;
}
}
// Image handling methods
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.newBookImagePreview.set(base64);
this.newBook.coverImage = base64;
} else if (target === 'edit') {
this.editBookImagePreview.set(base64);
this.editBook.coverImage = base64;
} else {
this.suggestBookImagePreview.set(base64);
this.suggestedBook.coverImage = base64;
}
};
reader.readAsDataURL(file);
}
clearImage(target: 'new' | 'edit' | 'suggest') {
if (target === 'new') {
this.newBookImagePreview.set(null);
this.newBook.coverImage = undefined;
} else if (target === 'edit') {
this.editBookImagePreview.set(null);
this.editBook.coverImage = undefined;
} else {
this.suggestBookImagePreview.set(null);
this.suggestedBook.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();
}
});
}
// Suggestion methods
toggleSuggestForm() {
this.showSuggestForm.update(v => !v);
if (!this.showSuggestForm()) {
this.resetSuggestForm();
}
}
resetSuggestForm() {
this.suggestedBook = {
title: '',
author: '',
isbn: '',
notes: '',
coverImage: undefined
};
this.suggestBookImagePreview.set(null);
this.imageError.set(null);
}
async submitSuggestion() {
if (!this.suggestedBook.title || !this.suggestedBook.author) return;
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.book,
title: this.suggestedBook.title,
author: this.suggestedBook.author,
isbn: this.suggestedBook.isbn,
notes: this.suggestedBook.notes,
coverImage: this.suggestedBook.coverImage
});
alert('Thank you for your suggestion! It will be reviewed soon.');
this.toggleSuggestForm();
} catch {
alert('Failed to submit suggestion. Please try again.');
}
}
handleCommentEdit(bookId: string, event: { commentId: string; content: string }) {
this.commentsService.updateCommentOnBook(bookId, event.commentId, event.content).subscribe({
next: (updatedComment) => {
this.comments.set({
...this.comments(),
[bookId]: (this.comments()[bookId] || []).map(c =>
c.id === event.commentId ? updatedComment : c
)
});
}
});
}
getCommentsSignal(bookId: string) {
return signal(this.comments()[bookId] || []);
}
}