From c514849f1266611163909d8aaaaf46cff1d5b75e Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 19:06:49 -0800 Subject: [PATCH] feat: implement comment reporting system Added comprehensive comment reporting infrastructure similar to profile reporting. API Changes: - New CommentReport model in Prisma schema with relations to User and Comment - CommentReportService with CRUD operations, duplicate prevention, and rate limiting - API routes at /comment-reports for creating and managing comment reports - Updated CommentService to include hasPendingReports flag for all comments Frontend Changes: - Created shared CommentDisplayComponent for reusable comment display with report button - Updated ReportModalComponent to handle both profile and comment reports - CommentReportService for API communication - Integrated CommentDisplayComponent into games-list component - Comments with pending reports show "[comment pending admin review]" message Features: - Users can report comments they didn't write - Duplicate prevention (one pending report per comment per user) - Rate limiting (5 pending reports maximum per user) - Admins can review and action comment reports - Comments are hidden during review to prevent abuse Remaining Work: - Need to integrate CommentDisplayComponent into remaining media components (books, music, art, shows, manga) - Need to extend admin-reports page to display comment reports alongside profile reports --- api/prisma/schema.prisma | 63 ++- api/src/app/routes/comment-reports/index.ts | 140 +++++++ .../app/services/comment-report.service.ts | 369 ++++++++++++++++++ api/src/app/services/comment.service.ts | 46 ++- .../comment-display.component.ts | 310 +++++++++++++++ .../components/games/games-list.component.ts | 71 ++-- .../components/profile/profile.component.ts | 3 +- .../report-modal/report-modal.component.ts | 53 ++- .../app/services/comment-report.service.ts | 50 +++ shared-types/src/lib/comment.types.ts | 27 +- shared-types/src/lib/report.types.ts | 50 +++ 11 files changed, 1068 insertions(+), 114 deletions(-) create mode 100644 api/src/app/routes/comment-reports/index.ts create mode 100644 api/src/app/services/comment-report.service.ts create mode 100644 apps/frontend/src/app/components/comment-display/comment-display.component.ts create mode 100644 apps/frontend/src/app/services/comment-report.service.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 6437f61..f6bed6d 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -205,33 +205,36 @@ model User { suggestions Suggestion[] likes Like[] refreshTokens RefreshToken[] - reportsMade ProfileReport[] @relation("Reporter") - reportsReceived ProfileReport[] @relation("ReportedUser") - reportsReviewed ProfileReport[] @relation("Reviewer") + reportsMade ProfileReport[] @relation("Reporter") + reportsReceived ProfileReport[] @relation("ReportedUser") + reportsReviewed ProfileReport[] @relation("Reviewer") + commentReportsMade CommentReport[] @relation("CommentReporter") + commentReportsReviewed CommentReport[] @relation("CommentReviewer") @@index([slug], map: "User_slug_key") } model Comment { - id String @id @default(auto()) @map("_id") @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId content String rawContent String? - userId String @db.ObjectId - user User @relation(fields: [userId], references: [id]) - gameId String? @db.ObjectId - game Game? @relation(fields: [gameId], references: [id]) - bookId String? @db.ObjectId - book Book? @relation(fields: [bookId], references: [id]) - musicId String? @db.ObjectId - music Music? @relation(fields: [musicId], references: [id]) - artId String? @db.ObjectId - art Art? @relation(fields: [artId], references: [id]) - showId String? @db.ObjectId - show Show? @relation(fields: [showId], references: [id]) - mangaId String? @db.ObjectId - manga Manga? @relation(fields: [mangaId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + gameId String? @db.ObjectId + game Game? @relation(fields: [gameId], references: [id]) + bookId String? @db.ObjectId + book Book? @relation(fields: [bookId], references: [id]) + musicId String? @db.ObjectId + music Music? @relation(fields: [musicId], references: [id]) + artId String? @db.ObjectId + art Art? @relation(fields: [artId], references: [id]) + showId String? @db.ObjectId + show Show? @relation(fields: [showId], references: [id]) + mangaId String? @db.ObjectId + manga Manga? @relation(fields: [mangaId], references: [id]) + reports CommentReport[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model AuditLog { @@ -369,3 +372,23 @@ model ProfileReport { @@index([reporterId]) @@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]) +} diff --git a/api/src/app/routes/comment-reports/index.ts b/api/src/app/routes/comment-reports/index.ts new file mode 100644 index 0000000..a784ff0 --- /dev/null +++ b/api/src/app/routes/comment-reports/index.ts @@ -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; diff --git a/api/src/app/services/comment-report.service.ts b/api/src/app/services/comment-report.service.ts new file mode 100644 index 0000000..8f0b6a2 --- /dev/null +++ b/api/src/app/services/comment-report.service.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + const count = await this.prisma.commentReport.count({ + where: { + reportedCommentId: commentId, + status: PrismaReportStatus.PENDING, + }, + }); + + return count > 0; + } +} diff --git a/api/src/app/services/comment.service.ts b/api/src/app/services/comment.service.ts index cc3f24d..d47ce0a 100644 --- a/api/src/app/services/comment.service.ts +++ b/api/src/app/services/comment.service.ts @@ -50,7 +50,12 @@ export class CommentService { }); } - private mapComment(comment: any): Comment { + private async mapComment(comment: any): Promise { + // Check if comment has pending reports + const hasPendingReports = comment.reports + ? comment.reports.some((report: any) => report.status === "PENDING") + : false; + return { id: comment.id, content: comment.content, @@ -71,6 +76,7 @@ export class CommentService { artId: comment.artId || undefined, showId: comment.showId || undefined, mangaId: comment.mangaId || undefined, + hasPendingReports, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; @@ -79,28 +85,28 @@ export class CommentService { async getCommentsForGame(gameId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { gameId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async getCommentsForBook(bookId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { bookId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async getCommentsForMusic(musicId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { musicId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForGame( @@ -116,7 +122,7 @@ export class CommentService { userId, gameId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -134,7 +140,7 @@ export class CommentService { userId, bookId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -152,7 +158,7 @@ export class CommentService { userId, musicId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -160,10 +166,10 @@ export class CommentService { async getCommentsForArt(artId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { artId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForArt( @@ -179,7 +185,7 @@ export class CommentService { userId, artId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -187,10 +193,10 @@ export class CommentService { async getCommentsForShow(showId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { showId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForShow( @@ -206,7 +212,7 @@ export class CommentService { userId, showId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -214,10 +220,10 @@ export class CommentService { async getCommentsForManga(mangaId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { mangaId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForManga( @@ -233,7 +239,7 @@ export class CommentService { userId, mangaId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -256,7 +262,7 @@ export class CommentService { content: sanitizedContent, rawContent: content, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } diff --git a/apps/frontend/src/app/components/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts new file mode 100644 index 0000000..879aa51 --- /dev/null +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -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: ` +
+ @if (comments().length > 0) { + @for (comment of comments(); track comment.id) { +
+
+ @if (comment.user.avatar) { + + } + {{ comment.user.username }} + @if (comment.user.inDiscord) { + Discord + } + @if (comment.user.isVip) { + VIP + } + @if (comment.user.isMod) { + Mod + } + @if (comment.user.isStaff) { + Staff + } + {{ formatDate(comment.createdAt) }} + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { + + } + @if (canReportComment(comment)) { + + } +
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { + @if (comment.hasPendingReports) { +
[comment pending admin review]
+ } @else { +
+ } + } +
+ } + } @else { +
No comments yet. Be the first to comment!
+ } +
+ + @if (showReportModal()) { + + } + `, + 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([]); + @Output() onEdit = new EventEmitter<{ commentId: string; content: string }>(); + @Output() onDelete = new EventEmitter(); + + editingCommentId = signal(null); + editCommentContent = ''; + showReportModal = signal(false); + reportingCommentId = signal(''); + + 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(''); + } +} diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index 4c6ee2b..c592498 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.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'; @Component({ selector: 'app-games-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -608,52 +609,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti @if (commentsLoading()[game.id]) {
Loading comments...
} @else { - @for (comment of comments()[game.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } + }
} @@ -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 toggleSuggestForm() { this.showSuggestForm.update(v => !v); diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index c877d23..62fb84e 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -158,7 +158,8 @@ import { ReportModalComponent } from '../report-modal/report-modal.component'; @if (reportModalOpen()) { diff --git a/apps/frontend/src/app/components/report-modal/report-modal.component.ts b/apps/frontend/src/app/components/report-modal/report-modal.component.ts index 9cff1a9..c1fd79f 100644 --- a/apps/frontend/src/app/components/report-modal/report-modal.component.ts +++ b/apps/frontend/src/app/components/report-modal/report-modal.component.ts @@ -4,10 +4,11 @@ * @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 { FormsModule } from '@angular/forms'; import { ReportService } from '../../services/report.service'; +import { CommentReportService } from '../../services/comment-report.service'; import { ToastService } from '../../services/toast.service'; import { ReportReason } from '@library/shared-types'; @@ -19,7 +20,7 @@ import { ReportReason } from '@library/shared-types';