diff --git a/DESIGN.md b/DESIGN.md index e6863a6..36590ca 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -362,6 +362,8 @@ Life Post 可配置: - Post 内容正文 - 创建者、最后编辑者、创建时间、最后编辑时间 +- 评论 +- 评论回复:仅支持回复顶层评论,不做无限嵌套 前台行为: @@ -369,14 +371,19 @@ Life Post 可配置: - 信息流按创建时间倒序展示。 - 已注册并完成邮箱验证的用户可以发布 Life Post。 - 作者本人可以编辑、删除自己的 Life Post。 -- 当前没有点赞、评论、图片上传、转发、分页、置顶或单独审核流程。 +- 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。 +- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 +- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 +- 当前没有点赞、图片上传、转发、分页、置顶或单独审核流程。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 API 暴露边界: - Life Post 作者信息只返回 `id` 和 `displayName`。 +- Life Comment 作者信息只返回 `id` 和 `displayName`。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - 非作者不能编辑或删除其他用户的 Life Post。 +- 非作者不能删除其他用户的 Life Comment。 ## 前端交互与 UI @@ -432,6 +439,10 @@ API 暴露边界: - `POST /api/life-posts` - `PUT /api/life-posts/:id` - `DELETE /api/life-posts/:id` +- Life Comment 的创建,以及作者本人对 Life Comment 的删除。 + - `POST /api/life-posts/:postId/comments` + - `POST /api/life-posts/:postId/comments/:commentId/replies` + - `DELETE /api/life-comments/:id` - 每日 CheckList 的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 81ad32c..afa653d 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -134,6 +134,24 @@ ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title; CREATE INDEX IF NOT EXISTS life_posts_created_at_idx ON life_posts(created_at DESC, id DESC); +CREATE TABLE IF NOT EXISTS life_post_comments ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, + parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL, + body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + deleted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS life_post_comments_post_idx + ON life_post_comments(post_id, created_at, id); + +CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx + ON life_post_comments(parent_comment_id, created_at, id); + CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 7e5d16a..8b584a6 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -108,6 +108,38 @@ type LifePostPayload = { body: string; }; +type LifeCommentPayload = { + body: string; +}; + +type LifeCommentRow = { + id: number; + postId: number; + parentCommentId: number | null; + body: string; + deleted: boolean; + createdAt: Date; + updatedAt: Date; + author: { id: number; displayName: string } | null; +}; + +type LifeComment = LifeCommentRow & { + replies: LifeComment[]; +}; + +type LifePostRow = { + id: number; + body: string; + createdAt: Date; + updatedAt: Date; + author: { id: number; displayName: string } | null; + updatedBy: { id: number; displayName: string } | null; +}; + +type LifePost = LifePostRow & { + comments: LifeComment[]; +}; + type HabitatPayload = { name: string; translations: TranslationInput; @@ -1181,6 +1213,15 @@ function cleanLifePostPayload(payload: Record): LifePostPayload return { body }; } +function cleanLifeCommentPayload(payload: Record): LifeCommentPayload { + const body = cleanName(payload.body, 'Please enter a comment'); + if (body.length > 1000) { + throw validationError('Comment is too long'); + } + + return { body }; +} + function lifePostProjection(): string { return ` SELECT @@ -1202,21 +1243,115 @@ function lifePostProjection(): string { `; } -export function listLifePosts() { - return query(` +function lifeCommentProjection(whereClause: string): string { + return ` + SELECT + lc.id, + lc.post_id AS "postId", + lc.parent_comment_id AS "parentCommentId", + CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body, + lc.deleted_at IS NOT NULL AS deleted, + lc.created_at AS "createdAt", + lc.updated_at AS "updatedAt", + CASE + WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL + ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) + END AS author + FROM life_post_comments lc + LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id + ${whereClause} + `; +} + +function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] { + const comments = new Map(); + const topLevelComments: LifeComment[] = []; + + for (const row of rows) { + comments.set(row.id, { ...row, replies: [] }); + } + + for (const comment of comments.values()) { + if (comment.parentCommentId === null) { + topLevelComments.push(comment); + continue; + } + + const parent = comments.get(comment.parentCommentId); + if (parent?.parentCommentId === null) { + parent.replies.push(comment); + } else { + topLevelComments.push(comment); + } + } + + return topLevelComments; +} + +async function lifeCommentsForPosts(postIds: number[]): Promise> { + const commentsByPost = new Map(); + if (postIds.length === 0) { + return commentsByPost; + } + + const rows = await query( + ` + ${lifeCommentProjection('WHERE lc.post_id = ANY($1::integer[])')} + ORDER BY lc.created_at, lc.id + `, + [postIds] + ); + + for (const postId of postIds) { + commentsByPost.set(postId, buildLifeCommentTree(rows.filter((comment) => comment.postId === postId))); + } + + return commentsByPost; +} + +async function getLifeCommentById(id: number): Promise { + const row = await queryOne( + ` + ${lifeCommentProjection('WHERE lc.id = $1')} + `, + [id] + ); + + return row ? { ...row, replies: [] } : null; +} + +export async function listLifePosts(): Promise { + const posts = await query(` ${lifePostProjection()} ORDER BY lp.created_at DESC, lp.id DESC `); + + const commentsByPost = await lifeCommentsForPosts(posts.map((post) => post.id)); + + return posts.map((post) => ({ + ...post, + comments: commentsByPost.get(post.id) ?? [] + })); } -async function getLifePostById(id: number) { - return queryOne( +async function getLifePostById(id: number): Promise { + const post = await queryOne( ` ${lifePostProjection()} WHERE lp.id = $1 `, [id] ); + + if (!post) { + return null; + } + + const commentsByPost = await lifeCommentsForPosts([post.id]); + return { + ...post, + comments: commentsByPost.get(post.id) ?? [] + }; } export async function createLifePost(payload: Record, userId: number) { @@ -1265,6 +1400,63 @@ export async function deleteLifePost(id: number, userId: number) { return Boolean(result); } +export async function createLifeComment(postId: number, payload: Record, userId: number) { + const cleanPayload = cleanLifeCommentPayload(payload); + + const result = await queryOne<{ id: number }>( + ` + INSERT INTO life_post_comments (post_id, body, created_by_user_id) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1) + RETURNING id + `, + [postId, cleanPayload.body, userId] + ); + + return result ? getLifeCommentById(result.id) : null; +} + +export async function createLifeCommentReply( + postId: number, + commentId: number, + payload: Record, + userId: number +) { + const cleanPayload = cleanLifeCommentPayload(payload); + + const result = await queryOne<{ id: number }>( + ` + INSERT INTO life_post_comments (post_id, parent_comment_id, body, created_by_user_id) + SELECT lc.post_id, lc.id, $3, $4 + FROM life_post_comments lc + WHERE lc.post_id = $1 + AND lc.id = $2 + AND lc.parent_comment_id IS NULL + AND lc.deleted_at IS NULL + RETURNING id + `, + [postId, commentId, cleanPayload.body, userId] + ); + + return result ? getLifeCommentById(result.id) : null; +} + +export async function deleteLifeComment(id: number, userId: number) { + const result = await queryOne<{ id: number }>( + ` + UPDATE life_post_comments + SET deleted_at = now(), deleted_by_user_id = $2, updated_at = now() + WHERE id = $1 + AND created_by_user_id = $2 + AND deleted_at IS NULL + RETURNING id + `, + [id, userId] + ); + + return Boolean(result); +} + export function isConfigType(type: string): type is ConfigType { return Object.hasOwn(configDefinitions, type); } diff --git a/backend/src/server.ts b/backend/src/server.ts index 54e54f7..20b0f27 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,8 @@ import { createHabitat, createItem, createLanguage, + createLifeComment, + createLifeCommentReply, createLifePost, createPokemon, createRecipe, @@ -18,6 +20,7 @@ import { deleteHabitat, deleteItem, deleteLanguage, + deleteLifeComment, deleteLifePost, deletePokemon, deleteRecipe, @@ -182,6 +185,31 @@ app.post('/api/life-posts', async (request, reply) => { return user ? reply.code(201).send(await createLifePost(request.body as Record, user.id)) : undefined; }); +app.post('/api/life-posts/:postId/comments', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { postId } = request.params as { postId: string }; + const comment = await createLifeComment(Number(postId), request.body as Record, user.id); + return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' }); +}); + +app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { postId, commentId } = request.params as { postId: string; commentId: string }; + const comment = await createLifeCommentReply( + Number(postId), + Number(commentId), + request.body as Record, + user.id + ); + return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' }); +}); + app.put('/api/life-posts/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { @@ -202,6 +230,16 @@ app.delete('/api/life-posts/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); +app.delete('/api/life-comments/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const deleted = await deleteLifeComment(Number(id), user.id); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/pokemon', async (request) => listPokemon(request.query as Record, requestLocale(request)) ); diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f998afc..2379be1 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -229,6 +229,26 @@ const messages = { composerPrompt: 'What would you like to share?', bodyLabel: 'Post', bodyPlaceholder: 'Share a thought, tip, or discovery...', + comments: 'Comments', + commentsCount: '{count} comments', + comment: 'Comment', + hideComments: 'Hide comments', + commentPlaceholder: 'Write a comment...', + commentReplyPlaceholder: 'Write a reply...', + postComment: 'Post comment', + postingComment: 'Posting comment', + reply: 'Reply', + postReply: 'Post reply', + postingReply: 'Posting reply', + cancelReply: 'Cancel reply', + noComments: 'No comments yet', + deleteComment: 'Delete comment', + deleteCommentConfirm: 'Delete this comment?', + commentDeleted: 'Comment deleted', + commentRequired: 'Please enter a comment.', + commentFailed: 'Comment failed', + replyFailed: 'Reply failed', + deleteCommentFailed: 'Delete comment failed', publish: 'Post', publishing: 'Posting', update: 'Update', @@ -542,6 +562,26 @@ const messages = { composerPrompt: '想分享什么?', bodyLabel: '动态内容', bodyPlaceholder: '分享一段想法、心得或发现……', + comments: '评论', + commentsCount: '{count} 条评论', + comment: '评论', + hideComments: '收起评论', + commentPlaceholder: '写下评论……', + commentReplyPlaceholder: '写下回复……', + postComment: '发表评论', + postingComment: '评论中', + reply: '回复', + postReply: '发布回复', + postingReply: '回复中', + cancelReply: '取消回复', + noComments: '暂无评论', + deleteComment: '删除评论', + deleteCommentConfirm: '确认删除这条评论?', + commentDeleted: '评论已删除', + commentRequired: '请输入评论内容。', + commentFailed: '评论失败', + replyFailed: '回复失败', + deleteCommentFailed: '删除评论失败', publish: '发布', publishing: '发布中', update: '更新', diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 8b1fdc7..79aed93 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -8,6 +8,7 @@ export const iconCheck: AppIcon = 'mdi:check'; export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline'; export const iconChevronDown: AppIcon = 'mdi:chevron-down'; export const iconClose: AppIcon = 'mdi:close'; +export const iconComment: AppIcon = 'mdi:comment-outline'; export const iconDelete: AppIcon = 'mdi:trash-can-outline'; export const iconDragHandle: AppIcon = 'mdi:drag'; export const iconEdit: AppIcon = 'mdi:pencil-outline'; @@ -23,6 +24,7 @@ export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline'; export const iconPokemon: AppIcon = 'mdi:pokeball'; export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline'; export const iconRegister: AppIcon = 'mdi:account-plus-outline'; +export const iconReply: AppIcon = 'mdi:reply-outline'; export const iconSave: AppIcon = 'mdi:content-save-outline'; export const iconSuccess: AppIcon = 'mdi:check-circle-outline'; export const iconTranslate: AppIcon = 'mdi:translate'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2ec3c99..f4537b6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -180,6 +180,19 @@ export interface LifePost { updatedAt: string; author: UserSummary | null; updatedBy: UserSummary | null; + comments: LifeComment[]; +} + +export interface LifeComment { + id: number; + postId: number; + parentCommentId: number | null; + body: string; + deleted: boolean; + createdAt: string; + updatedAt: string; + author: UserSummary | null; + replies: LifeComment[]; } export interface RecipeDetail extends Recipe { @@ -288,6 +301,10 @@ export interface LifePostPayload { body: string; } +export interface LifeCommentPayload { + body: string; +} + export function buildQuery(params: Record): string { const search = new URLSearchParams(); @@ -422,6 +439,11 @@ export const api = { updateLifePost: (id: string | number, payload: LifePostPayload) => sendJson(`/api/life-posts/${id}`, 'PUT', payload), deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`), + createLifeComment: (postId: string | number, payload: LifeCommentPayload) => + sendJson(`/api/life-posts/${postId}/comments`, 'POST', payload), + createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => + sendJson(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload), + deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`), createDailyChecklistItem: (payload: DailyChecklistPayload) => sendJson('/api/admin/daily-checklist', 'POST', payload), updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 72f2f94..3e676e0 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1316,6 +1316,212 @@ button:disabled, white-space: pre-wrap; } +.life-post__engagement { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 8px; + border-top: 1px solid var(--line); +} + +.life-post__engagement-button, +.life-post__comment-count { + min-height: 44px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 9px; + border-radius: var(--radius-control); + background: transparent; + color: var(--ink-soft); + font-weight: 900; + cursor: pointer; +} + +.life-post__engagement-button:hover, +.life-post__comment-count:hover, +.life-post__engagement-button[aria-expanded="true"] { + background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft)); + color: var(--pokemon-blue-deep); +} + +.life-post__engagement-button .ui-icon { + width: 18px; + height: 18px; +} + +.life-post__comment-count { + color: var(--muted); + font-size: 14px; +} + +.life-comments { + display: grid; + gap: 12px; +} + +.life-comments__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.life-comments__header h3 { + margin: 0; + color: var(--ink); + font-family: var(--font-display); + font-size: 18px; + font-weight: 950; +} + +.life-comments__header span { + min-width: 32px; + padding: 2px 8px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-soft); + color: var(--muted); + font-size: 13px; + font-weight: 900; + text-align: center; +} + +.life-comment-form { + display: grid; + gap: 8px; +} + +.life-comment-form textarea { + min-height: 78px; +} + +.life-comment-form--reply { + margin-top: 8px; + padding-left: 12px; + border-left: 3px solid color-mix(in srgb, var(--pokemon-blue) 34%, var(--line)); +} + +.life-comment-list, +.life-comment-replies { + display: grid; + gap: 10px; +} + +.life-comment { + min-width: 0; +} + +.life-comment__main, +.life-comment--reply { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 10px; +} + +.life-comment-replies { + margin-top: 10px; + padding-left: 16px; + border-left: 2px solid var(--line); +} + +.life-comment__avatar { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 2px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + color: var(--pokemon-blue-deep); + font-family: var(--font-display); + font-size: 15px; + font-weight: 950; +} + +.life-comment.is-deleted .life-comment__avatar { + color: var(--muted); +} + +.life-comment__content { + display: grid; + gap: 5px; + min-width: 0; +} + +.life-comment__meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} + +.life-comment__meta strong { + color: var(--ink); + font-weight: 950; +} + +.life-comment.is-deleted .life-comment__meta strong { + color: var(--muted); + font-style: italic; +} + +.life-comment__meta time { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.life-comment__body { + margin: 0; + color: var(--ink-soft); + line-height: 1.55; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.life-comment.is-deleted .life-comment__body, +.life-comments__empty { + color: var(--muted); + font-style: italic; +} + +.life-comment__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.life-comment__link-button { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 0; + background: transparent; + color: var(--pokemon-blue); + font-size: 13px; + font-weight: 900; + cursor: pointer; +} + +.life-comment__link-button:hover { + color: var(--pokemon-blue-deep); + text-decoration: underline; +} + +.life-comment__link-button .ui-icon { + width: 15px; + height: 15px; +} + +.life-comments__empty { + margin: 0; +} + .reorderable-row { position: relative; flex-wrap: wrap; @@ -2490,6 +2696,15 @@ button:disabled, justify-content: flex-start; } + .life-comment-replies { + padding-left: 10px; + } + + .life-comment__main, + .life-comment--reply { + gap: 8px; + } + .appearance-list li { grid-template-columns: 1fr; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 3fad91b..b335ca2 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -5,13 +5,14 @@ import { useI18n } from 'vue-i18n'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import StatusMessage from '../components/StatusMessage.vue'; -import { iconCancel, iconDelete, iconEdit, iconLife, iconSave } from '../icons'; +import { iconCancel, iconComment, iconDelete, iconEdit, iconLife, iconReply, iconSave } from '../icons'; import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, + type LifeComment, type LifePost } from '../services/api'; @@ -25,6 +26,12 @@ const body = ref(''); const editingPostId = ref(null); const formError = ref(''); const loadError = ref(''); +const commentBodies = ref>({}); +const replyBodies = ref>({}); +const replyTargetId = ref(null); +const expandedComments = ref>({}); +const commentBusyKey = ref(''); +const commentErrors = ref>({}); const bodyInput = ref(null); const skeletonPostCount = 3; let removeAuthListener: (() => void) | null = null; @@ -117,6 +124,60 @@ function canManage(post: LifePost) { return currentUser.value?.id === post.author?.id; } +function canManageComment(comment: LifeComment) { + return !comment.deleted && currentUser.value?.id === comment.author?.id; +} + +function commentKey(postId: number) { + return `post-${postId}`; +} + +function replyKey(commentId: number) { + return `reply-${commentId}`; +} + +function commentCount(post: LifePost) { + return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0); +} + +function areCommentsExpanded(postId: number) { + return expandedComments.value[postId] === true; +} + +function setCommentsExpanded(postId: number, expanded: boolean) { + expandedComments.value = { + ...expandedComments.value, + [postId]: expanded + }; +} + +function toggleComments(postId: number) { + setCommentsExpanded(postId, !areCommentsExpanded(postId)); +} + +function isCommentBusy(key: string) { + return commentBusyKey.value === key; +} + +function commentAuthorName(comment: LifeComment) { + return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown'); +} + +function commentInitial(comment: LifeComment) { + const name = commentAuthorName(comment); + return name.slice(0, 1).toUpperCase(); +} + +function setCommentError(key: string, message: string) { + commentErrors.value = { ...commentErrors.value, [key]: message }; +} + +function clearCommentError(key: string) { + const nextErrors = { ...commentErrors.value }; + delete nextErrors[key]; + commentErrors.value = nextErrors; +} + function startEdit(post: LifePost) { editingPostId.value = post.id; body.value = post.body; @@ -142,6 +203,99 @@ async function deletePost(post: LifePost) { } } +function startReply(comment: LifeComment) { + replyTargetId.value = comment.id; + clearCommentError(replyKey(comment.id)); +} + +function cancelReply(commentId: number) { + replyTargetId.value = null; + replyBodies.value[commentId] = ''; + clearCommentError(replyKey(commentId)); +} + +async function submitComment(post: LifePost) { + const key = commentKey(post.id); + const nextBody = (commentBodies.value[post.id] ?? '').trim(); + if (!nextBody) { + setCommentError(key, t('pages.life.commentRequired')); + return; + } + + commentBusyKey.value = key; + clearCommentError(key); + + try { + const comment = await api.createLifeComment(post.id, { body: nextBody }); + post.comments.push(comment); + commentBodies.value[post.id] = ''; + setCommentsExpanded(post.id, true); + } catch (error) { + setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed')); + } finally { + commentBusyKey.value = ''; + } +} + +async function submitReply(post: LifePost, comment: LifeComment) { + const key = replyKey(comment.id); + const nextBody = (replyBodies.value[comment.id] ?? '').trim(); + if (!nextBody) { + setCommentError(key, t('pages.life.commentRequired')); + return; + } + + commentBusyKey.value = key; + clearCommentError(key); + + try { + const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody }); + comment.replies.push(reply); + setCommentsExpanded(post.id, true); + cancelReply(comment.id); + } catch (error) { + setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed')); + } finally { + commentBusyKey.value = ''; + } +} + +function markCommentDeleted(comments: LifeComment[], id: number): boolean { + for (const comment of comments) { + if (comment.id === id) { + comment.deleted = true; + comment.body = ''; + comment.author = null; + return true; + } + + if (markCommentDeleted(comment.replies, id)) { + return true; + } + } + + return false; +} + +async function deleteComment(post: LifePost, comment: LifeComment) { + if (!window.confirm(t('pages.life.deleteCommentConfirm'))) { + return; + } + + const key = replyKey(comment.id); + clearCommentError(key); + + try { + await api.deleteLifeComment(comment.id); + markCommentDeleted(post.comments, comment.id); + if (replyTargetId.value === comment.id) { + cancelReply(comment.id); + } + } catch (error) { + setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed')); + } +} + function formatPostTime(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { @@ -268,6 +422,162 @@ onUnmounted(() => {

{{ post.body }}

+ + + +
+
+

{{ t('pages.life.comments') }}

+ {{ commentCount(post) }} +
+ +
+
+ + +
+ + +
+ +
+
+
+ +
+
+ {{ commentAuthorName(comment) }} + +
+

{{ comment.body }}

+ +
+ + +
+ + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ {{ commentAuthorName(reply) }} + +
+

{{ reply.body }}

+
+ +
+ +
+
+
+
+
+
+
+ +

{{ t('pages.life.noComments') }}

+