feat: multiple improvements to library functionality (#50)
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s

## 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:
2026-02-19 16:52:43 -08:00
committed by Naomi Carrigan
parent 9caf74945a
commit 7579f1ec97
93 changed files with 4297 additions and 645 deletions
@@ -39,22 +39,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }})
</button>
<button
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
(click)="setFilter(SuggestionStatus.unreviewed)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
class="filter-btn pending"
>
Pending ({{ unreviewedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
(click)="setFilter(SuggestionStatus.accepted)"
[class.active]="statusFilter() === SuggestionStatus.accepted"
class="filter-btn accepted"
>
Accepted ({{ acceptedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
(click)="setFilter(SuggestionStatus.declined)"
[class.active]="statusFilter() === SuggestionStatus.declined"
class="filter-btn declined"
>
Declined ({{ declinedCount() }})
@@ -171,7 +171,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
</div>
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
<div class="decline-reason">
<strong>Decline reason:</strong> {{ suggestion.declineReason }}
</div>
@@ -180,7 +180,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
<div class="suggestion-footer">
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
@if (suggestion.status === SuggestionStatus.UNREVIEWED) {
@if (suggestion.status === SuggestionStatus.unreviewed) {
<div class="actions">
<button (click)="acceptSuggestion(suggestion)" class="btn btn-accept">
Accept
@@ -206,8 +206,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
@if (showDeclineModal()) {
<div class="modal-overlay" (click)="closeDeclineModal()">
<div class="modal" (click)="$event.stopPropagation()">
<div class="modal-overlay" (click)="closeDeclineModal()" (keyup.escape)="closeDeclineModal()" tabindex="0" role="button">
<div class="modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<h3>Decline Suggestion</h3>
<p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p>
<div class="form-group">
@@ -229,8 +229,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
@if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()">
<div class="modal edit-modal" (click)="$event.stopPropagation()">
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p>
@@ -248,7 +248,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div>
@switch (editingSuggestion()!.entityType) {
@case ('BOOK') {
@case (SuggestionEntity.book) {
<div class="form-group">
<label for="edit-author">Author</label>
<input
@@ -269,7 +269,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case ('GAME') {
@case (SuggestionEntity.game) {
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
@@ -280,7 +280,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case ('MUSIC') {
@case (SuggestionEntity.music) {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
@@ -300,7 +300,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select>
</div>
}
@case ('ART') {
@case (SuggestionEntity.art) {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
@@ -331,7 +331,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case ('SHOW') {
@case (SuggestionEntity.show) {
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
@@ -342,7 +342,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select>
</div>
}
@case ('MANGA') {
@case (SuggestionEntity.manga) {
<div class="form-group">
<label for="edit-author">Author</label>
<input
@@ -366,7 +366,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
></textarea>
</div>
@if (editingSuggestion()!.entityType !== 'ART') {
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
<div class="form-group">
<label for="edit-coverImage">Cover Image URL</label>
<input
@@ -727,10 +727,11 @@ export class AdminSuggestionsComponent implements OnInit {
pageSize = signal(25);
SuggestionStatus = SuggestionStatus;
SuggestionEntity = SuggestionEntity;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
@@ -788,20 +789,20 @@ export class AdminSuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string {
switch (status) {
case SuggestionStatus.UNREVIEWED: return 'Pending';
case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.DECLINED: return 'Declined';
case SuggestionStatus.unreviewed: return 'Pending';
case SuggestionStatus.accepted: return 'Accepted';
case SuggestionStatus.declined: return 'Declined';
}
}
getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) {
case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.MANGA: return '📖';
case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.ART: return '🎨';
case SuggestionEntity.game: return '🎮';
case SuggestionEntity.book: return '📚';
case SuggestionEntity.music: return '🎵';
case SuggestionEntity.manga: return '📖';
case SuggestionEntity.show: return '📺';
case SuggestionEntity.art: return '🎨';
}
}
@@ -822,39 +823,39 @@ export class AdminSuggestionsComponent implements OnInit {
// Add entity-specific data
switch (suggestion.entityType) {
case 'BOOK':
case SuggestionEntity.book:
const bookData = suggestion.bookData as any;
this.editedData.author = bookData?.author || '';
this.editedData.isbn = bookData?.isbn || '';
this.editedData.notes = bookData?.notes || '';
this.editedData.coverImage = bookData?.coverImage || '';
break;
case 'GAME':
case SuggestionEntity.game:
const gameData = suggestion.gameData as any;
this.editedData.platform = gameData?.platform || '';
this.editedData.notes = gameData?.notes || '';
this.editedData.coverImage = gameData?.coverImage || '';
break;
case 'MUSIC':
case SuggestionEntity.music:
const musicData = suggestion.musicData as any;
this.editedData.artist = musicData?.artist || '';
this.editedData.type = musicData?.type || 'ALBUM';
this.editedData.notes = musicData?.notes || '';
this.editedData.coverArt = musicData?.coverArt || '';
break;
case 'ART':
case SuggestionEntity.art:
const artData = suggestion.artData as any;
this.editedData.artist = artData?.artist || '';
this.editedData.description = artData?.description || '';
this.editedData.imageUrl = artData?.imageUrl || '';
break;
case 'SHOW':
case SuggestionEntity.show:
const showData = suggestion.showData as any;
this.editedData.type = showData?.type || 'TV_SERIES';
this.editedData.notes = showData?.notes || '';
this.editedData.coverImage = showData?.coverImage || '';
break;
case 'MANGA':
case SuggestionEntity.manga:
const mangaData = suggestion.mangaData as any;
this.editedData.author = mangaData?.author || '';
this.editedData.notes = mangaData?.notes || '';
@@ -105,8 +105,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -123,8 +122,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</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 newArt.links; track link.url; let i = $index) {
<div class="link-item">
@@ -280,8 +278,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -298,8 +295,7 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</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 editArt.links; track link.url; let i = $index) {
<div class="link-item">
@@ -401,6 +397,10 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
[alt]="art.description || art.title"
class="art-image"
(click)="openLightbox(art)"
(keyup.enter)="openLightbox(art)"
(keyup.space)="openLightbox(art)"
tabindex="0"
role="button"
>
</div>
@@ -536,8 +536,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
@if (lightboxArt()) {
<div class="lightbox" (click)="closeLightbox()">
<div class="lightbox-content" (click)="$event.stopPropagation()">
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button">
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<button class="lightbox-close" (click)="closeLightbox()">&times;</button>
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
<div class="lightbox-info">
@@ -1571,7 +1571,7 @@ export class ArtGalleryComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.ART,
entityType: SuggestionEntity.art,
title: this.suggestedArt.title,
artist: this.suggestedArt.artist,
imageUrl: this.suggestedArt.imageUrl,
@@ -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,
@@ -67,9 +67,30 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -114,8 +135,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, 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 newGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -132,8 +152,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, 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 newGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -198,9 +217,30 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -245,8 +285,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, 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 editGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -263,8 +302,7 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, 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 editGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -436,6 +474,13 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
>
Backlog ({{ backlogCount() }})
</button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -504,6 +549,26 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
}
@if (game.dateStarted) {
<p class="date-started">
Started: {{ formatDate(game.dateStarted) }}
</p>
}
@if (game.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(game.dateFinished) }}
</p>
}
<p class="date-added">
Added: {{ formatDate(game.createdAt) }}
</p>
<p class="date-updated">
Updated: {{ formatDate(game.updatedAt) }}
</p>
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
@@ -858,6 +923,15 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1213,6 +1287,7 @@ export class GamesListComponent implements OnInit {
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1261,10 +1336,12 @@ export class GamesListComponent implements OnInit {
totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> = {
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1341,6 +1418,7 @@ export class GamesListComponent implements OnInit {
case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog';
case GameStatus.retired: return 'Retired';
}
}
@@ -1356,6 +1434,8 @@ export class GamesListComponent implements OnInit {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1424,6 +1504,8 @@ export class GamesListComponent implements OnInit {
rating: this.newGame.rating,
notes: this.newGame.notes,
coverImage: this.newGame.coverImage,
dateStarted: this.newGame.dateStarted ? new Date(this.newGame.dateStarted) : undefined,
dateFinished: this.newGame.dateFinished ? new Date(this.newGame.dateFinished) : undefined,
tags: this.newGame.tags || [],
links: this.newGame.links || []
};
@@ -1448,6 +1530,8 @@ export class GamesListComponent implements OnInit {
title: game.title,
platform: game.platform,
status: game.status,
dateStarted: game.dateStarted,
dateFinished: game.dateFinished,
rating: game.rating,
notes: game.notes,
coverImage: game.coverImage,
@@ -1476,7 +1560,13 @@ export class GamesListComponent implements OnInit {
const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return;
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
const updateData: UpdateGameDto = {
...this.editGame,
dateStarted: this.editGame.dateStarted ? new Date(this.editGame.dateStarted) : undefined,
dateFinished: this.editGame.dateFinished ? new Date(this.editGame.dateFinished) : undefined
};
this.gamesService.updateGame(game.id, updateData).subscribe(() => {
this.loadGames();
this.cancelEdit();
});
@@ -1673,7 +1763,7 @@ export class GamesListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.GAME,
entityType: SuggestionEntity.game,
title: this.suggestedGame.title,
platform: this.suggestedGame.platform,
notes: this.suggestedGame.notes,
@@ -35,17 +35,34 @@ import { ApiService } from '../../services/api.service';
<div class="auth-section">
@if (authService.user(); as user) {
<span class="welcome">Welcome, {{ user.username }}!</span>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
}
<a routerLink="/my-likes" class="user-link">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="admin-badge">Users</a>
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
<a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
}
<button (click)="logout()" class="btn btn-secondary">Logout</button>
<div class="user-menu">
@if (user.avatar) {
<img
[src]="user.avatar"
[alt]="user.username"
class="user-avatar"
(click)="toggleDropdown()"
(keyup.enter)="toggleDropdown()"
(keyup.space)="toggleDropdown()"
tabindex="0"
role="button"
/>
}
@if (showDropdown()) {
<div class="dropdown-menu">
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
}
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a>
}
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
</div>
}
</div>
} @else {
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
}
@@ -122,6 +139,75 @@ import { ApiService } from '../../services/api.service';
color: var(--witch-lavender);
}
.user-menu {
position: relative;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--witch-lavender);
transition: all 0.3s;
cursor: pointer;
}
.user-avatar:hover {
border-color: var(--witch-moon);
transform: scale(1.1);
}
.dropdown-menu {
position: absolute;
top: 50px;
right: 0;
background-color: var(--witch-purple);
border: 2px solid var(--witch-lavender);
border-radius: 8px;
padding: 0.5rem 0;
min-width: 180px;
box-shadow: 0 4px 12px var(--witch-shadow);
z-index: 1000;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.75rem 1rem;
color: var(--witch-lavender);
text-decoration: none;
background: none;
border: none;
text-align: left;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.logout-btn {
border-top: 1px solid var(--witch-lavender);
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 500;
}
.admin-badge {
background-color: var(--witch-rose);
color: var(--witch-moon);
@@ -190,6 +276,7 @@ export class HeaderComponent implements OnInit {
authService = inject(AuthService);
private apiService = inject(ApiService);
version = signal<string | null>(null);
showDropdown = signal<boolean>(false);
ngOnInit() {
this.apiService.get<{ version: string }>('/version').subscribe({
@@ -198,11 +285,20 @@ export class HeaderComponent implements OnInit {
});
}
toggleDropdown() {
this.showDropdown.update(v => !v);
}
closeDropdown() {
this.showDropdown.set(false);
}
login() {
this.authService.login();
}
logout() {
this.closeDropdown();
this.authService.logout().subscribe();
}
}
@@ -68,9 +68,30 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -115,8 +136,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -133,8 +153,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</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 newManga.links; track link.url; let i = $index) {
<div class="link-item">
@@ -200,9 +219,30 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -247,8 +287,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -265,8 +304,7 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</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 editManga.links; track link.url; let i = $index) {
<div class="link-item">
@@ -439,6 +477,13 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
>
Want to Read ({{ wantToReadCount() }})
</button>
<button
(click)="setFilter(MangaStatus.retired)"
[class.active]="statusFilter() === MangaStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -505,6 +550,30 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
}
@if (manga.dateStarted) {
<p class="date-started">
Started: {{ formatDate(manga.dateStarted) }}
</p>
}
@if (manga.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(manga.dateFinished) }}
</p>
}
@if (manga.createdAt) {
<p class="date-added">
Added: {{ formatDate(manga.createdAt) }}
</p>
}
@if (manga.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(manga.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
@@ -861,6 +930,15 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1216,6 +1294,7 @@ export class MangaListComponent implements OnInit {
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length);
wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length);
retiredCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1264,10 +1343,12 @@ export class MangaListComponent implements OnInit {
totalFilteredManga = computed(() => this.filteredManga().length);
newManga: Partial<CreateMangaDto> = {
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1344,6 +1425,7 @@ export class MangaListComponent implements OnInit {
case MangaStatus.reading: return 'Currently Reading';
case MangaStatus.completed: return 'Completed';
case MangaStatus.wantToRead: return 'Want to Read';
case MangaStatus.retired: return 'Retired';
}
}
@@ -1359,6 +1441,8 @@ export class MangaListComponent implements OnInit {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1424,6 +1508,8 @@ export class MangaListComponent implements OnInit {
title: this.newManga.title,
author: this.newManga.author,
status: this.newManga.status,
dateStarted: this.newManga.dateStarted ? new Date(this.newManga.dateStarted) : undefined,
dateFinished: this.newManga.dateFinished ? new Date(this.newManga.dateFinished) : undefined,
rating: this.newManga.rating,
notes: this.newManga.notes,
coverImage: this.newManga.coverImage,
@@ -1451,6 +1537,8 @@ export class MangaListComponent implements OnInit {
title: manga.title,
author: manga.author,
status: manga.status,
dateStarted: manga.dateStarted,
dateFinished: manga.dateFinished,
rating: manga.rating,
notes: manga.notes,
coverImage: manga.coverImage,
@@ -1479,7 +1567,13 @@ export class MangaListComponent implements OnInit {
const manga = this.editingManga();
if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return;
this.mangaService.updateManga(manga.id, this.editManga).subscribe(() => {
const updateData = {
...this.editManga,
dateStarted: this.editManga.dateStarted ? new Date(this.editManga.dateStarted) : undefined,
dateFinished: this.editManga.dateFinished ? new Date(this.editManga.dateFinished) : undefined,
};
this.mangaService.updateManga(manga.id, updateData).subscribe(() => {
this.loadManga();
this.cancelEdit();
});
@@ -1674,7 +1768,7 @@ export class MangaListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.MANGA,
entityType: SuggestionEntity.manga,
title: this.suggestedManga.title,
author: this.suggestedManga.author,
notes: this.suggestedManga.notes,
@@ -77,9 +77,30 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newMusic.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newMusic.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -124,8 +145,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newMusic.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -142,8 +162,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</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 newMusic.links; track link.url; let i = $index) {
<div class="link-item">
@@ -218,9 +237,30 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editMusicData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editMusicData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -265,8 +305,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editMusicData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -283,8 +322,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</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 editMusicData.links; track link.url; let i = $index) {
<div class="link-item">
@@ -500,6 +538,13 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
>
Want to Listen ({{ wantToListenCount() }})
</button>
<button
(click)="setStatusFilter(MusicStatus.retired)"
[class.active]="statusFilter() === MusicStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
</div>
@@ -581,9 +626,27 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
}
@if (music.dateCompleted) {
<p class="date-completed">
Completed: {{ formatDate(music.dateCompleted) }}
@if (music.dateStarted) {
<p class="date-started">
Started: {{ formatDate(music.dateStarted) }}
</p>
}
@if (music.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(music.dateFinished) }}
</p>
}
@if (music.createdAt) {
<p class="date-added">
Added: {{ formatDate(music.createdAt) }}
</p>
}
@if (music.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(music.updatedAt) }}
</p>
}
@@ -1017,7 +1080,10 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
margin: 0.5rem 0;
}
.date-completed {
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: var(--witch-plum);
margin-top: 0.5rem;
@@ -1435,6 +1501,7 @@ export class MusicListComponent implements OnInit {
listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length);
completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length);
wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length);
retiredCount = computed(() => this.music().filter(m => m.status === MusicStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1488,11 +1555,13 @@ export class MusicListComponent implements OnInit {
totalFilteredMusic = computed(() => this.filteredMusic().length);
newMusic: Partial<CreateMusicDto> = {
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
artist: '',
type: MusicType.album,
status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1583,6 +1652,7 @@ export class MusicListComponent implements OnInit {
case MusicStatus.listening: return 'Currently Listening';
case MusicStatus.completed: return 'Completed';
case MusicStatus.wantToListen: return 'Want to Listen';
case MusicStatus.retired: return 'Retired';
}
}
@@ -1599,6 +1669,8 @@ export class MusicListComponent implements OnInit {
artist: '',
type: MusicType.album,
status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverArt: undefined,
@@ -1665,6 +1737,8 @@ export class MusicListComponent implements OnInit {
artist: this.newMusic.artist,
type: this.newMusic.type,
status: this.newMusic.status,
dateStarted: this.newMusic.dateStarted ? new Date(this.newMusic.dateStarted) : undefined,
dateFinished: this.newMusic.dateFinished ? new Date(this.newMusic.dateFinished) : undefined,
rating: this.newMusic.rating,
notes: this.newMusic.notes,
coverArt: this.newMusic.coverArt,
@@ -1693,6 +1767,8 @@ export class MusicListComponent implements OnInit {
artist: music.artist,
type: music.type,
status: music.status,
dateStarted: music.dateStarted,
dateFinished: music.dateFinished,
rating: music.rating,
notes: music.notes,
coverArt: music.coverArt,
@@ -1721,7 +1797,13 @@ export class MusicListComponent implements OnInit {
const music = this.editingMusic();
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
const updateData = {
...this.editMusicData,
dateStarted: this.editMusicData.dateStarted ? new Date(this.editMusicData.dateStarted) : undefined,
dateFinished: this.editMusicData.dateFinished ? new Date(this.editMusicData.dateFinished) : undefined,
};
this.musicService.updateMusic(music.id, updateData).subscribe(() => {
this.loadMusic();
this.cancelEdit();
});
@@ -1919,7 +2001,7 @@ export class MusicListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.MUSIC,
entityType: SuggestionEntity.music,
title: this.suggestedMusic.title,
artist: this.suggestedMusic.artist,
type: this.suggestedMusic.type,
@@ -373,12 +373,12 @@ export class MyLikesComponent implements OnInit {
}
getItemTitle(likedItem: LikedItemDto): string {
const item = likedItem.item;
const item = likedItem.item as any;
return item.title || item.name || 'Untitled';
}
getItemSubtitle(likedItem: LikedItemDto): string {
const item = likedItem.item;
const item = likedItem.item as any;
const type = likedItem.like.entityType;
switch (type) {
@@ -400,7 +400,7 @@ export class MyLikesComponent implements OnInit {
}
getItemImage(likedItem: LikedItemDto): string | null {
const item = likedItem.item;
const item = likedItem.item as any;
return item.coverImage || item.imageUrl || null;
}
@@ -43,22 +43,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }})
</button>
<button
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
(click)="setFilter(SuggestionStatus.unreviewed)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
class="filter-btn pending"
>
Pending ({{ unreviewedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
(click)="setFilter(SuggestionStatus.accepted)"
[class.active]="statusFilter() === SuggestionStatus.accepted"
class="filter-btn accepted"
>
Accepted ({{ acceptedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
(click)="setFilter(SuggestionStatus.declined)"
[class.active]="statusFilter() === SuggestionStatus.declined"
class="filter-btn declined"
>
Declined ({{ declinedCount() }})
@@ -120,7 +120,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
</div>
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
<div class="decline-reason">
<strong>Reason:</strong> {{ suggestion.declineReason }}
</div>
@@ -337,9 +337,9 @@ export class MySuggestionsComponent implements OnInit {
SuggestionStatus = SuggestionStatus;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
@@ -397,20 +397,20 @@ export class MySuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string {
switch (status) {
case SuggestionStatus.UNREVIEWED: return 'Pending Review';
case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.DECLINED: return 'Declined';
case SuggestionStatus.unreviewed: return 'Pending Review';
case SuggestionStatus.accepted: return 'Accepted';
case SuggestionStatus.declined: return 'Declined';
}
}
getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) {
case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.MANGA: return '📖';
case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.ART: return '🎨';
case SuggestionEntity.game: return '🎮';
case SuggestionEntity.book: return '📚';
case SuggestionEntity.music: return '🎵';
case SuggestionEntity.manga: return '📖';
case SuggestionEntity.show: return '📺';
case SuggestionEntity.art: return '🎨';
}
}
@@ -66,9 +66,30 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -113,8 +134,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of newShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -131,8 +151,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</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 newShow.links; track link.url; let i = $index) {
<div class="link-item">
@@ -196,9 +215,30 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -243,8 +283,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
<div class="form-group">
<label>Tags</label>
<div class="tags-input-container">
<div class="tags-input-container" aria-label="Tags">
@for (tag of editShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -261,8 +300,7 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</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 editShow.links; track link.url; let i = $index) {
<div class="link-item">
@@ -433,6 +471,13 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
>
Want to Watch ({{ wantToWatchCount() }})
</button>
<button
(click)="setFilter(ShowStatus.retired)"
[class.active]="statusFilter() === ShowStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -499,6 +544,30 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
}
@if (show.dateStarted) {
<p class="date-started">
Started: {{ formatDate(show.dateStarted) }}
</p>
}
@if (show.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(show.dateFinished) }}
</p>
}
@if (show.createdAt) {
<p class="date-added">
Added: {{ formatDate(show.createdAt) }}
</p>
}
@if (show.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(show.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
@@ -854,6 +923,15 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1210,6 +1288,7 @@ export class ShowsListComponent implements OnInit {
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length);
wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length);
retiredCount = computed(() => this.shows().filter(show => show.status === ShowStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1258,12 +1337,14 @@ export class ShowsListComponent implements OnInit {
totalFilteredShows = computed(() => this.filteredShows().length);
newShow: Partial<CreateShowDto> = {
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = {
title: '',
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
rating: undefined,
notes: '',
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
@@ -1338,6 +1419,7 @@ export class ShowsListComponent implements OnInit {
case ShowStatus.watching: return 'Currently Watching';
case ShowStatus.completed: return 'Completed';
case ShowStatus.wantToWatch: return 'Want to Watch';
case ShowStatus.retired: return 'Retired';
}
}
@@ -1365,6 +1447,8 @@ export class ShowsListComponent implements OnInit {
rating: undefined,
notes: '',
coverImage: undefined,
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
@@ -1427,6 +1511,8 @@ export class ShowsListComponent implements OnInit {
title: this.newShow.title,
type: this.newShow.type,
status: this.newShow.status,
dateStarted: this.newShow.dateStarted ? new Date(this.newShow.dateStarted) : undefined,
dateFinished: this.newShow.dateFinished ? new Date(this.newShow.dateFinished) : undefined,
rating: this.newShow.rating,
notes: this.newShow.notes,
coverImage: this.newShow.coverImage,
@@ -1454,6 +1540,8 @@ export class ShowsListComponent implements OnInit {
title: show.title,
type: show.type,
status: show.status,
dateStarted: show.dateStarted,
dateFinished: show.dateFinished,
rating: show.rating,
notes: show.notes,
coverImage: show.coverImage,
@@ -1482,7 +1570,13 @@ export class ShowsListComponent implements OnInit {
const show = this.editingShow();
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
const updateData = {
...this.editShow,
dateStarted: this.editShow.dateStarted ? new Date(this.editShow.dateStarted) : undefined,
dateFinished: this.editShow.dateFinished ? new Date(this.editShow.dateFinished) : undefined,
};
this.showsService.updateShow(show.id, updateData).subscribe(() => {
this.loadShows();
this.cancelEdit();
});
@@ -1677,7 +1771,7 @@ export class ShowsListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.SHOW,
entityType: SuggestionEntity.show,
title: this.suggestedShow.title,
type: this.suggestedShow.type,
notes: this.suggestedShow.notes,
@@ -0,0 +1,91 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
animation: slideIn 0.3s ease-out;
border: 2px solid;
background-color: var(--witch-purple);
color: var(--moon-white);
}
.toast-error {
border-color: #ff4444;
background-color: rgba(255, 68, 68, 0.4);
}
.toast-success {
border-color: #44ff88;
background-color: rgba(68, 255, 136, 0.4);
}
.toast-info {
border-color: var(--witch-lavender);
background-color: rgba(200, 162, 200, 0.4);
}
.toast-warning {
border-color: #ffaa44;
background-color: rgba(255, 170, 68, 0.4);
}
.toast-icon {
font-size: 20px;
flex-shrink: 0;
}
.toast-message {
flex: 1;
word-wrap: break-word;
}
.toast-close {
background: none;
border: none;
color: var(--moon-white);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.toast-close:hover {
transform: scale(1.2);
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@@ -0,0 +1,29 @@
<!--
@copyright 2026 NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
-->
<div class="toast-container">
@for (toast of toastService.toastList(); track toast.id) {
<div
class="toast toast-{{ toast.type }}"
(click)="toastService.remove(toast.id)"
(keyup.enter)="toastService.remove(toast.id)"
(keyup.space)="toastService.remove(toast.id)"
tabindex="0"
role="button"
>
<div class="toast-icon">
@switch (toast.type) {
@case ('error') { ❌ }
@case ('success') { ✅ }
@case ('info') { ️ }
@case ('warning') { ⚠️ }
}
</div>
<div class="toast-message">{{ toast.message }}</div>
<button class="toast-close" (click)="toastService.remove(toast.id)">×</button>
</div>
}
</div>
@@ -0,0 +1,18 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject } from '@angular/core';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
templateUrl: './toast.component.html',
styleUrls: ['./toast.component.css']
})
export class ToastComponent {
public toastService = inject(ToastService);
}