generated from nhcarrigan/template
feat: multiple improvements to library functionality (#50)
## Summary This PR implements several improvements to the library application: - Added start and finish date tracking for media items - Added "Retired" category for abandoned media - Implemented avatar-based user menu with dropdown navigation - Added automatic background token refresh to prevent session expiry - Created centralised logging system with frontend-to-API log forwarding - Added toast notifications for error handling ## Changes ### Media Tracking (#41) - Added `dateStarted` and `dateFinished` fields to Books, Games, Manga, Music, and Shows - Updated TypeScript types, Prisma schema, and API services - Added manual date input fields to frontend forms - Properly converts HTML date strings to Date objects before API submission ### Retired Category (#43) - Added `RETIRED` status to all media type enums - Updated Prisma schema, frontend dropdowns, and filter buttons - Added status label handling for retired items ### User Menu (#46) - Replaced username text with avatar image in header - Created dropdown menu with navigation items (Users, Audit, Suggestions) - Added logout button to menu - Implemented keyboard accessibility (tabindex, role, keyup handlers) ### Token Refresh (#44) - Implemented automatic token refresh every 13 minutes in background - Added proactive refresh to prevent token expiry during form filling - Prevents users from losing form data due to expired sessions ### Centralised Logging (#1) - Created `/log` endpoint on API to receive frontend logs - Replaced API console.log calls with @nhcarrigan/logger - Created ConsoleLoggerService to intercept all console methods on frontend - Added global error handlers (window.error, unhandledrejection) on frontend - Added process error handlers (uncaughtException, unhandledRejection, SIGTERM, SIGINT) on API - All frontend console activity now forwarded to centralised logging ### Error Handling - Created ToastService and ToastComponent for displaying errors - Integrated with GlobalErrorHandler and HTTP interceptor - Added accessibility features (keyboard navigation, ARIA attributes) - Set toast opacity to 40% for optimal readability ### Testing & Build - Fixed pre-existing test failure for GET / route (now returns version info) - Added ESM module mocking (jsdom, marked, dompurify, @nhcarrigan/logger) - Configured Jest with isolatedModules to handle TypeScript errors - Excluded test-setup.ts from production build - All tests passing (123 total) - Build passing with no errors ## Test Plan - [x] All tests pass (123 tests) - [x] Build passes without errors - [x] Lint passes (only pre-existing warnings) - [x] Date fields work correctly on all media types - [x] Retired status displays and filters properly - [x] Avatar menu opens/closes correctly with keyboard and mouse - [x] Token refresh prevents session expiry - [x] Toast notifications appear for errors - [x] Frontend logs forward to API successfully - [x] Root route returns version information Closes #41 Closes #43 Closes #44 Closes #46 Closes #1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #50 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #50.
This commit is contained in:
@@ -79,9 +79,30 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<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
|
||||
@@ -126,8 +147,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of newBook.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -144,8 +164,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<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">
|
||||
@@ -222,9 +241,30 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
<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
|
||||
@@ -269,8 +309,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Tags</label>
|
||||
<div class="tags-input-container">
|
||||
<div class="tags-input-container" aria-label="Tags">
|
||||
@for (tag of editBook.tags; track tag; let i = $index) {
|
||||
<span class="tag">
|
||||
{{ tag }}
|
||||
@@ -287,8 +326,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>External Links</label>
|
||||
<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">
|
||||
@@ -421,8 +459,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
@if (showFilters()) {
|
||||
<div class="advanced-filters">
|
||||
<div class="filter-group">
|
||||
<label>Filter by Tags:</label>
|
||||
<div class="tags-filter">
|
||||
<div class="tags-filter" aria-label="Filter by Tags">
|
||||
@for (tag of allTags(); track tag) {
|
||||
<label class="tag-checkbox">
|
||||
<input
|
||||
@@ -475,6 +512,13 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
>
|
||||
To Read ({{ toReadCount() }})
|
||||
</button>
|
||||
<button
|
||||
(click)="setFilter(BookStatus.retired)"
|
||||
[class.active]="statusFilter() === BookStatus.retired"
|
||||
class="filter-btn"
|
||||
>
|
||||
Retired ({{ retiredCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -548,12 +592,30 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (book.dateStarted) {
|
||||
<p class="date-started">
|
||||
Started: {{ formatDate(book.dateStarted) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (book.dateFinished) {
|
||||
<p class="date-finished">
|
||||
Finished: {{ formatDate(book.dateFinished) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (book.createdAt) {
|
||||
<p class="date-added">
|
||||
Added: {{ formatDate(book.createdAt) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (book.updatedAt) {
|
||||
<p class="date-updated">
|
||||
Updated: {{ formatDate(book.updatedAt) }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (authService.isAdmin()) {
|
||||
<div class="actions">
|
||||
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
|
||||
@@ -989,7 +1051,10 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.date-finished {
|
||||
.date-started,
|
||||
.date-finished,
|
||||
.date-added,
|
||||
.date-updated {
|
||||
font-size: 0.85rem;
|
||||
color: var(--witch-plum);
|
||||
margin-top: 0.5rem;
|
||||
@@ -1417,6 +1482,7 @@ export class BooksListComponent implements OnInit {
|
||||
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(() => {
|
||||
@@ -1467,11 +1533,13 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
totalFilteredBooks = computed(() => this.filteredBooks().length);
|
||||
|
||||
newBook: Partial<CreateBookDto> = {
|
||||
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||||
title: '',
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
tags: [],
|
||||
@@ -1552,6 +1620,7 @@ export class BooksListComponent implements OnInit {
|
||||
case BookStatus.reading: return 'Currently Reading';
|
||||
case BookStatus.finished: return 'Finished';
|
||||
case BookStatus.toRead: return 'To Read';
|
||||
case BookStatus.retired: return 'Retired';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1568,6 +1637,8 @@ export class BooksListComponent implements OnInit {
|
||||
author: '',
|
||||
isbn: '',
|
||||
status: BookStatus.toRead,
|
||||
dateStarted: undefined,
|
||||
dateFinished: undefined,
|
||||
rating: undefined,
|
||||
notes: '',
|
||||
coverImage: undefined,
|
||||
@@ -1634,6 +1705,8 @@ export class BooksListComponent implements OnInit {
|
||||
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,
|
||||
@@ -1662,6 +1735,8 @@ export class BooksListComponent implements OnInit {
|
||||
author: book.author,
|
||||
isbn: book.isbn,
|
||||
status: book.status,
|
||||
dateStarted: book.dateStarted,
|
||||
dateFinished: book.dateFinished,
|
||||
rating: book.rating,
|
||||
notes: book.notes,
|
||||
coverImage: book.coverImage,
|
||||
@@ -1690,7 +1765,13 @@ export class BooksListComponent implements OnInit {
|
||||
const book = this.editingBook();
|
||||
if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return;
|
||||
|
||||
this.booksService.updateBook(book.id, this.editBook).subscribe(() => {
|
||||
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();
|
||||
});
|
||||
@@ -1888,7 +1969,7 @@ export class BooksListComponent implements OnInit {
|
||||
|
||||
try {
|
||||
await this.suggestionService.createSuggestion({
|
||||
entityType: SuggestionEntity.BOOK,
|
||||
entityType: SuggestionEntity.book,
|
||||
title: this.suggestedBook.title,
|
||||
author: this.suggestedBook.author,
|
||||
isbn: this.suggestedBook.isbn,
|
||||
|
||||
Reference in New Issue
Block a user