Files
library/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts
T
naomi 8837055e97 feat: add admin action buttons to report review modals
Added admin-specific action buttons in the report review modals:
- Comment reports: Edit Comment and Delete Comment buttons
- Profile reports: Edit Profile and Make Private buttons

Admin actions include:
- Edit comment content directly from the modal
- Delete reported comments with confirmation
- Navigate to profile edit page
- Make reported profiles private

Methods are stubbed with TODOs for full API integration.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 19:37:19 -08:00

1527 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 { AuthService } from '../../services/auth.service';
import { ToastService } from '../../services/toast.service';
import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason } from '@library/shared-types';
@Component({
selector: 'app-admin-reports',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="admin-reports-wrapper">
<div class="admin-reports-container">
<div class="header-section">
<h1>Reports</h1>
<div class="report-type-toggle">
<button
type="button"
class="toggle-btn"
[class.active]="reportType() === 'profile'"
(click)="setReportType('profile')"
>
Profile Reports
</button>
<button
type="button"
class="toggle-btn"
[class.active]="reportType() === 'comment'"
(click)="setReportType('comment')"
>
Comment Reports
</button>
</div>
</div>
@if (loading()) {
<div class="loading" role="status" aria-live="polite">
<p>Loading reports...</p>
</div>
} @else if (error()) {
<div class="error" role="alert" aria-live="assertive">
<p>{{ error() }}</p>
</div>
} @else {
<!-- Filter Tabs -->
<div class="filter-tabs" role="tablist" aria-label="Report status filters">
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === 'all'"
[attr.aria-selected]="activeFilter() === 'all'"
(click)="setFilter('all')"
>
All ({{ reportType() === 'profile' ? allProfileReports().length : allCommentReports().length }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.PENDING"
[attr.aria-selected]="activeFilter() === ReportStatus.PENDING"
(click)="setFilter(ReportStatus.PENDING)"
>
Pending ({{ pendingCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.REVIEWED"
[attr.aria-selected]="activeFilter() === ReportStatus.REVIEWED"
(click)="setFilter(ReportStatus.REVIEWED)"
>
Reviewed ({{ reviewedCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.DISMISSED"
[attr.aria-selected]="activeFilter() === ReportStatus.DISMISSED"
(click)="setFilter(ReportStatus.DISMISSED)"
>
Dismissed ({{ dismissedCount() }})
</button>
<button
type="button"
role="tab"
class="tab"
[class.active]="activeFilter() === ReportStatus.ACTION_TAKEN"
[attr.aria-selected]="activeFilter() === ReportStatus.ACTION_TAKEN"
(click)="setFilter(ReportStatus.ACTION_TAKEN)"
>
Action Taken ({{ actionTakenCount() }})
</button>
</div>
<!-- Reports List -->
<div class="reports-list" role="region" aria-label="Reports list">
@if (reportType() === 'profile') {
@for (report of filteredProfileReports(); track report.id) {
<div class="report-card">
<!-- Report Header -->
<div class="report-header">
<div class="user-info">
<div class="user-section">
<h3>Reported User</h3>
<div class="user-details">
@if (report.reportedUser.avatar) {
<img
[src]="report.reportedUser.avatar"
[alt]="report.reportedUser.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reportedUser.username + ' avatar'">
{{ report.reportedUser.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reportedUser.username }}</span>
@if (report.reportedUser.displayName) {
<span class="display-name">({{ report.reportedUser.displayName }})</span>
}
</div>
</div>
</div>
<div class="user-section">
<h3>Reporter</h3>
<div class="user-details">
@if (report.reporter.avatar) {
<img
[src]="report.reporter.avatar"
[alt]="report.reporter.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reporter.username + ' avatar'">
{{ report.reporter.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reporter.username }}</span>
@if (report.reporter.displayName) {
<span class="display-name">({{ report.reporter.displayName }})</span>
}
</div>
</div>
</div>
</div>
<div class="report-meta">
<span
class="status-badge"
[class.pending]="report.status === 'PENDING'"
[class.reviewed]="report.status === 'REVIEWED'"
[class.dismissed]="report.status === 'DISMISSED'"
[class.action-taken]="report.status === 'ACTION_TAKEN'"
[attr.aria-label]="'Status: ' + formatStatus(report.status)"
>
{{ formatStatus(report.status) }}
</span>
<span class="report-date">
{{ formatDate(report.createdAt) }}
</span>
</div>
</div>
<!-- Report Content -->
<div class="report-content">
<div class="reason">
<strong>Reason:</strong> {{ formatReason(report.reason) }}
</div>
<div class="details">
<strong>Details:</strong>
<p>{{ report.details }}</p>
</div>
@if (report.reviewNotes) {
<div class="review-notes">
<strong>Review Notes:</strong>
<p>{{ report.reviewNotes }}</p>
@if (report.reviewer) {
<small class="reviewer">
Reviewed by {{ report.reviewer.username }}
</small>
}
</div>
}
</div>
<!-- Report Actions -->
<div class="report-actions">
<button
type="button"
class="btn btn-review"
(click)="openProfileReviewModal(report)"
[attr.aria-label]="'Review report for ' + report.reportedUser.username"
>
Review
</button>
</div>
</div>
} @empty {
<div class="empty-state">
<p>No profile reports found.</p>
</div>
}
} @else {
@for (report of filteredCommentReports(); track report.id) {
<div class="report-card">
<!-- Report Header -->
<div class="report-header">
<div class="user-info">
<div class="user-section">
<h3>Comment Author</h3>
<div class="user-details">
@if (report.reportedComment.user.avatar) {
<img
[src]="report.reportedComment.user.avatar"
[alt]="report.reportedComment.user.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reportedComment.user.username + ' avatar'">
{{ report.reportedComment.user.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reportedComment.user.username }}</span>
</div>
</div>
</div>
<div class="user-section">
<h3>Reporter</h3>
<div class="user-details">
@if (report.reporter.avatar) {
<img
[src]="report.reporter.avatar"
[alt]="report.reporter.username + ' avatar'"
class="avatar"
/>
} @else {
<div class="avatar-placeholder" [attr.aria-label]="report.reporter.username + ' avatar'">
{{ report.reporter.username.charAt(0).toUpperCase() }}
</div>
}
<div class="user-names">
<span class="username">{{ report.reporter.username }}</span>
@if (report.reporter.displayName) {
<span class="display-name">({{ report.reporter.displayName }})</span>
}
</div>
</div>
</div>
</div>
<div class="report-meta">
<span
class="status-badge"
[class.pending]="report.status === 'PENDING'"
[class.reviewed]="report.status === 'REVIEWED'"
[class.dismissed]="report.status === 'DISMISSED'"
[class.action-taken]="report.status === 'ACTION_TAKEN'"
[attr.aria-label]="'Status: ' + formatStatus(report.status)"
>
{{ formatStatus(report.status) }}
</span>
<span class="report-date">
{{ formatDate(report.createdAt) }}
</span>
</div>
</div>
<!-- Report Content -->
<div class="report-content">
<div class="comment-content-preview">
<strong>Comment:</strong>
<p class="comment-text">{{ truncateComment(report.reportedComment.content) }}</p>
</div>
<div class="reason">
<strong>Reason:</strong> {{ formatReason(report.reason) }}
</div>
<div class="details">
<strong>Details:</strong>
<p>{{ report.details }}</p>
</div>
@if (report.reviewNotes) {
<div class="review-notes">
<strong>Review Notes:</strong>
<p>{{ report.reviewNotes }}</p>
@if (report.reviewer) {
<small class="reviewer">
Reviewed by {{ report.reviewer.username }}
</small>
}
</div>
}
</div>
<!-- Report Actions -->
<div class="report-actions">
<button
type="button"
class="btn btn-review"
(click)="openCommentReviewModal(report)"
[attr.aria-label]="'Review report for comment by ' + report.reportedComment.user.username"
>
Review
</button>
</div>
</div>
} @empty {
<div class="empty-state">
<p>No comment reports found.</p>
</div>
}
}
</div>
}
</div>
<!-- Profile Review Modal -->
@if (reviewingProfileReport()) {
<div
class="modal-overlay"
(click)="closeProfileReviewModal()"
(keydown.escape)="closeProfileReviewModal()"
role="dialog"
aria-modal="true"
aria-labelledby="profile-modal-title"
>
<div
class="modal-content"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="document"
tabindex="-1"
>
<div class="modal-header">
<h2 id="profile-modal-title">Review Profile Report</h2>
<button
type="button"
class="modal-close"
(click)="closeProfileReviewModal()"
aria-label="Close modal"
>
×
</button>
</div>
<div class="modal-body">
<!-- Report Summary -->
<div class="review-summary">
<div class="summary-item">
<strong>Reported User:</strong>
<span>{{ reviewingProfileReport()!.reportedUser.username }}</span>
</div>
<div class="summary-item">
<strong>Reporter:</strong>
<span>{{ reviewingProfileReport()!.reporter.username }}</span>
</div>
<div class="summary-item">
<strong>Reason:</strong>
<span>{{ formatReason(reviewingProfileReport()!.reason) }}</span>
</div>
<div class="summary-item">
<strong>Reported On:</strong>
<span>{{ formatDate(reviewingProfileReport()!.createdAt) }}</span>
</div>
</div>
<div class="details-section">
<strong>Details:</strong>
<p class="details-text">{{ reviewingProfileReport()!.details }}</p>
</div>
<!-- Admin Actions for Profile -->
<div class="admin-actions">
<strong>Admin Actions:</strong>
<div class="action-buttons">
<button
type="button"
class="btn btn-warning btn-sm"
(click)="editProfile(reviewingProfileReport()!)"
>
Edit Profile
</button>
<button
type="button"
class="btn btn-danger btn-sm"
(click)="makeProfilePrivate(reviewingProfileReport()!)"
>
Make Private
</button>
</div>
</div>
<!-- Review Form -->
<form (ngSubmit)="submitProfileReview()" class="review-form">
<div class="form-group">
<label for="status">Update Status</label>
<select
id="status"
name="status"
[(ngModel)]="reviewForm.status"
class="form-control"
required
aria-required="true"
>
<option value="PENDING">Pending</option>
<option value="REVIEWED">Reviewed</option>
<option value="DISMISSED">Dismissed</option>
<option value="ACTION_TAKEN">Action Taken</option>
</select>
</div>
<div class="form-group">
<label for="reviewNotes">Review Notes (Optional)</label>
<textarea
id="reviewNotes"
name="reviewNotes"
[(ngModel)]="reviewForm.reviewNotes"
class="form-control"
rows="4"
placeholder="Add any notes about your review..."
></textarea>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
(click)="closeProfileReviewModal()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="submitting()"
>
{{ submitting() ? 'Submitting...' : 'Submit Review' }}
</button>
</div>
</form>
</div>
</div>
</div>
}
<!-- Comment Review Modal -->
@if (reviewingCommentReport()) {
<div
class="modal-overlay"
(click)="closeCommentReviewModal()"
(keydown.escape)="closeCommentReviewModal()"
role="dialog"
aria-modal="true"
aria-labelledby="comment-modal-title"
>
<div
class="modal-content"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="document"
tabindex="-1"
>
<div class="modal-header">
<h2 id="comment-modal-title">Review Comment Report</h2>
<button
type="button"
class="modal-close"
(click)="closeCommentReviewModal()"
aria-label="Close modal"
>
×
</button>
</div>
<div class="modal-body">
<!-- Report Summary -->
<div class="review-summary">
<div class="summary-item">
<strong>Comment Author:</strong>
<span>{{ reviewingCommentReport()!.reportedComment.user.username }}</span>
</div>
<div class="summary-item">
<strong>Reporter:</strong>
<span>{{ reviewingCommentReport()!.reporter.username }}</span>
</div>
<div class="summary-item">
<strong>Reason:</strong>
<span>{{ formatReason(reviewingCommentReport()!.reason) }}</span>
</div>
<div class="summary-item">
<strong>Reported On:</strong>
<span>{{ formatDate(reviewingCommentReport()!.createdAt) }}</span>
</div>
</div>
<div class="comment-full-content">
<strong>Full Comment:</strong>
<p class="comment-text">{{ reviewingCommentReport()!.reportedComment.content }}</p>
</div>
<div class="details-section">
<strong>Report Details:</strong>
<p class="details-text">{{ reviewingCommentReport()!.details }}</p>
</div>
<!-- Admin Actions for Comment -->
<div class="admin-actions">
<strong>Admin Actions:</strong>
<div class="action-buttons">
<button
type="button"
class="btn btn-warning btn-sm"
(click)="editComment(reviewingCommentReport()!)"
>
Edit Comment
</button>
<button
type="button"
class="btn btn-danger btn-sm"
(click)="deleteComment(reviewingCommentReport()!)"
>
Delete Comment
</button>
</div>
</div>
<!-- Review Form -->
<form (ngSubmit)="submitCommentReview()" class="review-form">
<div class="form-group">
<label for="comment-status">Update Status</label>
<select
id="comment-status"
name="status"
[(ngModel)]="reviewForm.status"
class="form-control"
required
aria-required="true"
>
<option value="PENDING">Pending</option>
<option value="REVIEWED">Reviewed</option>
<option value="DISMISSED">Dismissed</option>
<option value="ACTION_TAKEN">Action Taken</option>
</select>
</div>
<div class="form-group">
<label for="comment-reviewNotes">Review Notes (Optional)</label>
<textarea
id="comment-reviewNotes"
name="reviewNotes"
[(ngModel)]="reviewForm.reviewNotes"
class="form-control"
rows="4"
placeholder="Add any notes about your review..."
></textarea>
</div>
<div class="modal-actions">
<button
type="button"
class="btn btn-secondary"
(click)="closeCommentReviewModal()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="submitting()"
>
{{ submitting() ? 'Submitting...' : 'Submit Review' }}
</button>
</div>
</form>
</div>
</div>
</div>
}
</div>
`,
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-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 authService = inject(AuthService);
private toastService = inject(ToastService);
private router = inject(Router);
// Make ReportStatus accessible in template
protected readonly ReportStatus = ReportStatus;
reportType = signal<'profile' | 'comment'>('profile');
allProfileReports = signal<ProfileReportWithUsers[]>([]);
allCommentReports = signal<CommentReportWithDetails[]>([]);
loading = signal(true);
error = signal<string | null>(null);
activeFilter = signal<'all' | ReportStatus>('all');
reviewingProfileReport = signal<ProfileReportWithUsers | null>(null);
reviewingCommentReport = signal<CommentReportWithDetails | null>(null);
submitting = signal(false);
reviewForm = {
status: ReportStatus.PENDING,
reviewNotes: ''
};
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, string> = {
[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, string> = {
[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 newContent = prompt('Edit comment content:', report.reportedComment.content);
if (newContent !== null && newContent.trim() !== '') {
// TODO: Implement comment editing API call
this.toastService.success('Comment edit functionality coming soon');
}
}
deleteComment(report: CommentReportWithDetails): void {
const commentAuthor = report.reportedComment.user.username;
const confirmed = confirm(`Are you sure you want to delete this comment by ${commentAuthor}?`);
if (confirmed) {
// TODO: Implement comment deletion API call
this.toastService.success('Comment delete functionality coming soon');
}
}
editProfile(report: ProfileReportWithUsers): void {
const userId = report.reportedUser.id;
const username = report.reportedUser.username;
// Navigate to profile edit page or open edit modal
this.router.navigate(['/profile', username]);
this.toastService.success('Navigate to profile to edit');
}
makeProfilePrivate(report: ProfileReportWithUsers): void {
const username = report.reportedUser.username;
const confirmed = confirm(`Are you sure you want to make ${username}'s profile private?`);
if (confirmed) {
// TODO: Implement make profile private API call
this.toastService.success('Profile privacy functionality coming soon');
}
}
}