/** * @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 { Router } from '@angular/router'; import { ReportService } from '../../services/report.service'; import { CommentReportService } from '../../services/comment-report.service'; import { CommentsService } from '../../services/comments.service'; import { UserService } from '../../services/user.service'; import { AuthService } from '../../services/auth.service'; import { ToastService } from '../../services/toast.service'; import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason, PrimaryBadge } from '@library/shared-types'; @Component({ selector: 'app-admin-reports', standalone: true, imports: [CommonModule, FormsModule], template: `

Reports

@if (loading()) {

Loading reports...

} @else if (error()) { } @else {
@if (reportType() === 'profile') { @for (report of filteredProfileReports(); track report.id) {
{{ formatStatus(report.status) }} {{ formatDate(report.createdAt) }}
Reason: {{ formatReason(report.reason) }}
Details:

{{ report.details }}

@if (report.reviewNotes) {
Review Notes:

{{ report.reviewNotes }}

@if (report.reviewer) { Reviewed by {{ report.reviewer.username }} }
}
} @empty {

No profile reports found.

} } @else { @for (report of filteredCommentReports(); track report.id) {
{{ formatStatus(report.status) }} {{ formatDate(report.createdAt) }}
Comment:

{{ truncateComment(report.reportedComment.content) }}

Reason: {{ formatReason(report.reason) }}
Details:

{{ report.details }}

@if (report.reviewNotes) {
Review Notes:

{{ report.reviewNotes }}

@if (report.reviewer) { Reviewed by {{ report.reviewer.username }} }
}
} @empty {

No comment reports found.

} }
}
@if (reviewingProfileReport()) { } @if (reviewingCommentReport()) { } @if (editingProfile()) { }
`, styles: [` .admin-reports-wrapper { position: relative; } .admin-reports-container { max-width: 1200px; margin: 0 auto; padding: 2rem; } .header-section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem; } h1 { color: var(--witch-purple); margin: 0; font-size: 2rem; } .report-type-toggle { display: flex; gap: 0.5rem; background: var(--witch-lavender); padding: 0.25rem; border-radius: 12px; } .toggle-btn { padding: 0.75rem 1.5rem; background: transparent; border: none; border-radius: 8px; color: var(--witch-mauve); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.3s; } .toggle-btn:hover { color: var(--witch-purple); } .toggle-btn.active { background: var(--witch-purple); color: var(--witch-moon); font-weight: 600; } .toggle-btn:focus { outline: 2px solid var(--witch-purple); outline-offset: 2px; } .loading, .error, .empty-state { text-align: center; padding: 3rem 2rem; color: var(--witch-mauve); font-size: 1.1rem; } .error { color: var(--witch-rose); } /* Filter Tabs */ .filter-tabs { display: flex; gap: 0.5rem; margin-bottom: 2rem; flex-wrap: wrap; border-bottom: 2px solid var(--witch-lavender); padding-bottom: 0.5rem; } .tab { padding: 0.75rem 1.5rem; background: transparent; border: none; border-radius: 8px 8px 0 0; color: var(--witch-mauve); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.3s; position: relative; } .tab:hover { background: var(--witch-lavender); color: var(--witch-purple); } .tab.active { background: var(--witch-purple); color: var(--witch-moon); font-weight: 600; } .tab:focus { outline: 2px solid var(--witch-purple); outline-offset: 2px; } /* Reports List */ .reports-list { display: flex; flex-direction: column; gap: 1.5rem; } .report-card { background: var(--witch-moon); border-radius: 12px; padding: 1.5rem; box-shadow: 0 4px 12px var(--witch-shadow); transition: all 0.3s; } .report-card:hover { box-shadow: 0 6px 16px var(--witch-shadow); transform: translateY(-2px); } /* Report Header */ .report-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--witch-lavender); flex-wrap: wrap; gap: 1rem; } .user-info { display: flex; gap: 2rem; flex-wrap: wrap; flex: 1; } .user-section h3 { font-size: 0.85rem; color: var(--witch-mauve); margin-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.5px; } .user-details { display: flex; align-items: center; gap: 0.75rem; } .avatar { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; border: 2px solid var(--witch-lavender); } .avatar-placeholder { width: 40px; height: 40px; border-radius: 50%; background: var(--witch-purple); color: var(--witch-moon); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 1rem; border: 2px solid var(--witch-lavender); } .user-names { display: flex; flex-direction: column; gap: 0.15rem; } .username { font-weight: 600; color: var(--witch-purple); font-size: 0.95rem; } .display-name { font-size: 0.85rem; color: var(--witch-mauve); } .report-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem; } .status-badge { padding: 0.4rem 0.9rem; border-radius: 20px; font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } .status-badge.pending { background: #ffd93d; color: #1a1a1a; } .status-badge.reviewed { background: #6ab7ff; color: #1a1a1a; } .status-badge.dismissed { background: #b0b0b0; color: #1a1a1a; } .status-badge.action-taken { background: #6bcf7f; color: #1a1a1a; } .report-date { font-size: 0.85rem; color: var(--witch-mauve); } /* Report Content */ .report-content { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; } .reason, .details, .review-notes { color: var(--witch-purple); } .reason strong, .details strong, .review-notes strong { display: block; margin-bottom: 0.5rem; color: var(--witch-purple); font-weight: 600; } .details p, .review-notes p { color: var(--witch-mauve); line-height: 1.6; margin: 0; } .reviewer { display: block; margin-top: 0.5rem; color: var(--witch-mauve); font-style: italic; } .comment-content-preview { padding: 1rem; background: var(--witch-lavender); border-radius: 8px; margin-bottom: 1rem; } .comment-content-preview strong { display: block; margin-bottom: 0.5rem; color: var(--witch-purple); font-weight: 600; } .comment-text { color: var(--witch-mauve); line-height: 1.6; margin: 0; word-wrap: break-word; } .comment-full-content { margin-bottom: 1.5rem; padding: 1rem; background: var(--witch-lavender); border-radius: 8px; } .comment-full-content strong { display: block; margin-bottom: 0.5rem; color: var(--witch-purple); font-weight: 600; } /* Report Actions */ .report-actions { display: flex; justify-content: flex-end; gap: 0.75rem; } .btn { padding: 0.65rem 1.5rem; border: none; border-radius: 8px; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.3s; } .btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 4px 8px var(--witch-shadow); } .btn:focus { outline: 2px solid var(--witch-purple); outline-offset: 2px; } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .btn-review { background: var(--witch-purple); color: var(--witch-moon); } .btn-review:hover { background: var(--witch-mauve); } /* Modal */ .modal-overlay { position: fixed !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; width: 100vw !important; height: 100vh !important; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; z-index: 9999 !important; padding: 1rem; margin: 0 !important; transform: none !important; } .modal-content { background: var(--witch-moon); border-radius: 16px; max-width: 600px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px var(--witch-shadow); } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 1px solid var(--witch-lavender); } .modal-header h2 { color: var(--witch-purple); margin: 0; font-size: 1.5rem; } .modal-close { background: none; border: none; font-size: 2rem; color: var(--witch-mauve); cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; transition: all 0.3s; } .modal-close:hover { background: var(--witch-lavender); color: var(--witch-purple); } .modal-close:focus { outline: 2px solid var(--witch-purple); outline-offset: 2px; } .modal-body { padding: 1.5rem; } /* Review Summary */ .review-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; background: var(--witch-lavender); border-radius: 8px; } .summary-item { display: flex; flex-direction: column; gap: 0.25rem; } .summary-item strong { color: var(--witch-purple); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; } .summary-item span { color: var(--witch-mauve); font-size: 0.95rem; } .details-section { margin-bottom: 1.5rem; } .details-section strong { display: block; margin-bottom: 0.5rem; color: var(--witch-purple); font-weight: 600; } .details-text { color: var(--witch-mauve); line-height: 1.6; padding: 1rem; background: var(--witch-lavender); border-radius: 8px; margin: 0; } /* Admin Actions */ .admin-actions { margin-bottom: 1.5rem; padding: 1rem; background: var(--witch-lavender); border-radius: 8px; } .admin-actions strong { display: block; margin-bottom: 0.75rem; color: var(--witch-purple); font-weight: 600; } .action-buttons { display: flex; gap: 0.75rem; flex-wrap: wrap; } .btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; } .btn-warning { background: #f59e0b; color: white; } .btn-warning:hover:not(:disabled) { background: #d97706; } .btn-danger { background: #ef4444; color: white; } .btn-danger:hover:not(:disabled) { background: #dc2626; } /* Review Form */ .review-form { display: flex; flex-direction: column; gap: 1.5rem; } .form-group { display: flex; flex-direction: column; gap: 0.5rem; } .form-group label { color: var(--witch-purple); font-weight: 600; font-size: 0.95rem; } .form-help { font-size: 0.85rem; color: var(--witch-mauve); font-style: italic; } .form-section { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--witch-lavender); } .form-section:last-of-type { border-bottom: none; padding-bottom: 0; } .form-section h3 { color: var(--witch-purple); margin-bottom: 1rem; font-size: 1.1rem; } .form-control { padding: 0.75rem; border: 2px solid var(--witch-lavender); border-radius: 8px; background: var(--witch-moon); color: var(--witch-purple); font-size: 1rem; font-family: inherit; transition: all 0.3s; } .form-control:focus { outline: none; border-color: var(--witch-purple); box-shadow: 0 0 0 3px rgba(155, 89, 182, 0.2); } select.form-control { cursor: pointer; } textarea.form-control { resize: vertical; min-height: 100px; } .modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; padding-top: 1rem; border-top: 1px solid var(--witch-lavender); } .btn-secondary { background: var(--witch-lavender); color: var(--witch-purple); } .btn-secondary:hover:not(:disabled) { background: var(--witch-mauve); color: var(--witch-moon); } .btn-primary { background: var(--witch-purple); color: var(--witch-moon); } .btn-primary:hover:not(:disabled) { background: var(--witch-mauve); } /* Responsive Design */ @media (max-width: 768px) { .admin-reports-container { padding: 1rem; } h1 { font-size: 1.5rem; } .filter-tabs { flex-direction: column; } .tab { text-align: left; } .report-header { flex-direction: column; } .user-info { flex-direction: column; gap: 1rem; } .report-meta { align-items: flex-start; } .review-summary { grid-template-columns: 1fr; } .modal-actions { flex-direction: column-reverse; } .btn { width: 100%; } } `] }) export class AdminReportsComponent implements OnInit { private reportService = inject(ReportService); private commentReportService = inject(CommentReportService); private commentsService = inject(CommentsService); private userService = inject(UserService); private authService = inject(AuthService); private toastService = inject(ToastService); private router = inject(Router); // Make ReportStatus and PrimaryBadge accessible in template protected readonly ReportStatus = ReportStatus; protected readonly PrimaryBadge = PrimaryBadge; reportType = signal<'profile' | 'comment'>('profile'); allProfileReports = signal([]); allCommentReports = signal([]); loading = signal(true); error = signal(null); activeFilter = signal<'all' | ReportStatus>('all'); reviewingProfileReport = signal(null); reviewingCommentReport = signal(null); editingProfile = signal<{ userId: string; username: string; profile: any } | null>(null); submitting = signal(false); reviewForm = { status: ReportStatus.PENDING, reviewNotes: '' }; profileEditForm = { displayName: '', slug: '', bio: '', profilePublic: true, primaryBadge: undefined as PrimaryBadge | undefined, website: '', discordServer: '', bluesky: '', github: '', linkedin: '', twitch: '', youtube: '' }; filteredProfileReports = computed(() => { const filter = this.activeFilter(); if (filter === 'all') { return this.allProfileReports(); } return this.allProfileReports().filter(report => report.status === filter); }); filteredCommentReports = computed(() => { const filter = this.activeFilter(); if (filter === 'all') { return this.allCommentReports(); } return this.allCommentReports().filter(report => report.status === filter); }); pendingCount = computed(() => { if (this.reportType() === 'profile') { return this.allProfileReports().filter(r => r.status === ReportStatus.PENDING).length; } return this.allCommentReports().filter(r => r.status === ReportStatus.PENDING).length; }); reviewedCount = computed(() => { if (this.reportType() === 'profile') { return this.allProfileReports().filter(r => r.status === ReportStatus.REVIEWED).length; } return this.allCommentReports().filter(r => r.status === ReportStatus.REVIEWED).length; }); dismissedCount = computed(() => { if (this.reportType() === 'profile') { return this.allProfileReports().filter(r => r.status === ReportStatus.DISMISSED).length; } return this.allCommentReports().filter(r => r.status === ReportStatus.DISMISSED).length; }); actionTakenCount = computed(() => { if (this.reportType() === 'profile') { return this.allProfileReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length; } return this.allCommentReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length; }); ngOnInit(): void { if (!this.authService.isAdmin()) { this.router.navigate(['/']); return; } this.loadReports(); } private loadReports(): void { this.loading.set(true); this.error.set(null); if (this.reportType() === 'profile') { this.reportService.getAllReports().subscribe({ next: (reports) => { this.allProfileReports.set(reports); this.loading.set(false); }, error: (err) => { this.error.set(err.message ?? 'Failed to load profile reports'); this.loading.set(false); this.toastService.error('Failed to load profile reports'); } }); } else { this.commentReportService.getAllReports().subscribe({ next: (reports) => { this.allCommentReports.set(reports); this.loading.set(false); }, error: (err) => { this.error.set(err.message ?? 'Failed to load comment reports'); this.loading.set(false); this.toastService.error('Failed to load comment reports'); } }); } } setReportType(type: 'profile' | 'comment'): void { this.reportType.set(type); this.activeFilter.set('all'); this.loadReports(); } setFilter(filter: 'all' | ReportStatus): void { this.activeFilter.set(filter); } openProfileReviewModal(report: ProfileReportWithUsers): void { this.reviewingProfileReport.set(report); this.reviewForm = { status: report.status, reviewNotes: report.reviewNotes || '' }; } closeProfileReviewModal(): void { if (!this.submitting()) { this.reviewingProfileReport.set(null); this.reviewForm = { status: ReportStatus.PENDING, reviewNotes: '' }; } } submitProfileReview(): void { const report = this.reviewingProfileReport(); if (!report) { return; } this.submitting.set(true); this.reportService.updateReport( report.id, this.reviewForm.status, this.reviewForm.reviewNotes || undefined ).subscribe({ next: (updatedReport) => { this.allProfileReports.update(reports => reports.map(r => r.id === updatedReport.id ? updatedReport : r) ); this.toastService.success('Profile report updated successfully'); this.submitting.set(false); this.closeProfileReviewModal(); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to update profile report'); this.submitting.set(false); } }); } openCommentReviewModal(report: CommentReportWithDetails): void { this.reviewingCommentReport.set(report); this.reviewForm = { status: report.status, reviewNotes: report.reviewNotes || '' }; } closeCommentReviewModal(): void { if (!this.submitting()) { this.reviewingCommentReport.set(null); this.reviewForm = { status: ReportStatus.PENDING, reviewNotes: '' }; } } submitCommentReview(): void { const report = this.reviewingCommentReport(); if (!report) { return; } this.submitting.set(true); this.commentReportService.updateReport(report.id, { status: this.reviewForm.status, reviewNotes: this.reviewForm.reviewNotes || undefined }).subscribe({ next: (updatedReport) => { this.allCommentReports.update(reports => reports.map(r => r.id === updatedReport.id ? updatedReport : r) ); this.toastService.success('Comment report updated successfully'); this.submitting.set(false); this.closeCommentReviewModal(); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to update comment report'); this.submitting.set(false); } }); } truncateComment(content: string, maxLength = 200): string { const stripped = content.replace(/<[^>]*>/g, ''); if (stripped.length <= maxLength) { return stripped; } return stripped.substring(0, maxLength) + '...'; } formatStatus(status: ReportStatus): string { const statusMap: Record = { [ReportStatus.PENDING]: 'Pending', [ReportStatus.REVIEWED]: 'Reviewed', [ReportStatus.DISMISSED]: 'Dismissed', [ReportStatus.ACTION_TAKEN]: 'Action Taken' }; return statusMap[status]; } formatReason(reason: ReportReason): string { const reasonMap: Record = { [ReportReason.INAPPROPRIATE_CONTENT]: 'Inappropriate Content', [ReportReason.HARASSMENT]: 'Harassment', [ReportReason.SPAM]: 'Spam', [ReportReason.IMPERSONATION]: 'Impersonation', [ReportReason.OFFENSIVE_NAME]: 'Offensive Name', [ReportReason.MALICIOUS_LINKS]: 'Malicious Links', [ReportReason.OTHER]: 'Other' }; return reasonMap[reason]; } formatDate(date: Date): string { return new Date(date).toLocaleString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } editComment(report: CommentReportWithDetails): void { const commentId = report.reportedComment.id; const currentContent = report.reportedComment.rawContent || report.reportedComment.content; const newContent = prompt('Edit comment content:', currentContent); if (newContent !== null && newContent.trim() !== '') { this.commentsService.adminUpdateComment(commentId, newContent).subscribe({ next: () => { this.toastService.success('Comment updated successfully'); this.loadReports(); this.closeCommentReviewModal(); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to update comment'); } }); } } deleteComment(report: CommentReportWithDetails): void { const commentId = report.reportedComment.id; const commentAuthor = report.reportedComment.user.username; const confirmed = confirm(`Are you sure you want to delete this comment by ${commentAuthor}?`); if (confirmed) { this.commentsService.adminDeleteComment(commentId).subscribe({ next: () => { this.toastService.success('Comment deleted successfully'); this.loadReports(); this.closeCommentReviewModal(); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to delete comment'); } }); } } editProfile(report: ProfileReportWithUsers): void { const userId = report.reportedUser.id; const username = report.reportedUser.username; // Load the full profile first to get current values this.userService.getProfile(userId).subscribe({ next: (profile) => { // Populate the edit form with current values this.profileEditForm = { displayName: profile.displayName || '', slug: profile.slug || '', bio: profile.bio || '', profilePublic: true, // We'll get this from the full user object if needed primaryBadge: profile.primaryBadge || undefined, website: profile.website || '', discordServer: profile.discordServer || '', bluesky: profile.bluesky || '', github: profile.github || '', linkedin: profile.linkedin || '', twitch: profile.twitch || '', youtube: profile.youtube || '' }; // Open the edit modal this.editingProfile.set({ userId, username, profile }); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to load profile'); } }); } closeProfileEditModal(): void { this.editingProfile.set(null); this.profileEditForm = { displayName: '', slug: '', bio: '', profilePublic: true, primaryBadge: undefined as PrimaryBadge | undefined, website: '', discordServer: '', bluesky: '', github: '', linkedin: '', twitch: '', youtube: '' }; } saveProfileEdit(): void { const editing = this.editingProfile(); if (!editing) return; this.submitting.set(true); this.userService.adminUpdateUser(editing.userId, this.profileEditForm).subscribe({ next: () => { this.toastService.success('Profile updated successfully'); this.loadReports(); this.closeProfileEditModal(); this.closeProfileReviewModal(); this.submitting.set(false); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to update profile'); this.submitting.set(false); } }); } makeProfilePrivate(report: ProfileReportWithUsers): void { const userId = report.reportedUser.id; const username = report.reportedUser.username; const confirmed = confirm(`Are you sure you want to make ${username}'s profile private?`); if (confirmed) { this.userService.makeProfilePrivate(userId).subscribe({ next: () => { this.toastService.success(`${username}'s profile has been made private`); this.loadReports(); this.closeProfileReviewModal(); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to update profile privacy'); } }); } } }