generated from nhcarrigan/template
ff0ae73fa7
## 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>
1854 lines
52 KiB
TypeScript
1854 lines
52 KiB
TypeScript
/**
|
||
* @copyright 2026 NHCarrigan
|
||
* @license Naomi's Public License
|
||
* @author Naomi Carrigan
|
||
*/
|
||
|
||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { RouterLink } from '@angular/router';
|
||
import { MusicService } from '../../services/music.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 { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||
|
||
@Component({
|
||
selector: 'app-music-list',
|
||
standalone: true,
|
||
imports: [CommonModule, FormsModule, RouterLink, PaginationComponent, LikeButtonComponent],
|
||
template: `
|
||
<div class="container">
|
||
<div class="page-hero">
|
||
<img src="/assets/avatars/music-avatar.jpg" alt="Music avatar" class="page-avatar" />
|
||
</div>
|
||
|
||
<div class="header-section">
|
||
<h2>My Music Collection</h2>
|
||
@if (authService.isAdmin()) {
|
||
<button (click)="toggleAddForm()" class="btn btn-primary">
|
||
{{ showAddForm() ? 'Cancel' : 'Add Music' }}
|
||
</button>
|
||
} @else if (authService.isAuthenticated() && !authService.user()?.isBanned) {
|
||
<button (click)="toggleSuggestForm()" class="btn btn-primary">
|
||
{{ showSuggestForm() ? 'Cancel' : 'Suggest Music' }}
|
||
</button>
|
||
}
|
||
</div>
|
||
|
||
@if (showAddForm() && authService.isAdmin()) {
|
||
<form (ngSubmit)="addMusic()" class="add-form">
|
||
<h3>Add New Music</h3>
|
||
<div class="form-group">
|
||
<label for="title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="title"
|
||
[(ngModel)]="newMusic.title"
|
||
name="title"
|
||
required
|
||
placeholder="Album/Single/EP title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="artist">Artist</label>
|
||
<input
|
||
type="text"
|
||
id="artist"
|
||
[(ngModel)]="newMusic.artist"
|
||
name="artist"
|
||
required
|
||
placeholder="Artist name"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="type">Type</label>
|
||
<select id="type" [(ngModel)]="newMusic.type" name="type" required>
|
||
<option [value]="MusicType.album">Album</option>
|
||
<option [value]="MusicType.single">Single</option>
|
||
<option [value]="MusicType.ep">EP</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="status">Status</label>
|
||
<select id="status" [(ngModel)]="newMusic.status" name="status" required>
|
||
<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
|
||
type="number"
|
||
id="rating"
|
||
[(ngModel)]="newMusic.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)]="newMusicTimeHours"
|
||
name="timeHours"
|
||
min="0"
|
||
placeholder="0"
|
||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||
>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="timeMinutes">Time Spent (Minutes)</label>
|
||
<input
|
||
type="number"
|
||
id="timeMinutes"
|
||
[(ngModel)]="newMusicTimeMinutes"
|
||
name="timeMinutes"
|
||
min="0"
|
||
max="59"
|
||
placeholder="0"
|
||
(ngModelChange)="updateNewMusicTimeSpent()"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="notes">Notes</label>
|
||
<textarea
|
||
id="notes"
|
||
[(ngModel)]="newMusic.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Your thoughts about this music..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="coverArt">Album Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="coverArt"
|
||
name="coverArt"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'new')"
|
||
>
|
||
@if (newMusicImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="newMusicImagePreview()" alt="Album art 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 newMusic.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 newMusic.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., Spotify)"
|
||
>
|
||
<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 Music</button>
|
||
<button type="button" (click)="toggleAddForm()" class="btn btn-secondary">Cancel</button>
|
||
</div>
|
||
</form>
|
||
}
|
||
|
||
@if (editingMusic() && authService.isAdmin()) {
|
||
<form (ngSubmit)="saveEdit()" class="add-form">
|
||
<h3>Edit Music</h3>
|
||
<div class="form-group">
|
||
<label for="edit-title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="edit-title"
|
||
[(ngModel)]="editMusicData.title"
|
||
name="title"
|
||
required
|
||
placeholder="Album/Single/EP title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-artist">Artist</label>
|
||
<input
|
||
type="text"
|
||
id="edit-artist"
|
||
[(ngModel)]="editMusicData.artist"
|
||
name="artist"
|
||
required
|
||
placeholder="Artist name"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-type">Type</label>
|
||
<select id="edit-type" [(ngModel)]="editMusicData.type" name="type" required>
|
||
<option [value]="MusicType.album">Album</option>
|
||
<option [value]="MusicType.single">Single</option>
|
||
<option [value]="MusicType.ep">EP</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-status">Status</label>
|
||
<select id="edit-status" [(ngModel)]="editMusicData.status" name="status" required>
|
||
<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
|
||
type="number"
|
||
id="edit-rating"
|
||
[(ngModel)]="editMusicData.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)]="editMusicTimeHours"
|
||
name="timeHours"
|
||
min="0"
|
||
placeholder="0"
|
||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||
>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="edit-timeMinutes">Time Spent (Minutes)</label>
|
||
<input
|
||
type="number"
|
||
id="edit-timeMinutes"
|
||
[(ngModel)]="editMusicTimeMinutes"
|
||
name="timeMinutes"
|
||
min="0"
|
||
max="59"
|
||
placeholder="0"
|
||
(ngModelChange)="updateEditMusicTimeSpent()"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-notes">Notes</label>
|
||
<textarea
|
||
id="edit-notes"
|
||
[(ngModel)]="editMusicData.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Your thoughts about this music..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="edit-coverArt">Album Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="edit-coverArt"
|
||
name="coverArt"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'edit')"
|
||
>
|
||
@if (editMusicImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="editMusicImagePreview()" alt="Album art 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 editMusicData.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 editMusicData.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., Spotify)"
|
||
>
|
||
<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 Music</h3>
|
||
<p class="suggest-note">Your suggestion will be reviewed by Naomi. If accepted, it will be added to the listening list!</p>
|
||
<div class="form-group">
|
||
<label for="suggest-title">Title</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-title"
|
||
[(ngModel)]="suggestedMusic.title"
|
||
name="title"
|
||
required
|
||
placeholder="Album/Single/EP title"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-artist">Artist</label>
|
||
<input
|
||
type="text"
|
||
id="suggest-artist"
|
||
[(ngModel)]="suggestedMusic.artist"
|
||
name="artist"
|
||
required
|
||
placeholder="Artist name"
|
||
>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-type">Type</label>
|
||
<select id="suggest-type" [(ngModel)]="suggestedMusic.type" name="type" required>
|
||
<option [value]="MusicType.album">Album</option>
|
||
<option [value]="MusicType.single">Single</option>
|
||
<option [value]="MusicType.ep">EP</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-notes">Notes (why should Naomi listen to this?)</label>
|
||
<textarea
|
||
id="suggest-notes"
|
||
[(ngModel)]="suggestedMusic.notes"
|
||
name="notes"
|
||
rows="3"
|
||
placeholder="Tell Naomi why this music is worth listening to..."
|
||
></textarea>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="suggest-coverArt">Album Art (max 500KB)</label>
|
||
<input
|
||
type="file"
|
||
id="suggest-coverArt"
|
||
name="coverArt"
|
||
accept="image/*"
|
||
(change)="onImageSelected($event, 'suggest')"
|
||
>
|
||
@if (suggestMusicImagePreview()) {
|
||
<div class="image-preview">
|
||
<img [src]="suggestMusicImagePreview()" alt="Album art 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"
|
||
[value]="searchQuery()"
|
||
(input)="searchQuery.set($any($event.target).value); currentPage.set(1)"
|
||
name="search"
|
||
placeholder="Search by title, artist, type, or notes..."
|
||
class="search-input"
|
||
>
|
||
<button (click)="toggleFilters()" class="btn btn-secondary btn-sm">
|
||
{{ showFilters() ? 'Hide' : 'Show' }} Advanced Filters
|
||
@if (selectedTags().length > 0) {
|
||
({{ selectedTags().length }})
|
||
}
|
||
</button>
|
||
@if (searchQuery() || selectedTags().length > 0) {
|
||
<button (click)="clearFilters()" class="btn btn-secondary btn-sm">
|
||
Clear All Filters
|
||
</button>
|
||
}
|
||
</div>
|
||
|
||
@if (showFilters()) {
|
||
<div class="advanced-filters">
|
||
<div class="filter-group">
|
||
<h4>Filter by Tags</h4>
|
||
<div class="tags-filter">
|
||
@for (tag of allTags(); track tag) {
|
||
<label class="tag-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
[checked]="selectedTags().includes(tag)"
|
||
(change)="toggleTag(tag)"
|
||
>
|
||
<span>{{ tag }}</span>
|
||
</label>
|
||
}
|
||
@empty {
|
||
<p class="no-tags">No tags available</p>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<div class="filters">
|
||
<div class="filter-group">
|
||
<strong>Type:</strong>
|
||
<button
|
||
(click)="setTypeFilter('all')"
|
||
[class.active]="typeFilter() === 'all'"
|
||
class="filter-btn"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
(click)="setTypeFilter(MusicType.album)"
|
||
[class.active]="typeFilter() === MusicType.album"
|
||
class="filter-btn"
|
||
>
|
||
Albums ({{ albumCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setTypeFilter(MusicType.single)"
|
||
[class.active]="typeFilter() === MusicType.single"
|
||
class="filter-btn"
|
||
>
|
||
Singles ({{ singleCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setTypeFilter(MusicType.ep)"
|
||
[class.active]="typeFilter() === MusicType.ep"
|
||
class="filter-btn"
|
||
>
|
||
EPs ({{ epCount() }})
|
||
</button>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<strong>Status:</strong>
|
||
<button
|
||
(click)="setStatusFilter('all')"
|
||
[class.active]="statusFilter() === 'all'"
|
||
class="filter-btn"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
(click)="setStatusFilter(MusicStatus.listening)"
|
||
[class.active]="statusFilter() === MusicStatus.listening"
|
||
class="filter-btn"
|
||
>
|
||
Listening ({{ listeningCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setStatusFilter(MusicStatus.completed)"
|
||
[class.active]="statusFilter() === MusicStatus.completed"
|
||
class="filter-btn"
|
||
>
|
||
Completed ({{ completedCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setStatusFilter(MusicStatus.wantToListen)"
|
||
[class.active]="statusFilter() === MusicStatus.wantToListen"
|
||
class="filter-btn"
|
||
>
|
||
Want to Listen ({{ wantToListenCount() }})
|
||
</button>
|
||
<button
|
||
(click)="setStatusFilter(MusicStatus.retired)"
|
||
[class.active]="statusFilter() === MusicStatus.retired"
|
||
class="filter-btn"
|
||
>
|
||
Retired ({{ retiredCount() }})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
@if (loading()) {
|
||
<div class="loading">Loading music...</div>
|
||
} @else if (filteredMusic().length === 0) {
|
||
<div class="empty-state">
|
||
<p>No music found with these filters.</p>
|
||
</div>
|
||
} @else {
|
||
<app-pagination
|
||
[currentPage]="currentPage()"
|
||
[pageSize]="pageSize()"
|
||
[totalItems]="totalFilteredMusic()"
|
||
(pageChange)="onPageChange($event)"
|
||
(pageSizeChange)="onPageSizeChange($event)"
|
||
></app-pagination>
|
||
|
||
<div class="music-grid">
|
||
@for (music of paginatedMusic(); track music.id) {
|
||
<div class="music-card" [class.completed]="music.status === MusicStatus.completed">
|
||
<a [routerLink]="['/music', music.id]" class="card-link">
|
||
<img [src]="music.coverArt || '/assets/default-cover.jpg'" [alt]="music.title" class="music-cover">
|
||
|
||
<div class="music-info">
|
||
<h3>{{ music.title }}</h3>
|
||
<p class="artist">{{ music.artist }}</p>
|
||
|
||
@if (music.type) {
|
||
<div class="badges">
|
||
<span class="type-badge type-{{ music.type }}">
|
||
{{ getTypeLabel(music.type) }}
|
||
</span>
|
||
<span class="status status-{{ music.status }}">
|
||
{{ getStatusLabel(music.status) }}
|
||
</span>
|
||
</div>
|
||
}
|
||
|
||
@if (music.rating) {
|
||
<div class="rating">
|
||
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) {
|
||
<span [class.filled]="star <= music.rating!">★</span>
|
||
}
|
||
</div>
|
||
}
|
||
</div>
|
||
</a>
|
||
|
||
<div class="card-actions">
|
||
<app-like-button
|
||
entityType="music"
|
||
[entityId]="music.id"
|
||
></app-like-button>
|
||
|
||
@if (authService.isAdmin()) {
|
||
<div class="admin-actions">
|
||
<button (click)="startEdit(music)" class="btn btn-secondary btn-sm">
|
||
Edit
|
||
</button>
|
||
<button (click)="deleteMusic(music)" class="btn btn-danger btn-sm">
|
||
Delete
|
||
</button>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<app-pagination
|
||
[currentPage]="currentPage()"
|
||
[pageSize]="pageSize()"
|
||
[totalItems]="totalFilteredMusic()"
|
||
(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 #74b9ff;
|
||
box-shadow: 0 4px 12px rgba(116, 185, 255, 0.3);
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.page-avatar:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 8px 16px rgba(116, 185, 255, 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 #74b9ff;
|
||
background: #f5faff;
|
||
}
|
||
|
||
.suggest-note {
|
||
color: #666;
|
||
font-size: 0.9rem;
|
||
margin-bottom: 1rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.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-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.search-section {
|
||
display: flex;
|
||
gap: 1rem;
|
||
align-items: center;
|
||
margin-bottom: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
min-width: 250px;
|
||
padding: 0.5rem 1rem;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: #8b5cf6;
|
||
}
|
||
|
||
.advanced-filters {
|
||
background: #f8f9fa;
|
||
padding: 1rem;
|
||
border-radius: 8px;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.filter-group h4 {
|
||
margin: 0 0 0.75rem 0;
|
||
color: #374151;
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tags-filter {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.tag-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
padding: 0.25rem 0.75rem;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 20px;
|
||
background: white;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.tag-checkbox:hover {
|
||
border-color: #8b5cf6;
|
||
background: #f3e8ff;
|
||
}
|
||
|
||
.tag-checkbox input[type="checkbox"] {
|
||
width: 16px;
|
||
height: 16px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tag-checkbox span {
|
||
font-size: 0.875rem;
|
||
color: #374151;
|
||
}
|
||
|
||
.no-tags {
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-group strong {
|
||
min-width: 60px;
|
||
color: var(--witch-plum);
|
||
}
|
||
|
||
.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: #74b9ff;
|
||
color: white;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 2rem;
|
||
color: var(--witch-plum);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: var(--witch-plum);
|
||
}
|
||
|
||
.music-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 1.5rem;
|
||
}
|
||
|
||
.music-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;
|
||
}
|
||
|
||
.music-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px var(--witch-shadow);
|
||
border-color: var(--witch-mauve);
|
||
}
|
||
|
||
.music-card.completed {
|
||
opacity: 0.9;
|
||
border-color: var(--witch-mauve);
|
||
}
|
||
|
||
.card-link {
|
||
text-decoration: none;
|
||
color: inherit;
|
||
display: block;
|
||
flex: 1;
|
||
}
|
||
|
||
.card-link:hover h3 {
|
||
color: var(--witch-rose);
|
||
}
|
||
|
||
.music-cover {
|
||
width: 100%;
|
||
height: 250px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.music-cover.placeholder {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--witch-lavender);
|
||
font-size: 4rem;
|
||
}
|
||
|
||
.music-info {
|
||
padding: 1rem;
|
||
}
|
||
|
||
.music-info h3 {
|
||
margin: 0 0 0.5rem 0;
|
||
font-size: 1.1rem;
|
||
transition: color 0.3s;
|
||
}
|
||
|
||
.artist {
|
||
color: var(--witch-plum);
|
||
font-weight: 500;
|
||
margin: 0.5rem 0;
|
||
}
|
||
|
||
.badges {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin: 0.75rem 0;
|
||
}
|
||
|
||
.card-actions {
|
||
padding: 1rem;
|
||
border-top: 1px solid var(--witch-lavender);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.admin-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.type-badge {
|
||
display: inline-block;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.type-album {
|
||
background: var(--witch-purple);
|
||
color: var(--witch-moon);
|
||
}
|
||
.type-single {
|
||
background: var(--witch-rose);
|
||
color: var(--witch-moon);
|
||
}
|
||
.type-ep {
|
||
background: var(--witch-plum);
|
||
color: var(--witch-moon);
|
||
}
|
||
|
||
.status {
|
||
display: inline-block;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status-listening {
|
||
background: var(--witch-rose);
|
||
color: var(--witch-moon);
|
||
}
|
||
.status-completed {
|
||
background: var(--witch-mauve);
|
||
color: var(--witch-purple);
|
||
}
|
||
.status-wantToListen {
|
||
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: #74b9ff;
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
background: #4a9ff5;
|
||
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: 100px;
|
||
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: #74b9ff;
|
||
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;
|
||
}
|
||
`]
|
||
})
|
||
export class MusicListComponent implements OnInit {
|
||
musicService = inject(MusicService);
|
||
authService = inject(AuthService);
|
||
commentsService = inject(CommentsService);
|
||
sanitizeService = inject(SanitizeService);
|
||
suggestionService = inject(SuggestionService);
|
||
|
||
music = signal<Music[]>([]);
|
||
loading = signal(true);
|
||
showAddForm = signal(false);
|
||
editingMusic = signal<Music | null>(null);
|
||
typeFilter = signal<'all' | MusicType>('all');
|
||
statusFilter = signal<'all' | MusicStatus>('all');
|
||
|
||
// Pagination state
|
||
currentPage = signal(1);
|
||
pageSize = signal(25);
|
||
|
||
// Search and filter state
|
||
searchQuery = signal('');
|
||
selectedTags = signal<string[]>([]);
|
||
showFilters = signal(false);
|
||
|
||
// 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
|
||
newMusicImagePreview = signal<string | null>(null);
|
||
editMusicImagePreview = signal<string | null>(null);
|
||
suggestMusicImagePreview = signal<string | null>(null);
|
||
imageError = signal<string | null>(null);
|
||
private readonly MAX_IMAGE_SIZE = 500 * 1024; // 500KB
|
||
|
||
// Suggestion state
|
||
showSuggestForm = signal(false);
|
||
suggestedMusic: { title: string; artist: string; type: MusicType; notes?: string; coverArt?: string } = {
|
||
title: '',
|
||
artist: '',
|
||
type: MusicType.album,
|
||
notes: '',
|
||
coverArt: undefined
|
||
};
|
||
|
||
// Expose enums to template
|
||
MusicType = MusicType;
|
||
MusicStatus = MusicStatus;
|
||
|
||
// Computed signals for reactive count updates (type)
|
||
albumCount = computed(() => this.music().filter(m => m.type === MusicType.album).length);
|
||
singleCount = computed(() => this.music().filter(m => m.type === MusicType.single).length);
|
||
epCount = computed(() => this.music().filter(m => m.type === MusicType.ep).length);
|
||
|
||
// Computed signals for reactive count updates (status)
|
||
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>();
|
||
this.music().forEach(music => {
|
||
music.tags?.forEach(tag => tagsSet.add(tag));
|
||
});
|
||
return Array.from(tagsSet).sort();
|
||
});
|
||
|
||
filteredMusic = computed(() => {
|
||
let filtered = this.music();
|
||
|
||
const typeFilter = this.typeFilter();
|
||
if (typeFilter !== 'all') {
|
||
filtered = filtered.filter(music => music.type === typeFilter);
|
||
}
|
||
|
||
const statusFilter = this.statusFilter();
|
||
if (statusFilter !== 'all') {
|
||
filtered = filtered.filter(music => music.status === statusFilter);
|
||
}
|
||
|
||
// Apply search filter
|
||
const searchQuery = this.searchQuery().toLowerCase().trim();
|
||
if (searchQuery) {
|
||
filtered = filtered.filter(music =>
|
||
music.title.toLowerCase().includes(searchQuery) ||
|
||
music.artist.toLowerCase().includes(searchQuery) ||
|
||
music.notes?.toLowerCase().includes(searchQuery) ||
|
||
this.getTypeLabel(music.type).toLowerCase().includes(searchQuery)
|
||
);
|
||
}
|
||
|
||
// Apply tag filter
|
||
const selectedTags = this.selectedTags();
|
||
if (selectedTags.length > 0) {
|
||
filtered = filtered.filter(music =>
|
||
selectedTags.every(tag => music.tags?.includes(tag))
|
||
);
|
||
}
|
||
|
||
return filtered;
|
||
});
|
||
|
||
paginatedMusic = computed(() => {
|
||
const music = this.filteredMusic();
|
||
const start = (this.currentPage() - 1) * this.pageSize();
|
||
const end = start + this.pageSize();
|
||
return music.slice(start, end);
|
||
});
|
||
|
||
totalFilteredMusic = computed(() => this.filteredMusic().length);
|
||
|
||
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = {
|
||
title: '',
|
||
artist: '',
|
||
type: MusicType.album,
|
||
status: MusicStatus.wantToListen,
|
||
dateStarted: undefined,
|
||
dateFinished: undefined,
|
||
rating: undefined,
|
||
notes: '',
|
||
tags: [],
|
||
links: []
|
||
};
|
||
|
||
editMusicData: Partial<UpdateMusicDto> = {};
|
||
|
||
// Time tracking state
|
||
newMusicTimeHours = 0;
|
||
newMusicTimeMinutes = 0;
|
||
editMusicTimeHours = 0;
|
||
editMusicTimeMinutes = 0;
|
||
|
||
// Tags and links input state
|
||
newTagInput = '';
|
||
editTagInput = '';
|
||
newLinkTitle = '';
|
||
newLinkUrl = '';
|
||
editLinkTitle = '';
|
||
editLinkUrl = '';
|
||
|
||
ngOnInit() {
|
||
this.loadMusic();
|
||
}
|
||
|
||
loadMusic() {
|
||
this.loading.set(true);
|
||
this.musicService.getAllMusic().subscribe({
|
||
next: (music) => {
|
||
this.music.set(music);
|
||
this.loading.set(false);
|
||
},
|
||
error: () => {
|
||
this.loading.set(false);
|
||
}
|
||
});
|
||
}
|
||
|
||
setTypeFilter(filter: 'all' | MusicType) {
|
||
this.typeFilter.set(filter);
|
||
this.currentPage.set(1); // Reset to first page when filter changes
|
||
}
|
||
|
||
setStatusFilter(filter: 'all' | MusicStatus) {
|
||
this.statusFilter.set(filter);
|
||
this.currentPage.set(1); // Reset to first page when filter changes
|
||
}
|
||
|
||
toggleTag(tag: string) {
|
||
const current = this.selectedTags();
|
||
if (current.includes(tag)) {
|
||
this.selectedTags.set(current.filter(t => t !== tag));
|
||
} else {
|
||
this.selectedTags.set([...current, tag]);
|
||
}
|
||
this.currentPage.set(1); // Reset to first page when tags change
|
||
}
|
||
|
||
clearFilters() {
|
||
this.searchQuery.set('');
|
||
this.selectedTags.set([]);
|
||
this.typeFilter.set('all');
|
||
this.statusFilter.set('all');
|
||
this.currentPage.set(1);
|
||
}
|
||
|
||
toggleFilters() {
|
||
this.showFilters.update(v => !v);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
getTypeLabel(type: MusicType): string {
|
||
switch (type) {
|
||
case MusicType.album: return 'Album';
|
||
case MusicType.single: return 'Single';
|
||
case MusicType.ep: return 'EP';
|
||
}
|
||
}
|
||
|
||
getStatusLabel(status: MusicStatus): string {
|
||
switch (status) {
|
||
case MusicStatus.listening: return 'Currently Listening';
|
||
case MusicStatus.completed: return 'Completed';
|
||
case MusicStatus.wantToListen: return 'Want to Listen';
|
||
case MusicStatus.retired: return 'Retired';
|
||
}
|
||
}
|
||
|
||
toggleAddForm() {
|
||
this.showAddForm.update(v => !v);
|
||
if (!this.showAddForm()) {
|
||
this.resetForm();
|
||
}
|
||
}
|
||
|
||
resetForm() {
|
||
this.newMusic = {
|
||
title: '',
|
||
artist: '',
|
||
type: MusicType.album,
|
||
status: MusicStatus.wantToListen,
|
||
dateStarted: undefined,
|
||
dateFinished: undefined,
|
||
rating: undefined,
|
||
notes: '',
|
||
coverArt: undefined,
|
||
tags: [],
|
||
links: []
|
||
};
|
||
this.newMusicTimeHours = 0;
|
||
this.newMusicTimeMinutes = 0;
|
||
this.newMusicImagePreview.set(null);
|
||
this.imageError.set(null);
|
||
this.newTagInput = '';
|
||
this.newLinkTitle = '';
|
||
this.newLinkUrl = '';
|
||
}
|
||
|
||
updateNewMusicTimeSpent() {
|
||
const totalMinutes = (this.newMusicTimeHours * 60) + this.newMusicTimeMinutes;
|
||
this.newMusic.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||
}
|
||
|
||
updateEditMusicTimeSpent() {
|
||
const totalMinutes = (this.editMusicTimeHours * 60) + this.editMusicTimeMinutes;
|
||
this.editMusicData.timeSpent = totalMinutes > 0 ? totalMinutes : undefined;
|
||
}
|
||
|
||
addTag(target: 'new' | 'edit') {
|
||
const input = target === 'new' ? this.newTagInput.trim() : this.editTagInput.trim();
|
||
if (!input) return;
|
||
|
||
if (target === 'new') {
|
||
this.newMusic.tags = [...(this.newMusic.tags || []), input];
|
||
this.newTagInput = '';
|
||
} else {
|
||
this.editMusicData.tags = [...(this.editMusicData.tags || []), input];
|
||
this.editTagInput = '';
|
||
}
|
||
}
|
||
|
||
removeTag(index: number, target: 'new' | 'edit') {
|
||
if (target === 'new') {
|
||
this.newMusic.tags = (this.newMusic.tags || []).filter((_, i) => i !== index);
|
||
} else {
|
||
this.editMusicData.tags = (this.editMusicData.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.newMusic.links = [...(this.newMusic.links || []), { title, url }];
|
||
this.newLinkTitle = '';
|
||
this.newLinkUrl = '';
|
||
} else {
|
||
this.editMusicData.links = [...(this.editMusicData.links || []), { title, url }];
|
||
this.editLinkTitle = '';
|
||
this.editLinkUrl = '';
|
||
}
|
||
}
|
||
|
||
removeLink(index: number, target: 'new' | 'edit') {
|
||
if (target === 'new') {
|
||
this.newMusic.links = (this.newMusic.links || []).filter((_, i) => i !== index);
|
||
} else {
|
||
this.editMusicData.links = (this.editMusicData.links || []).filter((_, i) => i !== index);
|
||
}
|
||
}
|
||
|
||
addMusic() {
|
||
if (!this.newMusic.title || !this.newMusic.artist || !this.newMusic.type || !this.newMusic.status) return;
|
||
|
||
const musicToAdd: CreateMusicDto = {
|
||
title: this.newMusic.title,
|
||
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,
|
||
tags: this.newMusic.tags || [],
|
||
links: this.newMusic.links || []
|
||
};
|
||
|
||
this.musicService.createMusic(musicToAdd).subscribe(() => {
|
||
this.loadMusic();
|
||
this.toggleAddForm();
|
||
});
|
||
}
|
||
|
||
deleteMusic(music: Music) {
|
||
if (confirm(`Are you sure you want to delete "${music.title}" by ${music.artist}?`)) {
|
||
this.musicService.deleteMusic(music.id).subscribe(() => {
|
||
this.loadMusic();
|
||
});
|
||
}
|
||
}
|
||
|
||
startEdit(music: Music) {
|
||
this.editingMusic.set(music);
|
||
this.editMusicData = {
|
||
title: music.title,
|
||
artist: music.artist,
|
||
type: music.type,
|
||
status: music.status,
|
||
dateStarted: music.dateStarted,
|
||
dateFinished: music.dateFinished,
|
||
rating: music.rating,
|
||
notes: music.notes,
|
||
coverArt: music.coverArt,
|
||
tags: [...(music.tags || [])],
|
||
links: [...(music.links || [])],
|
||
timeSpent: music.timeSpent
|
||
};
|
||
// Populate time fields from existing timeSpent
|
||
if (music.timeSpent) {
|
||
this.editMusicTimeHours = Math.floor(music.timeSpent / 60);
|
||
this.editMusicTimeMinutes = music.timeSpent % 60;
|
||
} else {
|
||
this.editMusicTimeHours = 0;
|
||
this.editMusicTimeMinutes = 0;
|
||
}
|
||
this.editMusicImagePreview.set(music.coverArt || null);
|
||
this.showAddForm.set(false);
|
||
this.imageError.set(null);
|
||
this.editTagInput = '';
|
||
this.editLinkTitle = '';
|
||
this.editLinkUrl = '';
|
||
|
||
// Scroll to top so the form is visible
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
cancelEdit() {
|
||
this.editingMusic.set(null);
|
||
this.editMusicData = {};
|
||
this.editMusicImagePreview.set(null);
|
||
this.imageError.set(null);
|
||
this.editTagInput = '';
|
||
this.editLinkTitle = '';
|
||
this.editLinkUrl = '';
|
||
}
|
||
|
||
saveEdit() {
|
||
const music = this.editingMusic();
|
||
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
|
||
|
||
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();
|
||
});
|
||
}
|
||
|
||
formatDate(date: Date | string): string {
|
||
return new Date(date).toLocaleDateString();
|
||
}
|
||
|
||
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.newMusicImagePreview.set(base64);
|
||
this.newMusic.coverArt = base64;
|
||
} else if (target === 'edit') {
|
||
this.editMusicImagePreview.set(base64);
|
||
this.editMusicData.coverArt = base64;
|
||
} else {
|
||
this.suggestMusicImagePreview.set(base64);
|
||
this.suggestedMusic.coverArt = base64;
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
clearImage(target: 'new' | 'edit' | 'suggest') {
|
||
if (target === 'new') {
|
||
this.newMusicImagePreview.set(null);
|
||
this.newMusic.coverArt = undefined;
|
||
} else if (target === 'edit') {
|
||
this.editMusicImagePreview.set(null);
|
||
this.editMusicData.coverArt = undefined;
|
||
} else {
|
||
this.suggestMusicImagePreview.set(null);
|
||
this.suggestedMusic.coverArt = undefined;
|
||
}
|
||
this.imageError.set(null);
|
||
}
|
||
|
||
// Comments methods
|
||
toggleComments(musicId: string) {
|
||
const expanded = this.expandedComments();
|
||
const isCurrentlyExpanded = expanded[musicId];
|
||
|
||
this.expandedComments.set({
|
||
...expanded,
|
||
[musicId]: !isCurrentlyExpanded
|
||
});
|
||
|
||
if (!isCurrentlyExpanded && !this.comments()[musicId]) {
|
||
this.loadComments(musicId);
|
||
}
|
||
}
|
||
|
||
loadComments(musicId: string) {
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[musicId]: true
|
||
});
|
||
|
||
this.commentsService.getCommentsForMusic(musicId).subscribe({
|
||
next: (comments) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[musicId]: comments
|
||
});
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[musicId]: false
|
||
});
|
||
},
|
||
error: () => {
|
||
this.commentsLoading.set({
|
||
...this.commentsLoading(),
|
||
[musicId]: false
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
getCommentCount(musicId: string): number {
|
||
return this.comments()[musicId]?.length || 0;
|
||
}
|
||
|
||
addComment(musicId: string) {
|
||
const content = this.newCommentContent[musicId];
|
||
if (!content?.trim()) return;
|
||
|
||
this.commentsService.addCommentToMusic(musicId, { content }).subscribe({
|
||
next: (comment) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[musicId]: [comment, ...(this.comments()[musicId] || [])]
|
||
});
|
||
this.newCommentContent[musicId] = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
deleteComment(musicId: string, commentId: string) {
|
||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||
|
||
this.commentsService.deleteCommentFromMusic(musicId, commentId).subscribe({
|
||
next: () => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[musicId]: (this.comments()[musicId] || []).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(musicId: string, comment: Comment) {
|
||
this.editingCommentId.set(comment.id);
|
||
this.editCommentContent = comment.rawContent ?? comment.content;
|
||
}
|
||
|
||
cancelCommentEdit() {
|
||
this.editingCommentId.set(null);
|
||
this.editCommentContent = '';
|
||
}
|
||
|
||
saveCommentEdit(musicId: string, commentId: string) {
|
||
if (!this.editCommentContent.trim()) return;
|
||
|
||
this.commentsService.updateCommentOnMusic(musicId, commentId, this.editCommentContent).subscribe({
|
||
next: (updatedComment) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[musicId]: (this.comments()[musicId] || []).map(c =>
|
||
c.id === commentId ? updatedComment : c
|
||
)
|
||
});
|
||
this.cancelCommentEdit();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Suggestion methods
|
||
toggleSuggestForm() {
|
||
this.showSuggestForm.update(v => !v);
|
||
if (!this.showSuggestForm()) {
|
||
this.resetSuggestForm();
|
||
}
|
||
}
|
||
|
||
resetSuggestForm() {
|
||
this.suggestedMusic = {
|
||
title: '',
|
||
artist: '',
|
||
type: MusicType.album,
|
||
notes: '',
|
||
coverArt: undefined
|
||
};
|
||
this.suggestMusicImagePreview.set(null);
|
||
this.imageError.set(null);
|
||
}
|
||
|
||
async submitSuggestion() {
|
||
if (!this.suggestedMusic.title || !this.suggestedMusic.artist) return;
|
||
|
||
try {
|
||
await this.suggestionService.createSuggestion({
|
||
entityType: SuggestionEntity.music,
|
||
title: this.suggestedMusic.title,
|
||
artist: this.suggestedMusic.artist,
|
||
type: this.suggestedMusic.type,
|
||
notes: this.suggestedMusic.notes,
|
||
coverArt: this.suggestedMusic.coverArt
|
||
});
|
||
alert('Thank you for your suggestion! It will be reviewed soon.');
|
||
this.toggleSuggestForm();
|
||
} catch {
|
||
alert('Failed to submit suggestion. Please try again.');
|
||
}
|
||
}
|
||
|
||
handleCommentEdit(musicId: string, event: { commentId: string; content: string }) {
|
||
this.commentsService.updateCommentOnMusic(musicId, event.commentId, event.content).subscribe({
|
||
next: (updatedComment) => {
|
||
this.comments.set({
|
||
...this.comments(),
|
||
[musicId]: (this.comments()[musicId] || []).map(c =>
|
||
c.id === event.commentId ? updatedComment : c
|
||
)
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
getCommentsSignal(musicId: string) {
|
||
return signal(this.comments()[musicId] || []);
|
||
}
|
||
} |