generated from nhcarrigan/template
feat: implement user profiles with achievements and primary badge system #58
@@ -208,6 +208,8 @@ model User {
|
|||||||
reportsMade ProfileReport[] @relation("Reporter")
|
reportsMade ProfileReport[] @relation("Reporter")
|
||||||
reportsReceived ProfileReport[] @relation("ReportedUser")
|
reportsReceived ProfileReport[] @relation("ReportedUser")
|
||||||
reportsReviewed ProfileReport[] @relation("Reviewer")
|
reportsReviewed ProfileReport[] @relation("Reviewer")
|
||||||
|
commentReportsMade CommentReport[] @relation("CommentReporter")
|
||||||
|
commentReportsReviewed CommentReport[] @relation("CommentReviewer")
|
||||||
|
|
||||||
@@index([slug], map: "User_slug_key")
|
@@index([slug], map: "User_slug_key")
|
||||||
}
|
}
|
||||||
@@ -230,6 +232,7 @@ model Comment {
|
|||||||
show Show? @relation(fields: [showId], references: [id])
|
show Show? @relation(fields: [showId], references: [id])
|
||||||
mangaId String? @db.ObjectId
|
mangaId String? @db.ObjectId
|
||||||
manga Manga? @relation(fields: [mangaId], references: [id])
|
manga Manga? @relation(fields: [mangaId], references: [id])
|
||||||
|
reports CommentReport[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -369,3 +372,23 @@ model ProfileReport {
|
|||||||
@@index([reporterId])
|
@@index([reporterId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CommentReport {
|
||||||
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
|
reportedCommentId String @db.ObjectId
|
||||||
|
reportedComment Comment @relation(fields: [reportedCommentId], references: [id])
|
||||||
|
reporterId String @db.ObjectId
|
||||||
|
reporter User @relation("CommentReporter", fields: [reporterId], references: [id])
|
||||||
|
reason ReportReason
|
||||||
|
details String
|
||||||
|
status ReportStatus @default(PENDING)
|
||||||
|
reviewedBy String? @db.ObjectId
|
||||||
|
reviewer User? @relation("CommentReviewer", fields: [reviewedBy], references: [id])
|
||||||
|
reviewNotes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([reportedCommentId])
|
||||||
|
@@index([reporterId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import type { FastifyPluginAsync } from "fastify";
|
||||||
|
import type {
|
||||||
|
CreateCommentReportDto,
|
||||||
|
CommentReportWithDetails,
|
||||||
|
ReportStatus,
|
||||||
|
UpdateCommentReportDto,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { ReportReason } from "@library/shared-types";
|
||||||
|
|
||||||
|
import { CommentReportService } from "../../services/comment-report.service.js";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard.js";
|
||||||
|
|
||||||
|
const commentReportsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
|
const commentReportService = new CommentReportService();
|
||||||
|
|
||||||
|
// Create a new comment report (authenticated users)
|
||||||
|
fastify.post<{
|
||||||
|
Body: CreateCommentReportDto;
|
||||||
|
Reply: CommentReportWithDetails | { error: string };
|
||||||
|
}>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate],
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["reportedCommentId", "reason", "details"],
|
||||||
|
properties: {
|
||||||
|
reportedCommentId: { type: "string" },
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
enum: Object.values(ReportReason),
|
||||||
|
},
|
||||||
|
details: { type: "string", minLength: 10, maxLength: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const report = await commentReportService.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") ||
|
||||||
|
error.message.includes("maximum number of pending reports"))
|
||||||
|
) {
|
||||||
|
return reply.status(409).send({ error: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all comment reports (admin only)
|
||||||
|
fastify.get<{
|
||||||
|
Querystring: { status?: ReportStatus };
|
||||||
|
Reply: CommentReportWithDetails[];
|
||||||
|
}>(
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate, adminGuard],
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const reports = await commentReportService.getAllReports(
|
||||||
|
request.query.status,
|
||||||
|
);
|
||||||
|
return reply.send(reports);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get a single comment report by ID (admin only)
|
||||||
|
fastify.get<{
|
||||||
|
Params: { id: string };
|
||||||
|
Reply: CommentReportWithDetails | { error: string };
|
||||||
|
}>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [fastify.authenticate, adminGuard],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const report = await commentReportService.getReportById(request.params.id);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return reply.status(404).send({ error: "Report not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(report);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update a comment report (admin only)
|
||||||
|
fastify.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: UpdateCommentReportDto;
|
||||||
|
Reply: CommentReportWithDetails;
|
||||||
|
}>(
|
||||||
|
"/: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 commentReportService.updateReport(
|
||||||
|
request.params.id,
|
||||||
|
request.user.id,
|
||||||
|
request.body,
|
||||||
|
);
|
||||||
|
return reply.send(report);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default commentReportsRoutes;
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
ReportStatus as PrismaReportStatus,
|
||||||
|
ReportReason as PrismaReportReason,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import type {
|
||||||
|
CreateCommentReportDto,
|
||||||
|
CommentReportWithDetails,
|
||||||
|
ReportStatus,
|
||||||
|
UpdateCommentReportDto,
|
||||||
|
} from "@library/shared-types";
|
||||||
|
import { ReportReason } from "@library/shared-types";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
|
||||||
|
export class CommentReportService {
|
||||||
|
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 comment 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 comment
|
||||||
|
*/
|
||||||
|
async createReport(
|
||||||
|
reporterId: string,
|
||||||
|
createDto: CreateCommentReportDto,
|
||||||
|
): Promise<CommentReportWithDetails> {
|
||||||
|
// Check if user already has a pending report for this comment
|
||||||
|
const existingReport = await this.prisma.commentReport.findFirst({
|
||||||
|
where: {
|
||||||
|
reporterId,
|
||||||
|
reportedCommentId: createDto.reportedCommentId,
|
||||||
|
status: PrismaReportStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReport) {
|
||||||
|
throw new Error(
|
||||||
|
"You already have a pending report for this comment. Please wait for it to be reviewed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has reached the limit of pending reports (5 max)
|
||||||
|
const pendingReportsCount = await this.prisma.commentReport.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.commentReport.create({
|
||||||
|
data: {
|
||||||
|
reporterId,
|
||||||
|
reportedCommentId: createDto.reportedCommentId,
|
||||||
|
reason: this.toPrismaReportReason(createDto.reason),
|
||||||
|
details: createDto.details,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reportedComment: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reporter: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
reportedCommentId: report.reportedCommentId,
|
||||||
|
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,
|
||||||
|
reportedComment: {
|
||||||
|
id: report.reportedComment.id,
|
||||||
|
content: report.reportedComment.content,
|
||||||
|
rawContent: report.reportedComment.rawContent ?? undefined,
|
||||||
|
userId: report.reportedComment.userId,
|
||||||
|
user: report.reportedComment.user,
|
||||||
|
},
|
||||||
|
reporter: report.reporter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all comment reports (admin only). Optionally filter by status.
|
||||||
|
*
|
||||||
|
* @param status - Optional status filter
|
||||||
|
* @returns All reports matching the filter
|
||||||
|
*/
|
||||||
|
async getAllReports(
|
||||||
|
status?: ReportStatus,
|
||||||
|
): Promise<CommentReportWithDetails[]> {
|
||||||
|
const reports = await this.prisma.commentReport.findMany({
|
||||||
|
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
|
||||||
|
include: {
|
||||||
|
reportedComment: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
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,
|
||||||
|
reportedCommentId: report.reportedCommentId,
|
||||||
|
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,
|
||||||
|
reportedComment: {
|
||||||
|
id: report.reportedComment.id,
|
||||||
|
content: report.reportedComment.content,
|
||||||
|
rawContent: report.reportedComment.rawContent ?? undefined,
|
||||||
|
userId: report.reportedComment.userId,
|
||||||
|
user: report.reportedComment.user,
|
||||||
|
},
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single comment report by ID (admin only).
|
||||||
|
*
|
||||||
|
* @param id - The report ID
|
||||||
|
* @returns The report or null
|
||||||
|
*/
|
||||||
|
async getReportById(id: string): Promise<CommentReportWithDetails | null> {
|
||||||
|
const report = await this.prisma.commentReport.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
reportedComment: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
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,
|
||||||
|
reportedCommentId: report.reportedCommentId,
|
||||||
|
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,
|
||||||
|
reportedComment: {
|
||||||
|
id: report.reportedComment.id,
|
||||||
|
content: report.reportedComment.content,
|
||||||
|
rawContent: report.reportedComment.rawContent ?? undefined,
|
||||||
|
userId: report.reportedComment.userId,
|
||||||
|
user: report.reportedComment.user,
|
||||||
|
},
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a comment 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: UpdateCommentReportDto,
|
||||||
|
): Promise<CommentReportWithDetails> {
|
||||||
|
const report = await this.prisma.commentReport.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: this.toPrismaReportStatus(updateDto.status),
|
||||||
|
reviewNotes: updateDto.reviewNotes,
|
||||||
|
reviewedBy: reviewerId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
reportedComment: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
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,
|
||||||
|
reportedCommentId: report.reportedCommentId,
|
||||||
|
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,
|
||||||
|
reportedComment: {
|
||||||
|
id: report.reportedComment.id,
|
||||||
|
content: report.reportedComment.content,
|
||||||
|
rawContent: report.reportedComment.rawContent ?? undefined,
|
||||||
|
userId: report.reportedComment.userId,
|
||||||
|
user: report.reportedComment.user,
|
||||||
|
},
|
||||||
|
reporter: report.reporter,
|
||||||
|
reviewer: report.reviewer ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a comment has any pending reports.
|
||||||
|
*
|
||||||
|
* @param commentId - The comment ID
|
||||||
|
* @returns True if the comment has pending reports
|
||||||
|
*/
|
||||||
|
async hasPendingReports(commentId: string): Promise<boolean> {
|
||||||
|
const count = await this.prisma.commentReport.count({
|
||||||
|
where: {
|
||||||
|
reportedCommentId: commentId,
|
||||||
|
status: PrismaReportStatus.PENDING,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,12 @@ export class CommentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapComment(comment: any): Comment {
|
private async mapComment(comment: any): Promise<Comment> {
|
||||||
|
// Check if comment has pending reports
|
||||||
|
const hasPendingReports = comment.reports
|
||||||
|
? comment.reports.some((report: any) => report.status === "PENDING")
|
||||||
|
: false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: comment.id,
|
id: comment.id,
|
||||||
content: comment.content,
|
content: comment.content,
|
||||||
@@ -71,6 +76,7 @@ export class CommentService {
|
|||||||
artId: comment.artId || undefined,
|
artId: comment.artId || undefined,
|
||||||
showId: comment.showId || undefined,
|
showId: comment.showId || undefined,
|
||||||
mangaId: comment.mangaId || undefined,
|
mangaId: comment.mangaId || undefined,
|
||||||
|
hasPendingReports,
|
||||||
createdAt: comment.createdAt,
|
createdAt: comment.createdAt,
|
||||||
updatedAt: comment.updatedAt,
|
updatedAt: comment.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -79,28 +85,28 @@ export class CommentService {
|
|||||||
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
async getCommentsForGame(gameId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { gameId },
|
where: { gameId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCommentsForBook(bookId: string): Promise<Comment[]> {
|
async getCommentsForBook(bookId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { bookId },
|
where: { bookId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
|
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { musicId },
|
where: { musicId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForGame(
|
async createCommentForGame(
|
||||||
@@ -116,7 +122,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
gameId,
|
gameId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -134,7 +140,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
bookId,
|
bookId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
musicId,
|
musicId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -160,10 +166,10 @@ export class CommentService {
|
|||||||
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
async getCommentsForArt(artId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { artId },
|
where: { artId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForArt(
|
async createCommentForArt(
|
||||||
@@ -179,7 +185,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
artId,
|
artId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -187,10 +193,10 @@ export class CommentService {
|
|||||||
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
async getCommentsForShow(showId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { showId },
|
where: { showId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForShow(
|
async createCommentForShow(
|
||||||
@@ -206,7 +212,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
showId,
|
showId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -214,10 +220,10 @@ export class CommentService {
|
|||||||
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
|
||||||
const comments = await this.prisma.comment.findMany({
|
const comments = await this.prisma.comment.findMany({
|
||||||
where: { mangaId },
|
where: { mangaId },
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
return comments.map((c) => this.mapComment(c));
|
return Promise.all(comments.map((c) => this.mapComment(c)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCommentForManga(
|
async createCommentForManga(
|
||||||
@@ -233,7 +239,7 @@ export class CommentService {
|
|||||||
userId,
|
userId,
|
||||||
mangaId,
|
mangaId,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
@@ -256,7 +262,7 @@ export class CommentService {
|
|||||||
content: sanitizedContent,
|
content: sanitizedContent,
|
||||||
rawContent: content,
|
rawContent: content,
|
||||||
},
|
},
|
||||||
include: { user: true },
|
include: { user: true, reports: true },
|
||||||
});
|
});
|
||||||
return this.mapComment(comment);
|
return this.mapComment(comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Component, Input, Output, EventEmitter, signal, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import type { Comment } from '@library/shared-types';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { SanitizeService } from '../../services/sanitize.service';
|
||||||
|
import { ReportModalComponent } from '../report-modal/report-modal.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-comment-display',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ReportModalComponent],
|
||||||
|
template: `
|
||||||
|
<div class="comments-section">
|
||||||
|
@if (comments().length > 0) {
|
||||||
|
@for (comment of comments(); track comment.id) {
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
@if (comment.user.avatar) {
|
||||||
|
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
||||||
|
}
|
||||||
|
<span class="comment-author">{{ comment.user.username }}</span>
|
||||||
|
@if (comment.user.inDiscord) {
|
||||||
|
<span class="discord-badge">Discord</span>
|
||||||
|
}
|
||||||
|
@if (comment.user.isVip) {
|
||||||
|
<span class="vip-badge">VIP</span>
|
||||||
|
}
|
||||||
|
@if (comment.user.isMod) {
|
||||||
|
<span class="mod-badge">Mod</span>
|
||||||
|
}
|
||||||
|
@if (comment.user.isStaff) {
|
||||||
|
<span class="staff-badge">Staff</span>
|
||||||
|
}
|
||||||
|
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||||
|
@if (canEditComment(comment)) {
|
||||||
|
<button (click)="startEdit(comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||||
|
}
|
||||||
|
@if (canDeleteComment(comment)) {
|
||||||
|
<button (click)="onDelete.emit(comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
||||||
|
}
|
||||||
|
@if (canReportComment(comment)) {
|
||||||
|
<button (click)="openReportModal(comment)" class="btn btn-warning btn-xs">Report</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (editingCommentId() === comment.id) {
|
||||||
|
<div class="comment-edit-form">
|
||||||
|
<textarea
|
||||||
|
[(ngModel)]="editCommentContent"
|
||||||
|
name="editComment"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<div class="comment-edit-actions">
|
||||||
|
<button (click)="saveEdit(comment.id)" class="btn btn-primary btn-xs">Save</button>
|
||||||
|
<button (click)="cancelEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (comment.hasPendingReports) {
|
||||||
|
<div class="comment-pending-review">[comment pending admin review]</div>
|
||||||
|
} @else {
|
||||||
|
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showReportModal()) {
|
||||||
|
<app-report-modal
|
||||||
|
[reportType]="'comment'"
|
||||||
|
[targetId]="reportingCommentId()"
|
||||||
|
(closeModal)="closeReportModal()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-badge,
|
||||||
|
.vip-badge,
|
||||||
|
.mod-badge,
|
||||||
|
.staff-badge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-badge {
|
||||||
|
background: #5865f2;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vip-badge {
|
||||||
|
background: #fbbf24;
|
||||||
|
color: #78350f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mod-badge {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staff-badge {
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-pending-review {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #9b59b6;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f3e8ff;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-form {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edit-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
color: #6b7280;
|
||||||
|
padding: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6b7280;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning:hover {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class CommentDisplayComponent {
|
||||||
|
private readonly authService = inject(AuthService);
|
||||||
|
readonly sanitizeService = inject(SanitizeService);
|
||||||
|
|
||||||
|
@Input({ required: true }) comments = signal<Comment[]>([]);
|
||||||
|
@Output() onEdit = new EventEmitter<{ commentId: string; content: string }>();
|
||||||
|
@Output() onDelete = new EventEmitter<string>();
|
||||||
|
|
||||||
|
editingCommentId = signal<string | null>(null);
|
||||||
|
editCommentContent = '';
|
||||||
|
showReportModal = signal(false);
|
||||||
|
reportingCommentId = signal<string>('');
|
||||||
|
|
||||||
|
formatDate(date: Date | string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canEditComment(comment: Comment): boolean {
|
||||||
|
const user = this.authService.user();
|
||||||
|
if (!user) return false;
|
||||||
|
return comment.userId === user.id || this.authService.isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
canDeleteComment(comment: Comment): boolean {
|
||||||
|
const user = this.authService.user();
|
||||||
|
if (!user) return false;
|
||||||
|
return comment.userId === user.id || this.authService.isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
canReportComment(comment: Comment): boolean {
|
||||||
|
const user = this.authService.user();
|
||||||
|
if (!user) return false;
|
||||||
|
// Users can report comments they didn't write (but not their own)
|
||||||
|
return comment.userId !== user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
startEdit(comment: Comment): void {
|
||||||
|
this.editingCommentId.set(comment.id);
|
||||||
|
this.editCommentContent = comment.rawContent ?? comment.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdit(commentId: string): void {
|
||||||
|
this.onEdit.emit({ commentId, content: this.editCommentContent });
|
||||||
|
this.cancelEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelEdit(): void {
|
||||||
|
this.editingCommentId.set(null);
|
||||||
|
this.editCommentContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
openReportModal(comment: Comment): void {
|
||||||
|
this.reportingCommentId.set(comment.id);
|
||||||
|
this.showReportModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReportModal(): void {
|
||||||
|
this.showReportModal.set(false);
|
||||||
|
this.reportingCommentId.set('');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service';
|
|||||||
import { SuggestionService } from '../../services/suggestion.service';
|
import { SuggestionService } from '../../services/suggestion.service';
|
||||||
import { PaginationComponent } from '../shared/pagination.component';
|
import { PaginationComponent } from '../shared/pagination.component';
|
||||||
import { LikeButtonComponent } from '../shared/like-button.component';
|
import { LikeButtonComponent } from '../shared/like-button.component';
|
||||||
|
import { CommentDisplayComponent } from '../comment-display/comment-display.component';
|
||||||
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-games-list',
|
selector: 'app-games-list',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent],
|
imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-section">
|
<div class="header-section">
|
||||||
@@ -608,52 +609,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
|
|||||||
@if (commentsLoading()[game.id]) {
|
@if (commentsLoading()[game.id]) {
|
||||||
<div class="comments-loading">Loading comments...</div>
|
<div class="comments-loading">Loading comments...</div>
|
||||||
} @else {
|
} @else {
|
||||||
@for (comment of comments()[game.id] || []; track comment.id) {
|
<app-comment-display
|
||||||
<div class="comment">
|
[comments]="getCommentsSignal(game.id)"
|
||||||
<div class="comment-header">
|
(onEdit)="handleCommentEdit(game.id, $event)"
|
||||||
@if (comment.user.avatar) {
|
(onDelete)="deleteComment(game.id, $event)"
|
||||||
<img [src]="comment.user.avatar" [alt]="comment.user.username" class="comment-avatar">
|
/>
|
||||||
}
|
|
||||||
<span class="comment-author">{{ comment.user.username }}</span>
|
|
||||||
@if (comment.user.inDiscord) {
|
|
||||||
<span class="discord-badge">Discord</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isVip) {
|
|
||||||
<span class="vip-badge">VIP</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isMod) {
|
|
||||||
<span class="mod-badge">Mod</span>
|
|
||||||
}
|
|
||||||
@if (comment.user.isStaff) {
|
|
||||||
<span class="staff-badge">Staff</span>
|
|
||||||
}
|
|
||||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
|
||||||
@if (canEditComment(comment)) {
|
|
||||||
<button (click)="startEditComment(game.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
|
||||||
}
|
|
||||||
@if (canDeleteComment(comment)) {
|
|
||||||
<button (click)="deleteComment(game.id, comment.id)" class="btn btn-danger btn-xs">Delete</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (editingCommentId() === comment.id) {
|
|
||||||
<div class="comment-edit-form">
|
|
||||||
<textarea
|
|
||||||
[(ngModel)]="editCommentContent"
|
|
||||||
name="editComment"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
<div class="comment-edit-actions">
|
|
||||||
<button (click)="saveCommentEdit(game.id, comment.id)" class="btn btn-primary btn-xs">Save</button>
|
|
||||||
<button (click)="cancelCommentEdit()" class="btn btn-secondary btn-xs">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="comment-content" [innerHTML]="sanitizeService.sanitizeHtml(comment.content)"></div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="no-comments">No comments yet. Be the first to comment!</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1739,6 +1699,23 @@ export class GamesListComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCommentEdit(gameId: string, event: { commentId: string; content: string }) {
|
||||||
|
this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({
|
||||||
|
next: (updatedComment) => {
|
||||||
|
this.comments.set({
|
||||||
|
...this.comments(),
|
||||||
|
[gameId]: (this.comments()[gameId] || []).map(c =>
|
||||||
|
c.id === event.commentId ? updatedComment : c
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommentsSignal(gameId: string) {
|
||||||
|
return signal(this.comments()[gameId] || []);
|
||||||
|
}
|
||||||
|
|
||||||
// Suggestion methods
|
// Suggestion methods
|
||||||
toggleSuggestForm() {
|
toggleSuggestForm() {
|
||||||
this.showSuggestForm.update(v => !v);
|
this.showSuggestForm.update(v => !v);
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ import { ReportModalComponent } from '../report-modal/report-modal.component';
|
|||||||
|
|
||||||
@if (reportModalOpen()) {
|
@if (reportModalOpen()) {
|
||||||
<app-report-modal
|
<app-report-modal
|
||||||
[reportedUserId]="profile()!.id"
|
[reportType]="'profile'"
|
||||||
|
[targetId]="profile()!.id"
|
||||||
[reportedUsername]="profile()!.displayName || profile()!.username"
|
[reportedUsername]="profile()!.displayName || profile()!.username"
|
||||||
(closeModal)="closeReportModal()"
|
(closeModal)="closeReportModal()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, inject, signal, input, output } from '@angular/core';
|
import { Component, inject, signal, input, output, computed } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ReportService } from '../../services/report.service';
|
import { ReportService } from '../../services/report.service';
|
||||||
|
import { CommentReportService } from '../../services/comment-report.service';
|
||||||
import { ToastService } from '../../services/toast.service';
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { ReportReason } from '@library/shared-types';
|
import { ReportReason } from '@library/shared-types';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ import { ReportReason } from '@library/shared-types';
|
|||||||
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
|
<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-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="modal-title">Report Profile</h2>
|
<h2 id="modal-title">{{ modalTitle() }}</h2>
|
||||||
<button
|
<button
|
||||||
class="close-button"
|
class="close-button"
|
||||||
(click)="onClose()"
|
(click)="onClose()"
|
||||||
@@ -32,8 +33,7 @@ import { ReportReason } from '@library/shared-types';
|
|||||||
|
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="report-info">
|
<p class="report-info">
|
||||||
You are reporting <strong>{{ reportedUsername() }}</strong>.
|
{{ reportInfo() }}
|
||||||
Please provide a reason and details for this report.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
|
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
|
||||||
@@ -294,11 +294,13 @@ import { ReportReason } from '@library/shared-types';
|
|||||||
})
|
})
|
||||||
export class ReportModalComponent {
|
export class ReportModalComponent {
|
||||||
private reportService = inject(ReportService);
|
private reportService = inject(ReportService);
|
||||||
|
private commentReportService = inject(CommentReportService);
|
||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
|
|
||||||
// Inputs
|
// Inputs
|
||||||
reportedUserId = input.required<string>();
|
reportType = input.required<'profile' | 'comment'>();
|
||||||
reportedUsername = input.required<string>();
|
targetId = input.required<string>(); // userId for profile, commentId for comment
|
||||||
|
reportedUsername = input<string>(''); // Only used for profile reports
|
||||||
|
|
||||||
// Outputs
|
// Outputs
|
||||||
closeModal = output<void>();
|
closeModal = output<void>();
|
||||||
@@ -308,6 +310,19 @@ export class ReportModalComponent {
|
|||||||
details = signal<string>('');
|
details = signal<string>('');
|
||||||
submitting = signal<boolean>(false);
|
submitting = signal<boolean>(false);
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
modalTitle = computed(() => {
|
||||||
|
return this.reportType() === 'profile' ? 'Report Profile' : 'Report Comment';
|
||||||
|
});
|
||||||
|
|
||||||
|
reportInfo = computed(() => {
|
||||||
|
if (this.reportType() === 'profile') {
|
||||||
|
return `You are reporting ${this.reportedUsername()}. Please provide a reason and details for this report.`;
|
||||||
|
} else {
|
||||||
|
return 'You are reporting this comment. Please provide a reason and details for this report.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Expose enum for template
|
// Expose enum for template
|
||||||
ReportReason = ReportReason;
|
ReportReason = ReportReason;
|
||||||
|
|
||||||
@@ -347,18 +362,30 @@ export class ReportModalComponent {
|
|||||||
|
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
|
|
||||||
|
if (this.reportType() === 'profile') {
|
||||||
this.reportService.createReport(
|
this.reportService.createReport(
|
||||||
this.reportedUserId(),
|
this.targetId(),
|
||||||
this.selectedReason(),
|
this.selectedReason(),
|
||||||
this.details()
|
this.details()
|
||||||
).subscribe({
|
).subscribe(this.getSubscribeHandlers());
|
||||||
|
} else {
|
||||||
|
this.commentReportService.createReport({
|
||||||
|
reportedCommentId: this.targetId(),
|
||||||
|
reason: this.selectedReason() as ReportReason,
|
||||||
|
details: this.details(),
|
||||||
|
}).subscribe(this.getSubscribeHandlers());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSubscribeHandlers() {
|
||||||
|
return {
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.success('Report submitted successfully');
|
this.toastService.success('Report submitted successfully');
|
||||||
this.onClose();
|
this.onClose();
|
||||||
},
|
},
|
||||||
error: (err: { status?: number; error?: { error?: string } }) => {
|
error: (err: { status?: number; error?: { error?: string } }) => {
|
||||||
console.error('Error submitting report:', err);
|
console.error('Error submitting report:', err);
|
||||||
// Check if it's a conflict error (duplicate pending report)
|
// Check if it's a conflict error (duplicate pending report or rate limit)
|
||||||
if (err.status === 409 && err.error?.error) {
|
if (err.status === 409 && err.error?.error) {
|
||||||
this.toastService.error(err.error.error);
|
this.toastService.error(err.error.error);
|
||||||
} else {
|
} else {
|
||||||
@@ -366,6 +393,6 @@ export class ReportModalComponent {
|
|||||||
}
|
}
|
||||||
this.submitting.set(false);
|
this.submitting.set(false);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import type {
|
||||||
|
CommentReportWithDetails,
|
||||||
|
CreateCommentReportDto,
|
||||||
|
UpdateCommentReportDto,
|
||||||
|
ReportStatus,
|
||||||
|
} from '@library/shared-types';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class CommentReportService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly apiUrl = `${environment.apiUrl}/comment-reports`;
|
||||||
|
|
||||||
|
createReport(
|
||||||
|
dto: CreateCommentReportDto,
|
||||||
|
): Observable<CommentReportWithDetails> {
|
||||||
|
return this.http.post<CommentReportWithDetails>(this.apiUrl, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllReports(
|
||||||
|
status?: ReportStatus,
|
||||||
|
): Observable<CommentReportWithDetails[]> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (status) {
|
||||||
|
params['status'] = status;
|
||||||
|
}
|
||||||
|
return this.http.get<CommentReportWithDetails[]>(this.apiUrl, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
getReportById(id: string): Observable<CommentReportWithDetails> {
|
||||||
|
return this.http.get<CommentReportWithDetails>(`${this.apiUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReport(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateCommentReportDto,
|
||||||
|
): Observable<CommentReportWithDetails> {
|
||||||
|
return this.http.put<CommentReportWithDetails>(`${this.apiUrl}/${id}`, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ interface Comment {
|
|||||||
artId?: string;
|
artId?: string;
|
||||||
showId?: string;
|
showId?: string;
|
||||||
mangaId?: string;
|
mangaId?: string;
|
||||||
|
hasPendingReports?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,53 @@ export interface UpdateReportDto {
|
|||||||
status: ReportStatus;
|
status: ReportStatus;
|
||||||
reviewNotes?: string;
|
reviewNotes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentReport {
|
||||||
|
id: string;
|
||||||
|
reportedCommentId: string;
|
||||||
|
reporterId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewNotes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReportWithDetails extends CommentReport {
|
||||||
|
reportedComment: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
rawContent?: string;
|
||||||
|
userId: string;
|
||||||
|
user: {
|
||||||
|
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 CreateCommentReportDto {
|
||||||
|
reportedCommentId: string;
|
||||||
|
reason: ReportReason;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentReportDto {
|
||||||
|
status: ReportStatus;
|
||||||
|
reviewNotes?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user