Files
library/apps/frontend/src/app/components/my-likes/my-likes.component.ts
T
naomi 7579f1ec97
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s
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>
2026-02-19 16:52:43 -08:00

460 lines
12 KiB
TypeScript

/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { LikesService } from '../../services/likes.service';
import { AuthService } from '../../services/auth.service';
import { PaginationComponent } from '../shared/pagination.component';
import { LikeButtonComponent } from '../shared/like-button.component';
import { LikedItemDto, Like } from '@library/shared-types';
@Component({
selector: 'app-my-likes',
standalone: true,
imports: [CommonModule, RouterLink, PaginationComponent, LikeButtonComponent],
template: `
<div class="container">
<div class="header-section">
<h2>My Likes</h2>
<p class="subtitle">All the items you've liked across the library</p>
</div>
@if (!authService.isAuthenticated()) {
<div class="not-authenticated">
<p>Please log in to view your liked items.</p>
</div>
} @else if (loading()) {
<div class="loading">Loading your liked items...</div>
} @else if (likedItems().length === 0) {
<div class="empty-state">
<p>You haven't liked any items yet!</p>
<p class="hint">Explore the library and click the heart button on items you enjoy.</p>
</div>
} @else {
<div class="filters">
<button
(click)="setFilter('all')"
[class.active]="typeFilter() === 'all'"
class="filter-btn"
>
All ({{ likedItems().length }})
</button>
<button
(click)="setFilter('book')"
[class.active]="typeFilter() === 'book'"
class="filter-btn"
>
Books ({{ bookCount() }})
</button>
<button
(click)="setFilter('game')"
[class.active]="typeFilter() === 'game'"
class="filter-btn"
>
Games ({{ gameCount() }})
</button>
<button
(click)="setFilter('show')"
[class.active]="typeFilter() === 'show'"
class="filter-btn"
>
Shows ({{ showCount() }})
</button>
<button
(click)="setFilter('manga')"
[class.active]="typeFilter() === 'manga'"
class="filter-btn"
>
Manga ({{ mangaCount() }})
</button>
<button
(click)="setFilter('music')"
[class.active]="typeFilter() === 'music'"
class="filter-btn"
>
Music ({{ musicCount() }})
</button>
<button
(click)="setFilter('art')"
[class.active]="typeFilter() === 'art'"
class="filter-btn"
>
Art ({{ artCount() }})
</button>
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredItems()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
<div class="liked-items-grid">
@for (likedItem of paginatedItems(); track likedItem.like.id) {
<div class="liked-item-card">
<div class="item-type-badge">{{ getTypeBadgeLabel(likedItem.like.entityType) }}</div>
@if (getItemImage(likedItem)) {
<img
[src]="getItemImage(likedItem)"
[alt]="getItemTitle(likedItem)"
class="item-image"
>
} @else {
<div class="item-image placeholder">{{ getTypePlaceholderEmoji(likedItem.like.entityType) }}</div>
}
<div class="item-info">
<h3>{{ getItemTitle(likedItem) }}</h3>
<p class="item-subtitle">{{ getItemSubtitle(likedItem) }}</p>
<p class="liked-date">Liked: {{ formatDate(likedItem.like.createdAt) }}</p>
<app-like-button
[entityType]="likedItem.like.entityType"
[entityId]="likedItem.like.entityId"
></app-like-button>
<a
[routerLink]="getItemLink(likedItem)"
class="view-link"
>
View Details →
</a>
</div>
</div>
}
</div>
<app-pagination
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[totalItems]="totalFilteredItems()"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
></app-pagination>
}
</div>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header-section {
margin-bottom: 2rem;
}
.header-section h2 {
color: var(--witch-purple);
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--witch-plum);
font-size: 1.1rem;
margin: 0;
}
.not-authenticated, .loading, .empty-state {
background: var(--witch-lavender);
padding: 3rem;
text-align: center;
border-radius: 12px;
margin: 2rem 0;
}
.empty-state .hint {
color: var(--witch-plum);
margin-top: 1rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--witch-plum);
}
.filter-btn {
padding: 0.5rem 1rem;
border: 2px solid var(--witch-plum);
background: transparent;
color: var(--witch-purple);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.filter-btn:hover {
background: var(--witch-lavender);
}
.filter-btn.active {
background: var(--witch-rose);
color: white;
border-color: var(--witch-rose);
}
.liked-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
margin: 2rem 0;
}
.liked-item-card {
background: white;
border: 2px solid var(--witch-plum);
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
position: relative;
}
.liked-item-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px var(--witch-shadow);
}
.item-type-badge {
position: absolute;
top: 10px;
right: 10px;
background: var(--witch-rose);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
text-transform: capitalize;
z-index: 1;
}
.item-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.item-image.placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--witch-lavender);
font-size: 4rem;
}
.item-info {
padding: 1.5rem;
}
.item-info h3 {
color: var(--witch-purple);
margin-bottom: 0.5rem;
font-size: 1.3rem;
}
.item-subtitle {
color: var(--witch-plum);
margin-bottom: 0.5rem;
}
.liked-date {
color: var(--witch-silver);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.view-link {
display: inline-block;
margin-top: 1rem;
color: var(--witch-rose);
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
}
.view-link:hover {
color: var(--witch-plum);
transform: translateX(4px);
}
@media (max-width: 768px) {
.liked-items-grid {
grid-template-columns: 1fr;
}
}
`]
})
export class MyLikesComponent implements OnInit {
private likesService = inject(LikesService);
authService = inject(AuthService);
loading = signal(false);
likedItems = signal<LikedItemDto[]>([]);
typeFilter = signal<'all' | Like['entityType']>('all');
currentPage = signal(1);
pageSize = signal(12);
// Computed signals for filtering and pagination
filteredItems = computed(() => {
const filter = this.typeFilter();
const items = this.likedItems();
if (filter === 'all') {
return items;
}
return items.filter(item => item.like.entityType === filter);
});
totalFilteredItems = computed(() => this.filteredItems().length);
paginatedItems = computed(() => {
const items = this.filteredItems();
const page = this.currentPage();
const size = this.pageSize();
const start = (page - 1) * size;
const end = start + size;
return items.slice(start, end);
});
// Computed counts for each type
bookCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'book').length);
gameCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'game').length);
showCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'show').length);
mangaCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'manga').length);
musicCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'music').length);
artCount = computed(() => this.likedItems().filter(item => item.like.entityType === 'art').length);
ngOnInit() {
if (this.authService.isAuthenticated()) {
this.loadLikedItems();
}
}
loadLikedItems() {
this.loading.set(true);
this.likesService.getUserLikedItems().subscribe({
next: (items) => {
this.likedItems.set(items);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
}
});
}
setFilter(filter: 'all' | Like['entityType']) {
this.typeFilter.set(filter);
this.currentPage.set(1); // Reset to first page when filtering
}
onPageChange(page: number) {
this.currentPage.set(page);
}
onPageSizeChange(size: number) {
this.pageSize.set(size);
this.currentPage.set(1); // Reset to first page when changing page size
}
getItemTitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any;
return item.title || item.name || 'Untitled';
}
getItemSubtitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any;
const type = likedItem.like.entityType;
switch (type) {
case 'book':
return `by ${item.author || 'Unknown author'}`;
case 'game':
return item.platform || 'Platform not specified';
case 'show':
return item.type === 'movie' ? 'Movie' : 'TV Show';
case 'manga':
return item.volumeCount ? `${item.volumeCount} volumes` : 'Ongoing';
case 'music':
return `${item.artist || 'Unknown artist'} - ${item.type || 'Album'}`;
case 'art':
return `by ${item.artist || 'Unknown artist'}`;
default:
return '';
}
}
getItemImage(likedItem: LikedItemDto): string | null {
const item = likedItem.item as any;
return item.coverImage || item.imageUrl || null;
}
getItemLink(likedItem: LikedItemDto): string[] {
const type = likedItem.like.entityType;
const id = likedItem.like.entityId;
switch (type) {
case 'book':
return ['/books'];
case 'game':
return ['/games'];
case 'show':
return ['/shows'];
case 'manga':
return ['/manga'];
case 'music':
return ['/music'];
case 'art':
return ['/art'];
default:
return ['/'];
}
}
getTypeBadgeLabel(type: Like['entityType']): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
getTypePlaceholderEmoji(type: Like['entityType']): string {
switch (type) {
case 'book':
return '📚';
case 'game':
return '🎮';
case 'show':
return '🎬';
case 'manga':
return '📖';
case 'music':
return '🎵';
case 'art':
return '🎨';
default:
return '❤️';
}
}
formatDate(date: Date | string): string {
const d = new Date(date);
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
}