From e20be5f4e83950d150ff39e268400434be3e3ddd Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 4 Feb 2026 17:33:34 -0800 Subject: [PATCH] feat: ability to edit and delete comments --- api/prisma/schema.prisma | 2 + api/src/app/routes/art/index.ts | 59 +++++++++++-- api/src/app/routes/books/index.ts | 59 +++++++++++-- api/src/app/routes/games/index.ts | 57 ++++++++++-- api/src/app/routes/manga/index.ts | 54 +++++++++++- api/src/app/routes/music/index.ts | 59 +++++++++++-- api/src/app/routes/shows/index.ts | 54 +++++++++++- api/src/app/services/comment.service.ts | 55 ++++++++++++ .../components/admin/admin-audit.component.ts | 37 +++++++- .../components/art/art-gallery.component.ts | 87 ++++++++++++++++++- .../components/books/books-list.component.ts | 87 ++++++++++++++++++- .../components/games/games-list.component.ts | 80 ++++++++++++++++- .../components/manga/manga-list.component.ts | 80 ++++++++++++++++- .../components/music/music-list.component.ts | 87 ++++++++++++++++++- .../components/shows/shows-list.component.ts | 80 ++++++++++++++++- .../src/app/services/comments.service.ts | 24 +++++ shared-types/src/lib/audit.types.ts | 1 + shared-types/src/lib/comment.types.ts | 1 + 18 files changed, 922 insertions(+), 41 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index ebc2739..1e2f483 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -161,6 +161,7 @@ model User { model Comment { 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 @@ -198,6 +199,7 @@ enum AuditAction { LOGOUT LOGIN_FAILED COMMENT_CREATE + COMMENT_UPDATE COMMENT_DELETE ENTRY_CREATE ENTRY_UPDATE diff --git a/api/src/app/routes/art/index.ts b/api/src/app/routes/art/index.ts index 63f5b1b..4c32b73 100644 --- a/api/src/app/routes/art/index.ts +++ b/api/src/app/routes/art/index.ts @@ -144,20 +144,69 @@ const artRoutes: FastifyPluginAsync = async (app) => { ); /** - * Delete comment (admin only). + * Update comment (owner or admin). */ - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "art", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "art", + resourceId: id, + details: `Updated comment ${commentId} on art`, + }); + return comment; + } + ); + + /** + * Delete comment (owner or admin). + */ + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "art", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "art", resourceId: id, details: `Deleted comment ${commentId} from art`, diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index 3cf7275..5e8a818 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -144,20 +144,69 @@ const booksRoutes: FastifyPluginAsync = async (app) => { ); /** - * Delete comment (admin only). + * Update comment (owner or admin). */ - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "book", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "book", + resourceId: id, + details: `Updated comment ${commentId} on book`, + }); + return comment; + } + ); + + /** + * Delete comment (owner or admin). + */ + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "book", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "book", resourceId: id, details: `Deleted comment ${commentId} from book`, diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index 5d77843..5b5b66a 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -129,19 +129,66 @@ const gamesRoutes: FastifyPluginAsync = async (app) => { } ); - // Delete comment (admin only) - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + // Update comment (owner or admin) + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "game", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "game", + resourceId: id, + details: `Updated comment ${commentId} on game`, + }); + return comment; + } + ); + + // Delete comment (owner or admin) + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "game", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "game", resourceId: id, details: `Deleted comment ${commentId} from game`, diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts index 8705395..1f46d63 100644 --- a/api/src/app/routes/manga/index.ts +++ b/api/src/app/routes/manga/index.ts @@ -122,18 +122,64 @@ const mangaRoutes: FastifyPluginAsync = async (app) => { } ); - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "manga", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "manga", + resourceId: id, + details: `Updated comment ${commentId} on manga`, + }); + return comment; + } + ); + + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "manga", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "manga", resourceId: id, details: `Deleted comment ${commentId} from manga`, diff --git a/api/src/app/routes/music/index.ts b/api/src/app/routes/music/index.ts index ca3ee3a..eae0662 100644 --- a/api/src/app/routes/music/index.ts +++ b/api/src/app/routes/music/index.ts @@ -144,20 +144,69 @@ const musicRoutes: FastifyPluginAsync = async (app) => { ); /** - * Delete comment (admin only). + * Update comment (owner or admin). */ - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "music", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "music", + resourceId: id, + details: `Updated comment ${commentId} on music`, + }); + return comment; + } + ); + + /** + * Delete comment (owner or admin). + */ + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "music", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "music", resourceId: id, details: `Deleted comment ${commentId} from music`, diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts index 2f189bd..2512ea3 100644 --- a/api/src/app/routes/shows/index.ts +++ b/api/src/app/routes/shows/index.ts @@ -122,18 +122,64 @@ const showsRoutes: FastifyPluginAsync = async (app) => { } ); - app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>( + app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>( "/:id/comments/:commentId", { - preValidation: [app.authenticate, adminGuard], + preValidation: [app.authenticate], preHandler: [app.csrfProtection], }, - async (request) => { + async (request, reply) => { const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "show", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only edit your own comments" }); + } + + const comment = await commentService.updateComment(commentId, request.body.content); + await AuditService.logFromRequest(request, { + action: AuditAction.COMMENT_UPDATE, + category: AuditCategory.CONTENT, + resourceType: "show", + resourceId: id, + details: `Updated comment ${commentId} on show`, + }); + return comment; + } + ); + + app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>( + "/:id/comments/:commentId", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id, commentId } = request.params; + const userId = request.user.id; + const isAdmin = request.user.isAdmin; + + const verification = await commentService.verifyCommentOwnership(commentId, "show", id); + + if (!verification.exists) { + return reply.code(404).send({ error: "Comment not found" }); + } + + if (verification.comment?.userId !== userId && !isAdmin) { + return reply.code(403).send({ error: "You can only delete your own comments" }); + } + await commentService.deleteComment(commentId); await AuditService.logFromRequest(request, { action: AuditAction.COMMENT_DELETE, - category: AuditCategory.ADMIN, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, resourceType: "show", resourceId: id, details: `Deleted comment ${commentId} from show`, diff --git a/api/src/app/services/comment.service.ts b/api/src/app/services/comment.service.ts index 1f32f92..6ecb104 100644 --- a/api/src/app/services/comment.service.ts +++ b/api/src/app/services/comment.service.ts @@ -54,6 +54,7 @@ export class CommentService { return { id: comment.id, content: comment.content, + rawContent: comment.rawContent || undefined, userId: comment.userId, user: { id: comment.user.id, @@ -107,6 +108,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, gameId, }, @@ -124,6 +126,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, bookId, }, @@ -141,6 +144,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, musicId, }, @@ -167,6 +171,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, artId, }, @@ -193,6 +198,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, showId, }, @@ -219,6 +225,7 @@ export class CommentService { const comment = await this.prisma.comment.create({ data: { content: sanitizedContent, + rawContent: data.content, userId, mangaId, }, @@ -227,9 +234,57 @@ export class CommentService { return this.mapComment(comment); } + async getCommentById(commentId: string) { + return this.prisma.comment.findUnique({ + where: { id: commentId }, + include: { user: true }, + }); + } + + async updateComment( + commentId: string, + content: string + ): Promise { + const sanitizedContent = this.sanitizeMarkdown(content); + const comment = await this.prisma.comment.update({ + where: { id: commentId }, + data: { + content: sanitizedContent, + rawContent: content, + }, + include: { user: true }, + }); + return this.mapComment(comment); + } + async deleteComment(commentId: string): Promise { await this.prisma.comment.delete({ where: { id: commentId }, }); } + + async verifyCommentOwnership( + commentId: string, + resourceType: "game" | "book" | "music" | "art" | "show" | "manga", + resourceId: string + ): Promise<{ exists: boolean; comment?: { userId: string } }> { + const fieldMap = { + game: "gameId", + book: "bookId", + music: "musicId", + art: "artId", + show: "showId", + manga: "mangaId", + }; + + const comment = await this.prisma.comment.findFirst({ + where: { + id: commentId, + [fieldMap[resourceType]]: resourceId, + }, + select: { userId: true }, + }); + + return comment ? { exists: true, comment } : { exists: false }; + } } diff --git a/apps/frontend/src/app/components/admin/admin-audit.component.ts b/apps/frontend/src/app/components/admin/admin-audit.component.ts index a2c3409..8a9057d 100644 --- a/apps/frontend/src/app/components/admin/admin-audit.component.ts +++ b/apps/frontend/src/app/components/admin/admin-audit.component.ts @@ -104,7 +104,12 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types {{ auditService.getActionLabel(log.action) }} - {{ log.details ?? '-' }} + + {{ log.details ?? '-' }} + @if (log.details && log.details.length > 50) { + {{ expandedRows()[log.id] ? '(click to collapse)' : '(click to expand)' }} + } + {{ log.success ? '✓' : '✗' }} @@ -258,11 +263,33 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types .details { max-width: 400px; + cursor: pointer; + position: relative; + } + + .details .details-content { + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .details.expanded .details-content { + white-space: normal; + word-break: break-word; + } + + .details .expand-hint { + display: block; + font-size: 0.7rem; + color: #9ca3af; + margin-top: 0.25rem; + } + + .details:hover { + background: #f3f4f6; + } + .status-badge { display: inline-block; width: 24px; @@ -320,6 +347,7 @@ export class AdminAuditComponent implements OnInit { loading = signal(true); currentPage = signal(1); totalPages = signal(1); + expandedRows = signal>({}); selectedCategory = ''; selectedAction = ''; @@ -370,4 +398,11 @@ export class AdminAuditComponent implements OnInit { const d = new Date(date); return d.toLocaleString(); } + + toggleRowExpand(logId: string) { + this.expandedRows.set({ + ...this.expandedRows(), + [logId]: !this.expandedRows()[logId] + }); + } } diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index 4adc988..ea91f54 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -239,11 +239,28 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -639,6 +656,32 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types' margin-bottom: 1rem; font-size: 0.9rem; } + + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 2px solid var(--witch-lavender); + border-radius: 4px; + font-size: 0.9rem; + background-color: var(--witch-moon); + color: var(--witch-purple); + resize: vertical; + } + + .comment-edit-form textarea:focus { + outline: none; + border-color: var(--witch-rose); + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } `] }) export class ArtGalleryComponent implements OnInit { @@ -658,6 +701,8 @@ export class ArtGalleryComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; newArt: Partial = { title: '', @@ -840,4 +885,42 @@ export class ArtGalleryComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(artId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(artId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnArt(artId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [artId]: (this.comments()[artId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 37886ab..83af491 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -346,11 +346,28 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -721,6 +738,32 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar font-size: 0.9rem; } + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 2px solid var(--witch-lavender); + border-radius: 4px; + font-size: 0.9rem; + background-color: var(--witch-moon); + color: var(--witch-purple); + resize: vertical; + } + + .comment-edit-form textarea:focus { + outline: none; + border-color: var(--witch-rose); + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -772,6 +815,8 @@ export class BooksListComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; // Image upload state newBookImagePreview = signal(null); @@ -1038,4 +1083,42 @@ export class BooksListComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(bookId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(bookId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnBook(bookId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [bookId]: (this.comments()[bookId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } \ No newline at end of file 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 f5e9e55..f512572 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -311,11 +311,28 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -600,6 +617,25 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar font-size: 0.9rem; } + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; + resize: vertical; + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -651,6 +687,8 @@ export class GamesListComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; // Image upload state newGameImagePreview = signal(null); @@ -913,4 +951,42 @@ export class GamesListComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(gameId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(gameId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnGame(gameId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [gameId]: (this.comments()[gameId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index e65fea3..b525045 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -311,11 +311,28 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -602,6 +619,25 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li font-size: 0.9rem; } + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; + resize: vertical; + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -652,6 +688,8 @@ export class MangaListComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; newMangaImagePreview = signal(null); editMangaImagePreview = signal(null); @@ -909,4 +947,42 @@ export class MangaListComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(mangaId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(mangaId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnManga(mangaId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [mangaId]: (this.comments()[mangaId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 8682b0d..27e9101 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -384,11 +384,28 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -793,6 +810,32 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment font-size: 0.9rem; } + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 2px solid var(--witch-lavender); + border-radius: 4px; + font-size: 0.9rem; + background-color: var(--witch-moon); + color: var(--witch-purple); + resize: vertical; + } + + .comment-edit-form textarea:focus { + outline: none; + border-color: var(--witch-rose); + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -845,6 +888,8 @@ export class MusicListComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; // Image upload state newMusicImagePreview = signal(null); @@ -1137,4 +1182,42 @@ export class MusicListComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(musicId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(musicId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnMusic(musicId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [musicId]: (this.comments()[musicId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index f3c7090..acf36d5 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -307,11 +307,28 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro } {{ comment.user.username }} {{ formatDate(comment.createdAt) }} - @if (authService.isAdmin()) { + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { } -
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { +
+ } } @empty {
No comments yet. Be the first to comment!
@@ -597,6 +614,25 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro font-size: 0.9rem; } + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 0.9rem; + resize: vertical; + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + .image-preview { margin-top: 0.5rem; display: flex; @@ -647,6 +683,8 @@ export class ShowsListComponent implements OnInit { commentsLoading = signal>({}); expandedComments = signal>({}); newCommentContent: Record = {}; + editingCommentId = signal(null); + editCommentContent = ''; newShowImagePreview = signal(null); editShowImagePreview = signal(null); @@ -914,4 +952,42 @@ export class ShowsListComponent implements OnInit { } }); } + + 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(); + } + + startEditComment(showId: string, comment: Comment) { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + cancelCommentEdit() { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + saveCommentEdit(showId: string, commentId: string) { + if (!this.editCommentContent.trim()) return; + + this.commentsService.updateCommentOnShow(showId, commentId, this.editCommentContent).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [showId]: (this.comments()[showId] || []).map(c => + c.id === commentId ? updatedComment : c + ) + }); + this.cancelCommentEdit(); + } + }); + } } diff --git a/apps/frontend/src/app/services/comments.service.ts b/apps/frontend/src/app/services/comments.service.ts index 1cb3ee6..4490492 100644 --- a/apps/frontend/src/app/services/comments.service.ts +++ b/apps/frontend/src/app/services/comments.service.ts @@ -86,4 +86,28 @@ export class CommentsService { deleteCommentFromManga(mangaId: string, commentId: string): Observable<{ success: boolean }> { return this.api.delete<{ success: boolean }>(`/manga/${mangaId}/comments/${commentId}`); } + + updateCommentOnGame(gameId: string, commentId: string, content: string): Observable { + return this.api.put(`/games/${gameId}/comments/${commentId}`, { content }); + } + + updateCommentOnBook(bookId: string, commentId: string, content: string): Observable { + return this.api.put(`/books/${bookId}/comments/${commentId}`, { content }); + } + + updateCommentOnMusic(musicId: string, commentId: string, content: string): Observable { + return this.api.put(`/music/${musicId}/comments/${commentId}`, { content }); + } + + updateCommentOnArt(artId: string, commentId: string, content: string): Observable { + return this.api.put(`/art/${artId}/comments/${commentId}`, { content }); + } + + updateCommentOnShow(showId: string, commentId: string, content: string): Observable { + return this.api.put(`/shows/${showId}/comments/${commentId}`, { content }); + } + + updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable { + return this.api.put(`/manga/${mangaId}/comments/${commentId}`, { content }); + } } diff --git a/shared-types/src/lib/audit.types.ts b/shared-types/src/lib/audit.types.ts index 8f8c2ac..a4da961 100644 --- a/shared-types/src/lib/audit.types.ts +++ b/shared-types/src/lib/audit.types.ts @@ -3,6 +3,7 @@ export enum AuditAction { LOGOUT = "LOGOUT", LOGIN_FAILED = "LOGIN_FAILED", COMMENT_CREATE = "COMMENT_CREATE", + COMMENT_UPDATE = "COMMENT_UPDATE", COMMENT_DELETE = "COMMENT_DELETE", ENTRY_CREATE = "ENTRY_CREATE", ENTRY_UPDATE = "ENTRY_UPDATE", diff --git a/shared-types/src/lib/comment.types.ts b/shared-types/src/lib/comment.types.ts index 191342e..a67b250 100644 --- a/shared-types/src/lib/comment.types.ts +++ b/shared-types/src/lib/comment.types.ts @@ -13,6 +13,7 @@ export interface CommentUser { export interface Comment { id: string; content: string; + rawContent?: string; userId: string; user: CommentUser; gameId?: string;