generated from nhcarrigan/template
feat: ability to edit and delete comments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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<Comment> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,12 @@ import type { AuditLog, AuditAction, AuditCategory } from '@library/shared-types
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ auditService.getActionLabel(log.action) }}</td>
|
||||
<td class="details">{{ log.details ?? '-' }}</td>
|
||||
<td class="details" [class.expanded]="expandedRows()[log.id]" (click)="toggleRowExpand(log.id)">
|
||||
<span class="details-content">{{ log.details ?? '-' }}</span>
|
||||
@if (log.details && log.details.length > 50) {
|
||||
<span class="expand-hint">{{ expandedRows()[log.id] ? '(click to collapse)' : '(click to expand)' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge" [class.success]="log.success" [class.failed]="!log.success">
|
||||
{{ 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<Record<string, boolean>>({});
|
||||
|
||||
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]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,11 +239,28 @@ import { Art, CreateArtDto, UpdateArtDto, Comment } from '@library/shared-types'
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(art.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(art.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(art.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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
newArt: Partial<CreateArtDto> = {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,11 +346,28 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment } from '@librar
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(book.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(book.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(book.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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
// Image upload state
|
||||
newBookImagePreview = signal<string | null>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -311,11 +311,28 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment } from '@librar
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
// Image upload state
|
||||
newGameImagePreview = signal<string | null>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -311,11 +311,28 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment } from '@li
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(manga.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(manga.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(manga.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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
newMangaImagePreview = signal<string | null>(null);
|
||||
editMangaImagePreview = signal<string | null>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,11 +384,28 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(music.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(music.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(music.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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
// Image upload state
|
||||
newMusicImagePreview = signal<string | null>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -307,11 +307,28 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment } fro
|
||||
}
|
||||
<span class="comment-author">{{ comment.user.username }}</span>
|
||||
<span class="comment-date">{{ formatDate(comment.createdAt) }}</span>
|
||||
@if (authService.isAdmin()) {
|
||||
@if (canEditComment(comment)) {
|
||||
<button (click)="startEditComment(show.id, comment)" class="btn btn-secondary btn-xs">Edit</button>
|
||||
}
|
||||
@if (canDeleteComment(comment)) {
|
||||
<button (click)="deleteComment(show.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(show.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>
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
expandedComments = signal<Record<string, boolean>>({});
|
||||
newCommentContent: Record<string, string> = {};
|
||||
editingCommentId = signal<string | null>(null);
|
||||
editCommentContent = '';
|
||||
|
||||
newShowImagePreview = signal<string | null>(null);
|
||||
editShowImagePreview = signal<string | null>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Comment> {
|
||||
return this.api.put<Comment>(`/games/${gameId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
updateCommentOnBook(bookId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/books/${bookId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
updateCommentOnMusic(musicId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/music/${musicId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
updateCommentOnArt(artId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/art/${artId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
updateCommentOnShow(showId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/shows/${showId}/comments/${commentId}`, { content });
|
||||
}
|
||||
|
||||
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
||||
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface CommentUser {
|
||||
export interface Comment {
|
||||
id: string;
|
||||
content: string;
|
||||
rawContent?: string;
|
||||
userId: string;
|
||||
user: CommentUser;
|
||||
gameId?: string;
|
||||
|
||||
Reference in New Issue
Block a user