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:
@@ -205,6 +205,9 @@ model User {
|
|||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
likes Like[]
|
likes Like[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
reportsMade ProfileReport[] @relation("Reporter")
|
||||||
|
reportsReceived ProfileReport[] @relation("ReportedUser")
|
||||||
|
reportsReviewed ProfileReport[] @relation("Reviewer")
|
||||||
|
|
||||||
@@index([slug], map: "User_slug_key")
|
@@index([slug], map: "User_slug_key")
|
||||||
}
|
}
|
||||||
@@ -329,3 +332,40 @@ model RefreshToken {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ReportReason {
|
||||||
|
INAPPROPRIATE_CONTENT
|
||||||
|
HARASSMENT
|
||||||
|
SPAM
|
||||||
|
IMPERSONATION
|
||||||
|
OFFENSIVE_NAME
|
||||||
|
MALICIOUS_LINKS
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReportStatus {
|
||||||
|
PENDING
|
||||||
|
REVIEWED
|
||||||
|
DISMISSED
|
||||||
|
ACTION_TAKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProfileReport {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
reportedUserId String @db.ObjectId
|
||||||
|
reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id])
|
||||||
|
reporterId String @db.ObjectId
|
||||||
|
reporter User @relation("Reporter", fields: [reporterId], references: [id])
|
||||||
|
reason ReportReason
|
||||||
|
details String
|
||||||
|
status ReportStatus @default(PENDING)
|
||||||
|
reviewedBy String? @db.ObjectId
|
||||||
|
reviewer User? @relation("Reviewer", fields: [reviewedBy], references: [id])
|
||||||
|
reviewNotes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([reportedUserId])
|
||||||
|
@@index([reporterId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import type {
|
||||||
|
CreateReportDto,
|
||||||
|
ProfileReportWithUsers,
|
||||||
|
ReportStatus,
|
||||||
|
UpdateReportDto,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { ReportReason } from "@library/shared-types";
|
||||||
|
|
||||||
|
import { ReportService } from "../../services/report.service.js";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard.js";
|
||||||
|
|
||||||
|
const reportsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
const reportService = new ReportService();
|
||||||
|
|
||||||
|
// Create a new report (authenticated users)
|
||||||
|
fastify.post<{
|
||||||
|
Body: CreateReportDto;
|
||||||
|
Reply: ProfileReportWithUsers | { error: string };
|
||||||
|
}>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate],
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["reportedUserId", "reason", "details"],
|
||||||
|
properties: {
|
||||||
|
reportedUserId: { type: "string" },
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
enum: Object.values(ReportReason),
|
||||||
|
},
|
||||||
|
details: { type: "string", minLength: 10, maxLength: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const report = await reportService.createReport(
|
||||||
|
request.user.id,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.status(201).send(report);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("already have a pending report")
|
||||||
|
) {
|
||||||
|
return reply.status(409).send({ error: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all reports (admin only)
|
||||||
|
fastify.get<{
|
||||||
|
Querystring: { status?: ReportStatus };
|
||||||
|
Reply: ProfileReportWithUsers[];
|
||||||
|
}>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate, adminGuard],
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const reports = await reportService.getAllReports(
|
||||||
|
request.query.status,
|
||||||
|
);
|
||||||
|
return reply.send(reports);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single report by ID (admin only)
|
||||||
|
fastify.get<{
|
||||||
|
Params: { id: string };
|
||||||
|
Reply: ProfileReportWithUsers | { error: string };
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const report = await reportService.getReportById(request.params.id);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return reply.status(404).send({ error: "Report not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(report);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a report (admin only)
|
||||||
|
fastify.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateReportDto;
|
||||||
|
Reply: ProfileReportWithUsers;
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate, adminGuard],
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["status"],
|
||||||
|
properties: {
|
||||||
|
status: { type: "string" },
|
||||||
|
reviewNotes: { type: "string", maxLength: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const report = await reportService.updateReport(
|
||||||
|
request.params.id,
|
||||||
|
request.user.id,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(report);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportsRoutes;
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ReportStatus as PrismaReportStatus,
|
||||||
|
ReportReason as PrismaReportReason,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import type {
|
||||||
|
CreateReportDto,
|
||||||
|
ProfileReportWithUsers,
|
||||||
|
ReportStatus,
|
||||||
|
UpdateReportDto,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { ReportReason } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
|
||||||
|
export class ReportService {
|
||||||
|
private prisma = prisma;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Prisma ReportReason to shared-types ReportReason
|
||||||
|
*/
|
||||||
|
private toPrismaReportReason(reason: ReportReason): PrismaReportReason {
|
||||||
|
return reason as unknown as PrismaReportReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Prisma ReportStatus to shared-types ReportStatus
|
||||||
|
*/
|
||||||
|
private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus {
|
||||||
|
return status as unknown as PrismaReportStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Prisma enum back to shared-types enum
|
||||||
|
*/
|
||||||
|
private fromPrismaReportReason(reason: PrismaReportReason): ReportReason {
|
||||||
|
return reason as unknown as ReportReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Prisma enum back to shared-types enum
|
||||||
|
*/
|
||||||
|
private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus {
|
||||||
|
return status as unknown as ReportStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new profile report.
|
||||||
|
*
|
||||||
|
* @param reporterId - The ID of the user making the report
|
||||||
|
* @param createDto - The report details
|
||||||
|
* @returns The created report
|
||||||
|
* @throws Error if user already has a pending report for this profile
|
||||||
|
*/
|
||||||
|
async createReport(
|
||||||
|
reporterId: string,
|
||||||
|
createDto: CreateReportDto,
|
||||||
|
): Promise<ProfileReportWithUsers> {
|
||||||
|
// Check if user already has a pending report for this profile
|
||||||
|
const existingReport = await this.prisma.profileReport.findFirst({
|
||||||
|
where: {
|
||||||
|
reporterId,
|
||||||
|
reportedUserId: createDto.reportedUserId,
|
||||||
|
status: PrismaReportStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReport) {
|
||||||
|
throw new Error(
|
||||||
|
"You already have a pending report for this profile. Please wait for it to be reviewed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has reached the limit of pending reports (5 max)
|
||||||
|
const pendingReportsCount = await this.prisma.profileReport.count({
|
||||||
|
where: {
|
||||||
|
reporterId,
|
||||||
|
status: PrismaReportStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingReportsCount >= 5) {
|
||||||
|
throw new Error(
|
||||||
|
"You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await this.prisma.profileReport.create({
|
||||||
|
data: {
|
||||||
|
reporterId,
|
||||||
|
reportedUserId: createDto.reportedUserId,
|
||||||
|
reason: this.toPrismaReportReason(createDto.reason),
|
||||||
|
details: createDto.details,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reportedUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
reportedUserId: report.reportedUserId,
|
||||||
|
reporterId: report.reporterId,
|
||||||
|
reason: this.fromPrismaReportReason(report.reason),
|
||||||
|
details: report.details,
|
||||||
|
status: this.fromPrismaReportStatus(report.status),
|
||||||
|
reviewedBy: report.reviewedBy ?? undefined,
|
||||||
|
reviewNotes: report.reviewNotes ?? undefined,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
updatedAt: report.updatedAt,
|
||||||
|
reportedUser: report.reportedUser,
|
||||||
|
reporter: report.reporter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all reports (admin only). Optionally filter by status.
|
||||||
|
*
|
||||||
|
* @param status - Optional status filter
|
||||||
|
* @returns All reports matching the filter
|
||||||
|
*/
|
||||||
|
async getAllReports(
|
||||||
|
status?: ReportStatus,
|
||||||
|
): Promise<ProfileReportWithUsers[]> {
|
||||||
|
const reports = await this.prisma.profileReport.findMany({
|
||||||
|
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
|
||||||
|
include: {
|
||||||
|
reportedUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reports.map((report) => ({
|
||||||
|
id: report.id,
|
||||||
|
reportedUserId: report.reportedUserId,
|
||||||
|
reporterId: report.reporterId,
|
||||||
|
reason: this.fromPrismaReportReason(report.reason),
|
||||||
|
details: report.details,
|
||||||
|
status: this.fromPrismaReportStatus(report.status),
|
||||||
|
reviewedBy: report.reviewedBy ?? undefined,
|
||||||
|
reviewNotes: report.reviewNotes ?? undefined,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
updatedAt: report.updatedAt,
|
||||||
|
reportedUser: report.reportedUser,
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single report by ID (admin only).
|
||||||
|
*
|
||||||
|
* @param id - The report ID
|
||||||
|
* @returns The report or null
|
||||||
|
*/
|
||||||
|
async getReportById(id: string): Promise<ProfileReportWithUsers | null> {
|
||||||
|
const report = await this.prisma.profileReport.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
reportedUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
reportedUserId: report.reportedUserId,
|
||||||
|
reporterId: report.reporterId,
|
||||||
|
reason: this.fromPrismaReportReason(report.reason),
|
||||||
|
details: report.details,
|
||||||
|
status: this.fromPrismaReportStatus(report.status),
|
||||||
|
reviewedBy: report.reviewedBy ?? undefined,
|
||||||
|
reviewNotes: report.reviewNotes ?? undefined,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
updatedAt: report.updatedAt,
|
||||||
|
reportedUser: report.reportedUser,
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a report's status and review notes (admin only).
|
||||||
|
*
|
||||||
|
* @param id - The report ID
|
||||||
|
* @param reviewerId - The ID of the admin reviewing the report
|
||||||
|
* @param updateDto - The update details
|
||||||
|
* @returns The updated report
|
||||||
|
*/
|
||||||
|
async updateReport(
|
||||||
|
id: string,
|
||||||
|
reviewerId: string,
|
||||||
|
updateDto: UpdateReportDto,
|
||||||
|
): Promise<ProfileReportWithUsers> {
|
||||||
|
const report = await this.prisma.profileReport.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: this.toPrismaReportStatus(updateDto.status),
|
||||||
|
reviewNotes: updateDto.reviewNotes,
|
||||||
|
reviewedBy: reviewerId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reportedUser: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reviewer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
reportedUserId: report.reportedUserId,
|
||||||
|
reporterId: report.reporterId,
|
||||||
|
reason: this.fromPrismaReportReason(report.reason),
|
||||||
|
details: report.details,
|
||||||
|
status: this.fromPrismaReportStatus(report.status),
|
||||||
|
reviewedBy: report.reviewedBy ?? undefined,
|
||||||
|
reviewNotes: report.reviewNotes ?? undefined,
|
||||||
|
createdAt: report.createdAt,
|
||||||
|
updatedAt: report.updatedAt,
|
||||||
|
reportedUser: report.reportedUser,
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
|
|||||||
path: 'admin/suggestions',
|
path: 'admin/suggestions',
|
||||||
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent)
|
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',
|
path: 'my-suggestions',
|
||||||
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent)
|
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 { ActivatedRoute } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
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 { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { UserService, UserProfileResponse } from '../../services/user.service';
|
import { UserService, UserProfileResponse } from '../../services/user.service';
|
||||||
import { ToastService } from '../../services/toast.service';
|
import { ToastService } from '../../services/toast.service';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { ReportModalComponent } from '../report-modal/report-modal.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-profile',
|
selector: 'app-profile',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FontAwesomeModule],
|
imports: [CommonModule, FontAwesomeModule, ReportModalComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="profile-container">
|
<div class="profile-container">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
@@ -42,6 +44,17 @@ import { ToastService } from '../../services/toast.service';
|
|||||||
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
|
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="badges-section">
|
<div class="badges-section">
|
||||||
@@ -142,6 +155,14 @@ import { ToastService } from '../../services/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (reportModalOpen()) {
|
||||||
|
<app-report-modal
|
||||||
|
[reportedUserId]="profile()!.id"
|
||||||
|
[reportedUsername]="profile()!.displayName || profile()!.username"
|
||||||
|
(closeModal)="closeReportModal()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
@@ -173,6 +194,7 @@ import { ToastService } from '../../services/toast.service';
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-avatar {
|
.profile-avatar {
|
||||||
@@ -217,6 +239,33 @@ import { ToastService } from '../../services/toast.service';
|
|||||||
font-size: 0.9rem;
|
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 {
|
.badges-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -369,10 +418,12 @@ export class ProfileComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private userService = inject(UserService);
|
private userService = inject(UserService);
|
||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
profile = signal<UserProfileResponse | null>(null);
|
profile = signal<UserProfileResponse | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
|
reportModalOpen = signal(false);
|
||||||
|
|
||||||
// Font Awesome icons
|
// Font Awesome icons
|
||||||
faGlobe = faGlobe;
|
faGlobe = faGlobe;
|
||||||
@@ -382,6 +433,7 @@ export class ProfileComponent implements OnInit {
|
|||||||
faTwitch = faTwitch;
|
faTwitch = faTwitch;
|
||||||
faYoutube = faYoutube;
|
faYoutube = faYoutube;
|
||||||
faDiscord = faDiscord;
|
faDiscord = faDiscord;
|
||||||
|
faFlag = faFlag;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const identifier = this.route.snapshot.paramMap.get('identifier');
|
const identifier = this.route.snapshot.paramMap.get('identifier');
|
||||||
@@ -412,4 +464,34 @@ export class ProfileComponent implements OnInit {
|
|||||||
day: 'numeric'
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export enum ReportReason {
|
||||||
|
INAPPROPRIATE_CONTENT = "INAPPROPRIATE_CONTENT",
|
||||||
|
HARASSMENT = "HARASSMENT",
|
||||||
|
SPAM = "SPAM",
|
||||||
|
IMPERSONATION = "IMPERSONATION",
|
||||||
|
OFFENSIVE_NAME = "OFFENSIVE_NAME",
|
||||||
|
MALICIOUS_LINKS = "MALICIOUS_LINKS",
|
||||||
|
OTHER = "OTHER",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ReportStatus {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
REVIEWED = "REVIEWED",
|
||||||
|
DISMISSED = "DISMISSED",
|
||||||
|
ACTION_TAKEN = "ACTION_TAKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileReport {
|
||||||
|
id: string;
|
||||||
|
reportedUserId: string;
|
||||||
|
reporterId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewNotes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileReportWithUsers extends ProfileReport {
|
||||||
|
reportedUser: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
reporter: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
reviewer?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReportDto {
|
||||||
|
reportedUserId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateReportDto {
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewNotes?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user