generated from nhcarrigan/template
1f62d64ace
Add explicit background and text color styling to select option elements to fix poor contrast that made dropdown options illegible. Changes: - Add option styling to settings component select dropdown - Add option styling to admin reports component select dropdown - Set background to match form background color - Set text color to match standard text color - Ensures dropdown options are readable on all backgrounds
1884 lines
56 KiB
TypeScript
1884 lines
56 KiB
TypeScript
/**
|
||
* @copyright 2026 NHCarrigan
|
||
* @license Naomi's Public License
|
||
* @author Naomi Carrigan
|
||
*/
|
||
|
||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||
import { CommonModule } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { 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: `
|
||
<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>
|
||
}
|
||
|
||
<!-- Profile Edit Modal -->
|
||
@if (editingProfile()) {
|
||
<div
|
||
class="modal-overlay"
|
||
(click)="closeProfileEditModal()"
|
||
(keydown.escape)="closeProfileEditModal()"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="edit-profile-modal-title"
|
||
>
|
||
<div
|
||
class="modal-content"
|
||
(click)="$event.stopPropagation()"
|
||
(keydown)="$event.stopPropagation()"
|
||
role="document"
|
||
tabindex="-1"
|
||
>
|
||
<div class="modal-header">
|
||
<h2 id="edit-profile-modal-title">Edit Profile: {{ editingProfile()!.username }}</h2>
|
||
<button
|
||
type="button"
|
||
class="modal-close"
|
||
(click)="closeProfileEditModal()"
|
||
aria-label="Close modal"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div class="modal-body">
|
||
<form (ngSubmit)="saveProfileEdit()" class="profile-edit-form">
|
||
<div class="form-section">
|
||
<h3>Profile Information</h3>
|
||
|
||
<div class="form-group">
|
||
<label for="displayName">Display Name</label>
|
||
<input
|
||
type="text"
|
||
id="displayName"
|
||
name="displayName"
|
||
[(ngModel)]="profileEditForm.displayName"
|
||
class="form-control"
|
||
placeholder="Display name"
|
||
maxlength="50"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="slug">Profile URL Slug</label>
|
||
<input
|
||
type="text"
|
||
id="slug"
|
||
name="slug"
|
||
[(ngModel)]="profileEditForm.slug"
|
||
class="form-control"
|
||
placeholder="url-slug"
|
||
maxlength="30"
|
||
pattern="[a-z0-9-]+"
|
||
/>
|
||
<small class="form-help">Only lowercase letters, numbers, and hyphens</small>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="bio">Bio</label>
|
||
<textarea
|
||
id="bio"
|
||
name="bio"
|
||
[(ngModel)]="profileEditForm.bio"
|
||
class="form-control"
|
||
rows="4"
|
||
maxlength="500"
|
||
placeholder="User bio..."
|
||
></textarea>
|
||
<small class="form-help">{{ (profileEditForm.bio.length || 0) }} / 500 characters</small>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="primaryBadge">Primary Badge</label>
|
||
<select
|
||
id="primaryBadge"
|
||
name="primaryBadge"
|
||
[(ngModel)]="profileEditForm.primaryBadge"
|
||
class="form-control"
|
||
>
|
||
<option [ngValue]="undefined">None (hide all badges)</option>
|
||
@if (editingProfile()?.profile?.badges.isStaff) {
|
||
<option [ngValue]="PrimaryBadge.STAFF">Staff</option>
|
||
}
|
||
@if (editingProfile()?.profile?.badges.isMod) {
|
||
<option [ngValue]="PrimaryBadge.MOD">Moderator</option>
|
||
}
|
||
@if (editingProfile()?.profile?.badges.isVip) {
|
||
<option [ngValue]="PrimaryBadge.VIP">VIP</option>
|
||
}
|
||
@if (editingProfile()?.profile?.badges.inDiscord) {
|
||
<option [ngValue]="PrimaryBadge.DISCORD">Discord Member</option>
|
||
}
|
||
</select>
|
||
<small class="form-help">Choose one badge to display on profile and comments</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">
|
||
<h3>Social Links</h3>
|
||
|
||
<div class="form-group">
|
||
<label for="website">Website</label>
|
||
<input
|
||
type="text"
|
||
id="website"
|
||
name="website"
|
||
[(ngModel)]="profileEditForm.website"
|
||
class="form-control"
|
||
placeholder="https://example.com"
|
||
pattern="https?://.+"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="discordServer">Discord Server</label>
|
||
<input
|
||
type="text"
|
||
id="discordServer"
|
||
name="discordServer"
|
||
[(ngModel)]="profileEditForm.discordServer"
|
||
class="form-control"
|
||
placeholder="https://discord.gg/..."
|
||
pattern="https://discord\\.gg/[a-zA-Z0-9]+"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="github">GitHub</label>
|
||
<input
|
||
type="text"
|
||
id="github"
|
||
name="github"
|
||
[(ngModel)]="profileEditForm.github"
|
||
class="form-control"
|
||
placeholder="username"
|
||
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="bluesky">Bluesky</label>
|
||
<input
|
||
type="text"
|
||
id="bluesky"
|
||
name="bluesky"
|
||
[(ngModel)]="profileEditForm.bluesky"
|
||
class="form-control"
|
||
placeholder="username.bsky.social"
|
||
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="linkedin">LinkedIn</label>
|
||
<input
|
||
type="text"
|
||
id="linkedin"
|
||
name="linkedin"
|
||
[(ngModel)]="profileEditForm.linkedin"
|
||
class="form-control"
|
||
placeholder="username"
|
||
pattern="[a-zA-Z0-9-]{3,100}"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="twitch">Twitch</label>
|
||
<input
|
||
type="text"
|
||
id="twitch"
|
||
name="twitch"
|
||
[(ngModel)]="profileEditForm.twitch"
|
||
class="form-control"
|
||
placeholder="username"
|
||
pattern="[a-zA-Z0-9_]{4,25}"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="youtube">YouTube</label>
|
||
<input
|
||
type="text"
|
||
id="youtube"
|
||
name="youtube"
|
||
[(ngModel)]="profileEditForm.youtube"
|
||
class="form-control"
|
||
placeholder="@username or channel-id"
|
||
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-actions">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary"
|
||
(click)="closeProfileEditModal()"
|
||
[disabled]="submitting()"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="btn btn-primary"
|
||
[disabled]="submitting()"
|
||
>
|
||
{{ submitting() ? 'Saving...' : 'Save Changes' }}
|
||
</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-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;
|
||
}
|
||
|
||
select.form-control option {
|
||
background: var(--witch-moon);
|
||
color: var(--witch-purple);
|
||
}
|
||
|
||
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<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);
|
||
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, 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 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');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|