feat: implement user profiles with achievements and primary badge system #58

Merged
naomi merged 17 commits from feat/user-profiles into main 2026-02-19 22:21:18 -08:00
11 changed files with 1068 additions and 114 deletions
Showing only changes of commit c514849f12 - Show all commits
+43 -20
View File
@@ -205,33 +205,36 @@ model User {
suggestions Suggestion[] suggestions Suggestion[]
likes Like[] likes Like[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
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")
} }
model Comment { model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId id String @id @default(auto()) @map("_id") @db.ObjectId
content String content String
rawContent String? rawContent String?
userId String @db.ObjectId userId String @db.ObjectId
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
gameId String? @db.ObjectId gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id]) game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id]) book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id]) music Music? @relation(fields: [musicId], references: [id])
artId String? @db.ObjectId artId String? @db.ObjectId
art Art? @relation(fields: [artId], references: [id]) art Art? @relation(fields: [artId], references: [id])
showId String? @db.ObjectId showId String? @db.ObjectId
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])
createdAt DateTime @default(now()) reports CommentReport[]
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model AuditLog { model AuditLog {
@@ -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])
}
+140
View File
@@ -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;
}
}
+26 -20
View File
@@ -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);
this.reportService.createReport( if (this.reportType() === 'profile') {
this.reportedUserId(), this.reportService.createReport(
this.selectedReason(), this.targetId(),
this.details() this.selectedReason(),
).subscribe({ this.details()
).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);
}
}
+14 -13
View File
@@ -15,19 +15,20 @@ interface CommentUser {
} }
interface Comment { interface Comment {
id: string; id: string;
content: string; content: string;
rawContent?: string; rawContent?: string;
userId: string; userId: string;
user: CommentUser; user: CommentUser;
gameId?: string; gameId?: string;
bookId?: string; bookId?: string;
musicId?: string; musicId?: string;
artId?: string; artId?: string;
showId?: string; showId?: string;
mangaId?: string; mangaId?: string;
createdAt: Date; hasPendingReports?: boolean;
updatedAt: Date; createdAt: Date;
updatedAt: Date;
} }
interface CreateCommentDto { interface CreateCommentDto {
+50
View File
@@ -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;
}