Files
library/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts
T
hikari 9d965808a7 feat: add primary badge selection for user profiles
Implements #49 - Allow users to select one primary badge to display
on their profile instead of showing all badges at once.

Changes:
- Add PrimaryBadge enum to Prisma schema and shared types (STAFF, MOD, VIP, DISCORD)
- Add primaryBadge field to User model and all user interfaces
- Update settings component with badge selection dropdown
- Only show badges the user actually has in the dropdown
- Update profile component to display only selected badge (or all if none selected)
- Add primaryBadge to admin profile edit modal
- Update API routes and services to handle primaryBadge
- Export PrimaryBadge enum from shared-types (not just as type)

Additional fixes:
- Fix Angular output naming: rename onEdit/onDelete to edit/delete
- Update all parent components using comment-display outputs
- Add type casting for Prisma PrimaryBadge enum to shared-types enum
2026-02-19 20:28:23 -08:00

1879 lines
56 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 { 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 (show 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, or show all</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;
}
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');
}
});
}
}
}