/** * @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 { 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, PaginationComponent, LikeButtonComponent], template: `

My Music Collection

@if (authService.isAdmin()) { } @else if (authService.isAuthenticated() && !authService.user()?.isBanned) { }
@if (showAddForm() && authService.isAdmin()) {

Add New Music

@if (newMusicImagePreview()) {
Album art preview
} @if (imageError()) { {{ imageError() }} }
@for (tag of newMusic.tags; track tag; let i = $index) { {{ tag }} }
} @if (editingMusic() && authService.isAdmin()) {

Edit Music

@if (editMusicImagePreview()) {
Album art preview
} @if (imageError()) { {{ imageError() }} }
@for (tag of editMusicData.tags; track tag; let i = $index) { {{ tag }} }
} @if (showSuggestForm() && !authService.isAdmin() && authService.isAuthenticated()) {

Suggest Music

Your suggestion will be reviewed by Naomi. If accepted, it will be added to the listening list!

@if (suggestMusicImagePreview()) {
Album art preview
} @if (imageError()) { {{ imageError() }} }
}
@if (searchQuery() || selectedTags().length > 0) { }
@if (showFilters()) {

Filter by Tags

@for (tag of allTags(); track tag) { } @empty {

No tags available

}
}
Type:
Status:
@if (loading()) {
Loading music...
} @else if (filteredMusic().length === 0) {

No music found with these filters.

} @else {
@for (music of paginatedMusic(); track music.id) {
@if (music.coverArt) { } @else {
@switch (music.type) { @case (MusicType.album) { 💿 } @case (MusicType.single) { 🎵 } @case (MusicType.ep) { 🎶 } }
}

{{ music.title }}

{{ music.artist }}

{{ getTypeLabel(music.type) }} {{ getStatusLabel(music.status) }}
@if (music.rating) {
@for (star of [1,2,3,4,5,6,7,8,9,10]; track star) { }
} @if (music.notes) {

{{ music.notes }}

} @if (music.tags && music.tags.length > 0) {
@for (tag of music.tags; track tag) { {{ tag }} }
} @if (music.links && music.links.length > 0) { } @if (music.dateStarted) {

Started: {{ formatDate(music.dateStarted) }}

} @if (music.dateFinished) {

Finished: {{ formatDate(music.dateFinished) }}

} @if (music.createdAt) {

Added: {{ formatDate(music.createdAt) }}

} @if (music.updatedAt) {

Updated: {{ formatDate(music.updatedAt) }}

} @if (authService.isAdmin()) {
}
@if (expandedComments()[music.id]) {
@if (authService.isAuthenticated()) { @if (authService.user()?.isBanned) {
You have been banned from commenting.
} @else {
} } @if (commentsLoading()[music.id]) {
Loading comments...
} @else { @for (comment of comments()[music.id] || []; track comment.id) {
@if (comment.user.avatar) { } {{ comment.user.username }} @if (comment.user.inDiscord) { Discord } @if (comment.user.isVip) { VIP } @if (comment.user.isMod) { Mod } @if (comment.user.isStaff) { Staff } {{ formatDate(comment.createdAt) }} @if (canEditComment(comment)) { } @if (canDeleteComment(comment)) { }
@if (editingCommentId() === comment.id) {
} @else {
}
} @empty {
No comments yet. Be the first to comment!
} }
}
}
}
`, styles: [` .container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .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-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); } .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); } .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; } .artist { color: var(--witch-plum); font-weight: 500; margin: 0.5rem 0; } .badges { display: flex; gap: 0.5rem; margin: 0.75rem 0; } .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); } .notes { font-size: 0.9rem; color: var(--witch-plum); margin: 0.5rem 0; } .date-started, .date-finished, .date-added, .date-updated { font-size: 0.85rem; color: var(--witch-plum); margin-top: 0.5rem; } .actions { margin-top: 1rem; } .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; } .comments-section { margin-top: 1rem; border-top: 1px solid var(--witch-lavender); padding-top: 1rem; } .comments-toggle { width: 100%; } .comments-container { margin-top: 1rem; } .comment-form { margin-bottom: 1rem; } .comment-form textarea { width: 100%; padding: 0.5rem; border: 2px solid var(--witch-lavender); border-radius: 4px; font-size: 0.9rem; background-color: var(--witch-moon); color: var(--witch-purple); resize: vertical; margin-bottom: 0.5rem; } .comment-form textarea:focus { outline: none; border-color: var(--witch-rose); } .comment { background: var(--witch-moon); border: 1px solid var(--witch-lavender); border-radius: 4px; padding: 0.75rem; margin-bottom: 0.5rem; } .comment-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; } .comment-avatar { width: 24px; height: 24px; border-radius: 50%; } .comment-author { font-weight: 500; color: var(--witch-plum); } .discord-badge { background: #5865f2; color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 500; } .vip-badge { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #1a1a1a; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .mod-badge { background: linear-gradient(135deg, #00b894, #00cec9); color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .staff-badge { background: linear-gradient(135deg, #e84393, #fd79a8); color: white; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; } .comment-date { font-size: 0.75rem; color: var(--witch-mauve); } .comment-content { font-size: 0.9rem; color: var(--witch-purple); } .comments-loading, .no-comments { text-align: center; padding: 1rem; color: var(--witch-mauve); font-size: 0.9rem; } .banned-notice { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; padding: 0.75rem 1rem; border-radius: 4px; text-align: center; margin-bottom: 1rem; font-size: 0.9rem; } .comment-edit-form { margin-top: 0.5rem; } .comment-edit-form textarea { width: 100%; padding: 0.5rem; border: 2px solid var(--witch-lavender); border-radius: 4px; font-size: 0.9rem; background-color: var(--witch-moon); color: var(--witch-purple); resize: vertical; } .comment-edit-form textarea:focus { outline: none; border-color: var(--witch-rose); } .comment-edit-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .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; } .tags-display { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; } .tag-chip { background: rgba(116, 185, 255, 0.2); color: #74b9ff; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } .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; } .links-display { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.5rem 0; } .external-link { color: #74b9ff; text-decoration: none; font-size: 0.85rem; padding: 0.2rem 0.5rem; background: rgba(116, 185, 255, 0.1); border-radius: 4px; transition: background 0.2s; } .external-link:hover { background: rgba(116, 185, 255, 0.2); text-decoration: underline; } `] }) export class MusicListComponent implements OnInit { musicService = inject(MusicService); authService = inject(AuthService); commentsService = inject(CommentsService); sanitizeService = inject(SanitizeService); suggestionService = inject(SuggestionService); music = signal([]); loading = signal(true); showAddForm = signal(false); editingMusic = signal(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([]); showFilters = signal(false); // Comments state comments = signal>({}); commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; editingCommentId = signal(null); editCommentContent = ''; // Image upload state newMusicImagePreview = signal(null); editMusicImagePreview = signal(null); suggestMusicImagePreview = signal(null); imageError = signal(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(); 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 & { dateStarted?: Date; dateFinished?: Date } = { title: '', artist: '', type: MusicType.album, status: MusicStatus.wantToListen, dateStarted: undefined, dateFinished: undefined, rating: undefined, notes: '', tags: [], links: [] }; editMusicData: Partial = {}; // 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.newMusicImagePreview.set(null); this.imageError.set(null); this.newTagInput = ''; this.newLinkTitle = ''; this.newLinkUrl = ''; } 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 || [])] }; this.editMusicImagePreview.set(music.coverArt || null); this.showAddForm.set(false); this.imageError.set(null); this.editTagInput = ''; this.editLinkTitle = ''; this.editLinkUrl = ''; } 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(); } // 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.'); } } }