/** * @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 { SuggestionService } from '../../services/suggestion.service'; import { AuthService } from '../../services/auth.service'; import { PaginationComponent } from '../shared/pagination.component'; import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-types'; @Component({ selector: 'app-admin-suggestions', standalone: true, imports: [CommonModule, FormsModule, PaginationComponent], template: `

Manage Suggestions

Review and respond to community suggestions

@if (!authService.isAdmin()) {

You don't have permission to view this page.

} @else if (loading()) {
Loading suggestions...
} @else {
@if (filteredSuggestions().length === 0) {

No suggestions found.

} @else {
@for (suggestion of paginatedSuggestions(); track suggestion.id) {
{{ getEntityIcon(suggestion.entityType) }} {{ suggestion.entityType }} {{ getStatusLabel(suggestion.status) }}

{{ suggestion.title }}

@if (suggestion.gameData) { @if (suggestion.gameData.platform) {

Platform: {{ suggestion.gameData.platform }}

} @if (suggestion.gameData.notes) {

Notes: {{ suggestion.gameData.notes }}

} @if (suggestion.gameData.coverImage) { Cover } } @if (suggestion.bookData) { @if (suggestion.bookData.author) {

Author: {{ suggestion.bookData.author }}

} @if (suggestion.bookData.isbn) {

ISBN: {{ suggestion.bookData.isbn }}

} @if (suggestion.bookData.notes) {

Notes: {{ suggestion.bookData.notes }}

} @if (suggestion.bookData.coverImage) { Cover } } @if (suggestion.musicData) { @if (suggestion.musicData.artist) {

Artist: {{ suggestion.musicData.artist }}

} @if (suggestion.musicData.type) {

Type: {{ suggestion.musicData.type }}

} @if (suggestion.musicData.notes) {

Notes: {{ suggestion.musicData.notes }}

} @if (suggestion.musicData.coverArt) { Cover } } @if (suggestion.artData) { @if (suggestion.artData.artist) {

Artist: {{ suggestion.artData.artist }}

} @if (suggestion.artData.description) {

Description: {{ suggestion.artData.description }}

} @if (suggestion.artData.imageUrl) { Artwork } } @if (suggestion.showData) { @if (suggestion.showData.type) {

Type: {{ suggestion.showData.type }}

} @if (suggestion.showData.notes) {

Notes: {{ suggestion.showData.notes }}

} @if (suggestion.showData.coverImage) { Cover } } @if (suggestion.mangaData) { @if (suggestion.mangaData.author) {

Author: {{ suggestion.mangaData.author }}

} @if (suggestion.mangaData.notes) {

Notes: {{ suggestion.mangaData.notes }}

} @if (suggestion.mangaData.coverImage) { Cover } }
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
Decline reason: {{ suggestion.declineReason }}
}
}
} } @if (showDeclineModal()) { } @if (showEditModal()) { }
`, styles: [` .container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } .header-section { margin-bottom: 2rem; } .header-section h2 { margin: 0 0 0.5rem 0; } .subtitle { color: #666; margin: 0; } .not-authorized, .loading, .empty-state { text-align: center; padding: 3rem; color: #666; background: #f8f9fa; border-radius: 8px; } .filters { display: flex; gap: 0.75rem; margin-bottom: 2rem; flex-wrap: wrap; } .filter-btn { padding: 0.5rem 1rem; background: #e5e7eb; border: none; border-radius: 4px; cursor: pointer; transition: all 0.3s; } .filter-btn:hover { background: #d1d5db; } .filter-btn.active { color: white; } .filter-btn.active, .filter-btn.active.pending { background: #f59e0b; } .filter-btn.active.accepted { background: #10b981; } .filter-btn.active.declined { background: #ef4444; } .suggestions-list { display: flex; flex-direction: column; gap: 1rem; } .suggestion-card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 1.25rem; transition: box-shadow 0.3s; } .suggestion-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .suggestion-card.status-accepted { border-left: 4px solid #10b981; } .suggestion-card.status-declined { border-left: 4px solid #ef4444; } .suggestion-card.status-unreviewed { border-left: 4px solid #f59e0b; } .suggestion-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; } .badges { display: flex; gap: 0.5rem; } .user-info { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; color: #666; } .user-avatar { width: 24px; height: 24px; border-radius: 50%; } .entity-badge, .status-badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .entity-badge { background: #e5e7eb; color: #374151; } .entity-badge.entity-game { background: #fff1f1; color: #dc2626; } .entity-badge.entity-book { background: #fdf4ed; color: #8b6f47; } .entity-badge.entity-music { background: #eff6ff; color: #2563eb; } .entity-badge.entity-manga { background: #ecfdf5; color: #059669; } .entity-badge.entity-show { background: #fdf2f8; color: #db2777; } .entity-badge.entity-art { background: #fefce8; color: #ca8a04; } .status-badge.status-unreviewed { background: #fef3c7; color: #92400e; } .status-badge.status-accepted { background: #d1fae5; color: #065f46; } .status-badge.status-declined { background: #fee2e2; color: #991b1b; } .suggestion-title { margin: 0 0 0.75rem 0; font-size: 1.1rem; } .suggestion-details { font-size: 0.9rem; color: #4b5563; } .suggestion-details p { margin: 0.25rem 0; } .suggestion-image { max-width: 150px; max-height: 200px; border-radius: 4px; margin-top: 0.5rem; border: 2px solid #e5e7eb; } .decline-reason { background: #fee2e2; border: 1px solid #fecaca; border-radius: 4px; padding: 0.75rem; margin-top: 0.75rem; font-size: 0.9rem; color: #991b1b; } .suggestion-footer { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; } .date { font-size: 0.8rem; color: #9ca3af; } .actions { display: flex; gap: 0.5rem; } .btn { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; transition: all 0.3s; } .btn-accept { background: #10b981; color: white; } .btn-accept:hover { background: #059669; } .btn-decline { background: #ef4444; color: white; } .btn-decline:hover { background: #dc2626; } .btn-secondary { background: #e5e7eb; color: #374151; } .btn-secondary:hover { background: #d1d5db; } /* Modal styles */ .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } .modal { background: white; border-radius: 8px; padding: 1.5rem; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; } .edit-modal { max-width: 650px; } .edit-modal form { margin-top: 1rem; } .modal h3 { margin: 0 0 1rem 0; } .modal p { margin: 0 0 1rem 0; color: #666; } .form-group { margin-bottom: 1rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; } .form-group textarea { width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 1rem; resize: vertical; } .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } `] }) export class AdminSuggestionsComponent implements OnInit { suggestionService = inject(SuggestionService); authService = inject(AuthService); suggestions = signal([]); loading = signal(true); statusFilter = signal<'all' | SuggestionStatus>('all'); showDeclineModal = signal(false); decliningsuggestion = signal(null); declineReason = ''; // Edit modal state showEditModal = signal(false); editingSuggestion = signal(null); editedData: any = {}; // Pagination state currentPage = signal(1); pageSize = signal(25); SuggestionStatus = SuggestionStatus; unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length; acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length; declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length; filteredSuggestions = computed(() => { const filter = this.statusFilter(); if (filter === 'all') { return this.suggestions(); } return this.suggestions().filter(s => s.status === filter); }); paginatedSuggestions = computed(() => { const suggestions = this.filteredSuggestions(); const start = (this.currentPage() - 1) * this.pageSize(); const end = start + this.pageSize(); return suggestions.slice(start, end); }); totalFilteredSuggestions = computed(() => this.filteredSuggestions().length); ngOnInit() { if (this.authService.isAdmin()) { this.loadSuggestions(); } else { this.loading.set(false); } } async loadSuggestions() { this.loading.set(true); try { const suggestions = await this.suggestionService.getAllSuggestions(); this.suggestions.set(suggestions); } catch { // Handle error silently } finally { this.loading.set(false); } } setFilter(filter: 'all' | SuggestionStatus) { this.statusFilter.set(filter); this.currentPage.set(1); // Reset to first page when filter changes } 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); } getStatusLabel(status: SuggestionStatus): string { switch (status) { case SuggestionStatus.UNREVIEWED: return 'Pending'; case SuggestionStatus.ACCEPTED: return 'Accepted'; case SuggestionStatus.DECLINED: return 'Declined'; } } getEntityIcon(entityType: SuggestionEntity): string { switch (entityType) { case SuggestionEntity.GAME: return '🎮'; case SuggestionEntity.BOOK: return '📚'; case SuggestionEntity.MUSIC: return '🎵'; case SuggestionEntity.MANGA: return '📖'; case SuggestionEntity.SHOW: return '📺'; case SuggestionEntity.ART: return '🎨'; } } formatDate(date: Date | string): string { return new Date(date).toLocaleDateString(); } async acceptSuggestion(suggestion: Suggestion) { this.editingSuggestion.set(suggestion); // Pre-populate the edit form with suggestion data this.editedData = { title: suggestion.title, notes: '', coverImage: '', coverArt: '' }; // Add entity-specific data switch (suggestion.entityType) { case 'BOOK': const bookData = suggestion.bookData as any; this.editedData.author = bookData?.author || ''; this.editedData.isbn = bookData?.isbn || ''; this.editedData.notes = bookData?.notes || ''; this.editedData.coverImage = bookData?.coverImage || ''; break; case 'GAME': const gameData = suggestion.gameData as any; this.editedData.platform = gameData?.platform || ''; this.editedData.notes = gameData?.notes || ''; this.editedData.coverImage = gameData?.coverImage || ''; break; case 'MUSIC': const musicData = suggestion.musicData as any; this.editedData.artist = musicData?.artist || ''; this.editedData.type = musicData?.type || 'ALBUM'; this.editedData.notes = musicData?.notes || ''; this.editedData.coverArt = musicData?.coverArt || ''; break; case 'ART': const artData = suggestion.artData as any; this.editedData.artist = artData?.artist || ''; this.editedData.description = artData?.description || ''; this.editedData.imageUrl = artData?.imageUrl || ''; break; case 'SHOW': const showData = suggestion.showData as any; this.editedData.type = showData?.type || 'TV_SERIES'; this.editedData.notes = showData?.notes || ''; this.editedData.coverImage = showData?.coverImage || ''; break; case 'MANGA': const mangaData = suggestion.mangaData as any; this.editedData.author = mangaData?.author || ''; this.editedData.notes = mangaData?.notes || ''; this.editedData.coverImage = mangaData?.coverImage || ''; break; } this.showEditModal.set(true); } openDeclineModal(suggestion: Suggestion) { this.decliningsuggestion.set(suggestion); this.declineReason = ''; this.showDeclineModal.set(true); } closeDeclineModal() { this.showDeclineModal.set(false); this.decliningsuggestion.set(null); this.declineReason = ''; } closeEditModal() { this.showEditModal.set(false); this.editingSuggestion.set(null); this.editedData = {}; } async confirmAcceptWithEdits() { const suggestion = this.editingSuggestion(); if (!suggestion) return; try { await this.suggestionService.acceptSuggestionWithEdits(suggestion.id, this.editedData); alert(`"${this.editedData.title}" has been added to your collection with your edits!`); this.closeEditModal(); this.loadSuggestions(); } catch (error) { alert('Failed to accept suggestion. Please try again.'); } } async confirmDecline() { const suggestion = this.decliningsuggestion(); if (!suggestion) return; try { await this.suggestionService.declineSuggestion(suggestion.id, { reason: this.declineReason || undefined }); this.closeDeclineModal(); this.loadSuggestions(); } catch { alert('Failed to decline suggestion. Please try again.'); } } }