generated from nhcarrigan/template
feat: implement profile reporting system with admin review
Added comprehensive profile reporting system to allow users to report inappropriate profiles and admins to review reports. Features: - User can report profiles with predefined reasons + custom details - Duplicate prevention (one pending report per profile per user) - Rate limiting (5 pending reports maximum per user) - Admin dashboard to view and filter reports (All, Pending, Reviewed, etc.) - Admin review modal to update status and add review notes - Report button on profile page (only visible when viewing others) - Font Awesome icons for better UI consistency Database changes: - New ProfileReport model with ReportReason/ReportStatus enums - User relations for reports (reportsMade, reportsReceived, reportsReviewed) - Indices for efficient querying
This commit is contained in:
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
|
||||
path: 'admin/suggestions',
|
||||
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
|
||||
},
|
||||
{
|
||||
path: 'admin/reports',
|
||||
loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent)
|
||||
},
|
||||
{
|
||||
path: 'my-suggestions',
|
||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
||||
|
||||
@@ -0,0 +1,968 @@
|
||||
/**
|
||||
* @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 { AuthService } from '../../services/auth.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { ProfileReportWithUsers, ReportStatus, ReportReason } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-reports',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="admin-reports-container">
|
||||
<h1>Profile Reports</h1>
|
||||
|
||||
@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 ({{ allReports().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">
|
||||
@for (report of filteredReports(); 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)="openReviewModal(report)"
|
||||
[attr.aria-label]="'Review report for ' + report.reportedUser.username"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<p>No reports found.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Review Modal -->
|
||||
@if (reviewingReport()) {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeReviewModal()"
|
||||
(keydown.escape)="closeReviewModal()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="document"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Review Report</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
(click)="closeReviewModal()"
|
||||
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>{{ reviewingReport()!.reportedUser.username }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reporter:</strong>
|
||||
<span>{{ reviewingReport()!.reporter.username }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reason:</strong>
|
||||
<span>{{ formatReason(reviewingReport()!.reason) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reported On:</strong>
|
||||
<span>{{ formatDate(reviewingReport()!.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-section">
|
||||
<strong>Details:</strong>
|
||||
<p class="details-text">{{ reviewingReport()!.details }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Review Form -->
|
||||
<form (ngSubmit)="submitReview()" 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)="closeReviewModal()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
{{ submitting() ? 'Submitting...' : 'Submit Review' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.admin-reports-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--witch-purple);
|
||||
margin-bottom: 2rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Review Form */
|
||||
.review-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: var(--witch-purple);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--witch-lavender);
|
||||
border-radius: 8px;
|
||||
background: var(--witch-moon);
|
||||
color: var(--witch-purple);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--witch-purple);
|
||||
box-shadow: 0 0 0 3px rgba(155, 89, 182, 0.2);
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--witch-lavender);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--witch-lavender);
|
||||
color: var(--witch-purple);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--witch-mauve);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--witch-purple);
|
||||
color: var(--witch-moon);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--witch-mauve);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.admin-reports-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.report-meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.review-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AdminReportsComponent implements OnInit {
|
||||
private reportService = inject(ReportService);
|
||||
private authService = inject(AuthService);
|
||||
private toastService = inject(ToastService);
|
||||
private router = inject(Router);
|
||||
|
||||
// Make ReportStatus accessible in template
|
||||
protected readonly ReportStatus = ReportStatus;
|
||||
|
||||
allReports = signal<ProfileReportWithUsers[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
activeFilter = signal<'all' | ReportStatus>('all');
|
||||
|
||||
reviewingReport = signal<ProfileReportWithUsers | null>(null);
|
||||
submitting = signal(false);
|
||||
|
||||
reviewForm = {
|
||||
status: ReportStatus.PENDING,
|
||||
reviewNotes: ''
|
||||
};
|
||||
|
||||
filteredReports = computed(() => {
|
||||
const filter = this.activeFilter();
|
||||
if (filter === 'all') {
|
||||
return this.allReports();
|
||||
}
|
||||
return this.allReports().filter(report => report.status === filter);
|
||||
});
|
||||
|
||||
pendingCount = computed(() =>
|
||||
this.allReports().filter(r => r.status === ReportStatus.PENDING).length
|
||||
);
|
||||
|
||||
reviewedCount = computed(() =>
|
||||
this.allReports().filter(r => r.status === ReportStatus.REVIEWED).length
|
||||
);
|
||||
|
||||
dismissedCount = computed(() =>
|
||||
this.allReports().filter(r => r.status === ReportStatus.DISMISSED).length
|
||||
);
|
||||
|
||||
actionTakenCount = computed(() =>
|
||||
this.allReports().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);
|
||||
|
||||
this.reportService.getAllReports().subscribe({
|
||||
next: (reports) => {
|
||||
this.allReports.set(reports);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load reports');
|
||||
this.loading.set(false);
|
||||
this.toastService.error('Failed to load reports');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFilter(filter: 'all' | ReportStatus): void {
|
||||
this.activeFilter.set(filter);
|
||||
}
|
||||
|
||||
openReviewModal(report: ProfileReportWithUsers): void {
|
||||
this.reviewingReport.set(report);
|
||||
this.reviewForm = {
|
||||
status: report.status,
|
||||
reviewNotes: report.reviewNotes || ''
|
||||
};
|
||||
}
|
||||
|
||||
closeReviewModal(): void {
|
||||
if (!this.submitting()) {
|
||||
this.reviewingReport.set(null);
|
||||
this.reviewForm = {
|
||||
status: ReportStatus.PENDING,
|
||||
reviewNotes: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
submitReview(): void {
|
||||
const report = this.reviewingReport();
|
||||
if (!report) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
|
||||
this.reportService.updateReport(
|
||||
report.id,
|
||||
this.reviewForm.status,
|
||||
this.reviewForm.reviewNotes || undefined
|
||||
).subscribe({
|
||||
next: (updatedReport) => {
|
||||
this.allReports.update(reports =>
|
||||
reports.map(r => r.id === updatedReport.id ? updatedReport : r)
|
||||
);
|
||||
this.toastService.success('Report updated successfully');
|
||||
this.submitting.set(false);
|
||||
this.closeReviewModal();
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastService.error(err.message ?? 'Failed to update report');
|
||||
this.submitting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,17 @@ import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faGlobe, faCloud } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
|
||||
import { UserService, UserProfileResponse } from '../../services/user.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ReportModalComponent } from '../report-modal/report-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FontAwesomeModule],
|
||||
imports: [CommonModule, FontAwesomeModule, ReportModalComponent],
|
||||
template: `
|
||||
<div class="profile-container">
|
||||
@if (loading()) {
|
||||
@@ -42,6 +44,17 @@ import { ToastService } from '../../services/toast.service';
|
||||
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (showReportButton()) {
|
||||
<button
|
||||
class="report-button"
|
||||
(click)="openReportModal()"
|
||||
[attr.aria-label]="'Report ' + profile()!.username + ' profile'"
|
||||
type="button"
|
||||
>
|
||||
<fa-icon [icon]="faFlag"></fa-icon>
|
||||
<span>Report</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="badges-section">
|
||||
@@ -142,6 +155,14 @@ import { ToastService } from '../../services/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (reportModalOpen()) {
|
||||
<app-report-modal
|
||||
[reportedUserId]="profile()!.id"
|
||||
[reportedUsername]="profile()!.displayName || profile()!.username"
|
||||
(closeModal)="closeReportModal()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -173,6 +194,7 @@ import { ToastService } from '../../services/toast.service';
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
@@ -217,6 +239,33 @@ import { ToastService } from '../../services/toast.service';
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.report-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.report-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
|
||||
}
|
||||
|
||||
.report-button fa-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.badges-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -369,10 +418,12 @@ export class ProfileComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private userService = inject(UserService);
|
||||
private toastService = inject(ToastService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
profile = signal<UserProfileResponse | null>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
reportModalOpen = signal(false);
|
||||
|
||||
// Font Awesome icons
|
||||
faGlobe = faGlobe;
|
||||
@@ -382,6 +433,7 @@ export class ProfileComponent implements OnInit {
|
||||
faTwitch = faTwitch;
|
||||
faYoutube = faYoutube;
|
||||
faDiscord = faDiscord;
|
||||
faFlag = faFlag;
|
||||
|
||||
ngOnInit(): void {
|
||||
const identifier = this.route.snapshot.paramMap.get('identifier');
|
||||
@@ -412,4 +464,34 @@ export class ProfileComponent implements OnInit {
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to show the report button.
|
||||
* Only show if the user is authenticated and viewing someone else's profile.
|
||||
*/
|
||||
showReportButton(): boolean {
|
||||
const currentUser = this.authService.user();
|
||||
const profileData = this.profile();
|
||||
|
||||
if (!currentUser || !profileData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show report button on your own profile
|
||||
return currentUser.id !== profileData.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the report modal.
|
||||
*/
|
||||
openReportModal(): void {
|
||||
this.reportModalOpen.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the report modal.
|
||||
*/
|
||||
closeReportModal(): void {
|
||||
this.reportModalOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Component, inject, signal, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ReportService } from '../../services/report.service';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { ReportReason } from '@library/shared-types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-report-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
|
||||
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Report Profile</h2>
|
||||
<button
|
||||
class="close-button"
|
||||
(click)="onClose()"
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="report-info">
|
||||
You are reporting <strong>{{ reportedUsername() }}</strong>.
|
||||
Please provide a reason and details for this report.
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
|
||||
<div class="form-group">
|
||||
<label for="reason">Reason *</label>
|
||||
<select
|
||||
id="reason"
|
||||
name="reason"
|
||||
[(ngModel)]="selectedReason"
|
||||
required
|
||||
class="form-control"
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="" disabled>Select a reason</option>
|
||||
<option [value]="ReportReason.INAPPROPRIATE_CONTENT">Inappropriate Content</option>
|
||||
<option [value]="ReportReason.HARASSMENT">Harassment</option>
|
||||
<option [value]="ReportReason.SPAM">Spam</option>
|
||||
<option [value]="ReportReason.IMPERSONATION">Impersonation</option>
|
||||
<option [value]="ReportReason.OFFENSIVE_NAME">Offensive Name</option>
|
||||
<option [value]="ReportReason.MALICIOUS_LINKS">Malicious Links</option>
|
||||
<option [value]="ReportReason.OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="details">Details *</label>
|
||||
<textarea
|
||||
id="details"
|
||||
name="details"
|
||||
[(ngModel)]="details"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="1000"
|
||||
rows="5"
|
||||
class="form-control"
|
||||
placeholder="Please provide specific details about why you are reporting this profile (10-1000 characters)"
|
||||
aria-required="true"
|
||||
[attr.aria-invalid]="detailsInvalid()"
|
||||
></textarea>
|
||||
<div class="character-count" [class.invalid]="detailsInvalid()">
|
||||
{{ details().length }} / 1000 characters
|
||||
@if (details().length < 10 && details().length > 0) {
|
||||
<span class="error-text">(minimum 10 characters)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="button button-secondary"
|
||||
(click)="onClose()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button-danger"
|
||||
[disabled]="!reportForm.valid || submitting()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<span>Submitting...</span>
|
||||
} @else {
|
||||
<span>Submit Report</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--card-background, #1a1a2e);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 2px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
line-height: 1;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.report-info {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.report-info strong {
|
||||
color: var(--accent-colour, #9b59b6);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: rgba(155, 89, 182, 0.1);
|
||||
border: 2px solid rgba(155, 89, 182, 0.3);
|
||||
border-radius: 8px;
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-colour, #9b59b6);
|
||||
}
|
||||
|
||||
.form-control:invalid:not(:focus):not(:placeholder-shown) {
|
||||
border-color: var(--error-colour, #c41e3a);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23e0e0e0"><path d="M7 10l5 5 5-5z"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-size: 1.5rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
select.form-control option {
|
||||
background: #2d2d2d;
|
||||
color: #e0e0e0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.character-count {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.character-count.invalid {
|
||||
color: var(--error-colour, #c41e3a);
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--error-colour, #c41e3a);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: rgba(155, 89, 182, 0.2);
|
||||
color: var(--text-colour, #e0e0e0);
|
||||
border: 2px solid rgba(155, 89, 182, 0.3);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled) {
|
||||
background: rgba(155, 89, 182, 0.3);
|
||||
border-color: var(--accent-colour, #9b59b6);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-danger:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ReportModalComponent {
|
||||
private reportService = inject(ReportService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
// Inputs
|
||||
reportedUserId = input.required<string>();
|
||||
reportedUsername = input.required<string>();
|
||||
|
||||
// Outputs
|
||||
closeModal = output<void>();
|
||||
|
||||
// State
|
||||
selectedReason = signal<string>('');
|
||||
details = signal<string>('');
|
||||
submitting = signal<boolean>(false);
|
||||
|
||||
// Expose enum for template
|
||||
ReportReason = ReportReason;
|
||||
|
||||
/**
|
||||
* Check if details are invalid (less than 10 characters or more than 1000).
|
||||
*/
|
||||
detailsInvalid(): boolean {
|
||||
const length = this.details().length;
|
||||
return (length > 0 && length < 10) || length > 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle overlay click to close modal.
|
||||
*/
|
||||
onOverlayClick(event: MouseEvent): void {
|
||||
if ((event.target as HTMLElement).classList.contains('modal-overlay')) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal.
|
||||
*/
|
||||
onClose(): void {
|
||||
this.closeModal.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the report.
|
||||
*/
|
||||
onSubmit(): void {
|
||||
// Validate form
|
||||
if (!this.selectedReason() || this.detailsInvalid()) {
|
||||
this.toastService.error('Please fill in all required fields correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
this.submitting.set(true);
|
||||
|
||||
this.reportService.createReport(
|
||||
this.reportedUserId(),
|
||||
this.selectedReason(),
|
||||
this.details()
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.toastService.success('Report submitted successfully');
|
||||
this.onClose();
|
||||
},
|
||||
error: (err: { status?: number; error?: { error?: string } }) => {
|
||||
console.error('Error submitting report:', err);
|
||||
// Check if it's a conflict error (duplicate pending report)
|
||||
if (err.status === 409 && err.error?.error) {
|
||||
this.toastService.error(err.error.error);
|
||||
} else {
|
||||
this.toastService.error('Failed to submit report. Please try again.');
|
||||
}
|
||||
this.submitting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @copyright 2026 NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiService } from './api.service';
|
||||
import type {
|
||||
ProfileReportWithUsers,
|
||||
CreateReportDto,
|
||||
ReportStatus
|
||||
} from '@library/shared-types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReportService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
/**
|
||||
* Create a new profile report.
|
||||
*
|
||||
* @param reportedUserId - The ID of the user being reported
|
||||
* @param reason - The reason for the report
|
||||
* @param details - Additional details about the report
|
||||
* @returns Observable of the created report
|
||||
*/
|
||||
createReport(reportedUserId: string, reason: string, details: string): Observable<ProfileReportWithUsers> {
|
||||
const dto: CreateReportDto = {
|
||||
reportedUserId,
|
||||
reason: reason as CreateReportDto['reason'],
|
||||
details
|
||||
};
|
||||
return this.api.post<ProfileReportWithUsers>('/reports', dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all reports (admin only). Optionally filter by status.
|
||||
*
|
||||
* @param status - Optional status to filter by
|
||||
* @returns Observable of all matching reports
|
||||
*/
|
||||
getAllReports(status?: ReportStatus): Observable<ProfileReportWithUsers[]> {
|
||||
const url = status ? `/reports?status=${status}` : '/reports';
|
||||
return this.api.get<ProfileReportWithUsers[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific report by ID (admin only).
|
||||
*
|
||||
* @param id - The report ID
|
||||
* @returns Observable of the report
|
||||
*/
|
||||
getReportById(id: string): Observable<ProfileReportWithUsers> {
|
||||
return this.api.get<ProfileReportWithUsers>(`/reports/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a report's status and review notes (admin only).
|
||||
*
|
||||
* @param id - The report ID
|
||||
* @param status - The new status
|
||||
* @param reviewNotes - Optional review notes
|
||||
* @returns Observable of the updated report
|
||||
*/
|
||||
updateReport(id: string, status: ReportStatus, reviewNotes?: string): Observable<ProfileReportWithUsers> {
|
||||
return this.api.put<ProfileReportWithUsers>(`/reports/${id}`, {
|
||||
status,
|
||||
reviewNotes
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user