Files
library/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts
T
naomi 86404497f0
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s
feat: implement user profiles with achievements and primary badge system (#58)
## Summary

This PR implements comprehensive user profile enhancements including:
- User profile pages showing stats, badges, social links, and bio
- Achievement system with 62 achievements across 5 categories
- Primary badge selection allowing users to display their preferred badge
- Admin profile editing capabilities

## Changes

### User Profiles (#45)
- **Frontend**: User profile pages with stats display
  - Profile cards showing avatar, display name, username, and bio
  - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord)
  - Stats display (suggestions, accepted suggestions, likes, comments)
  - Recent achievements section
  - Badge display
  - Report button for other users' profiles
- **Backend**: Profile API endpoints
  - Get user profile by username or ID
  - Profile includes stats, badges, and achievement points

### Achievement System (#48)
- **Database**: UserAchievement model for tracking progress
- **62 Total Achievements** across 5 categories:
  - **Suggestions (15)**: First suggestion through ultimate curator
  - **Likes (12)**: First like through legendary fan
  - **Comments (12)**: First comment through review legend
  - **Engagement (15)**: Login streaks and activity milestones
  - **Reports (8)**: Valid reports and accuracy tracking
- **Backend**: AchievementService with real-time checking
  - Integrated into all user interaction points
  - API endpoints for achievement data
  - Progress tracking to avoid recalculation
- **Frontend**: Achievements page and profile integration
  - Full achievements page with category filtering
  - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond)
  - Progress indicators for in-progress achievements
  - Recent achievements on profile pages

### Primary Badge System (#49)
- **Database**: Add primaryBadge field to User model
- **Backend**: Update profile endpoints to include primary badge
- **Frontend**: Primary badge selection in settings
  - Only shows badges the user has earned
  - Displayed on profile page
  - Displayed in comments (next to username)
  - Falls back to no badge if selection is invalid
- **Admin Features**: Admin can edit any user's primary badge

### Admin Enhancements
- Comprehensive profile editing modal for admins
  - Edit display name, bio, slug, social links
  - Set primary badge for users
  - Visual feedback for save/error states
- Admin action buttons in report review modals
  - Ban user, delete comment, edit profile
  - Integrated with report workflow

### Quality Improvements
- Improved dropdown option contrast for readability
- Hide all badges when no primary badge is selected
- "View All" achievements link only shown on own profile
- Improved achievement text readability

## Testing

-  User profiles display correctly with stats and badges
-  Achievement checking works for all interaction types
-  Primary badge selection persists and displays correctly
-  Admin profile editing saves successfully
-  Report workflow integrated with admin actions
-  Achievements page shows all 62 achievements with filtering
-  Text readability improved across components

Closes #45
Closes #48
Closes #49

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #58
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2026-02-19 22:21:17 -08:00

1884 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 (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');
}
});
}
}
}