feat(life): add comments and replies to life posts

Introduce life_post_comments table for nested comment threads
Add API endpoints to create, reply to, and delete comments
Implement frontend UI with engagement counts and collapsible threads
This commit is contained in:
2026-05-01 21:29:25 +08:00
parent cd1891cc82
commit a683982b80
9 changed files with 854 additions and 6 deletions

View File

@@ -362,6 +362,8 @@ Life Post 可配置:
- Post 内容正文 - Post 内容正文
- 创建者、最后编辑者、创建时间、最后编辑时间 - 创建者、最后编辑者、创建时间、最后编辑时间
- 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套
前台行为: 前台行为:
@@ -369,14 +371,19 @@ Life Post 可配置:
- 信息流按创建时间倒序展示。 - 信息流按创建时间倒序展示。
- 已注册并完成邮箱验证的用户可以发布 Life Post。 - 已注册并完成邮箱验证的用户可以发布 Life Post。
- 作者本人可以编辑、删除自己的 Life Post。 - 作者本人可以编辑、删除自己的 Life Post。
- 当前没有点赞、评论、图片上传、转发、分页、置顶或单独审核流程 - 已注册并完成邮箱验证的用户可以评论 Life Post并回复顶层评论
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- 当前没有点赞、图片上传、转发、分页、置顶或单独审核流程。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界: API 暴露边界:
- Life Post 作者信息只返回 `id``displayName` - Life Post 作者信息只返回 `id``displayName`
- Life Comment 作者信息只返回 `id``displayName`
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
- 非作者不能编辑或删除其他用户的 Life Post。 - 非作者不能编辑或删除其他用户的 Life Post。
- 非作者不能删除其他用户的 Life Comment。
## 前端交互与 UI ## 前端交互与 UI
@@ -432,6 +439,10 @@ API 暴露边界:
- `POST /api/life-posts` - `POST /api/life-posts`
- `PUT /api/life-posts/:id` - `PUT /api/life-posts/:id`
- `DELETE /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 的创建、更新、删除、排序。 - 每日 CheckList 的创建、更新、删除、排序。
- 全局配置项的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。
- 语言的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。

View File

@@ -134,6 +134,24 @@ ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
ON life_posts(created_at DESC, id DESC); 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 ( CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,

View File

@@ -108,6 +108,38 @@ type LifePostPayload = {
body: string; 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 = { type HabitatPayload = {
name: string; name: string;
translations: TranslationInput; translations: TranslationInput;
@@ -1181,6 +1213,15 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
return { body }; return { body };
} }
function cleanLifeCommentPayload(payload: Record<string, unknown>): 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 { function lifePostProjection(): string {
return ` return `
SELECT SELECT
@@ -1202,21 +1243,115 @@ function lifePostProjection(): string {
`; `;
} }
export function listLifePosts() { function lifeCommentProjection(whereClause: string): string {
return query(` 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<number, LifeComment>();
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<Map<number, LifeComment[]>> {
const commentsByPost = new Map<number, LifeComment[]>();
if (postIds.length === 0) {
return commentsByPost;
}
const rows = await query<LifeCommentRow>(
`
${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<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
${lifeCommentProjection('WHERE lc.id = $1')}
`,
[id]
);
return row ? { ...row, replies: [] } : null;
}
export async function listLifePosts(): Promise<LifePost[]> {
const posts = await query<LifePostRow>(`
${lifePostProjection()} ${lifePostProjection()}
ORDER BY lp.created_at DESC, lp.id DESC 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) { async function getLifePostById(id: number): Promise<LifePost | null> {
return queryOne( const post = await queryOne<LifePostRow>(
` `
${lifePostProjection()} ${lifePostProjection()}
WHERE lp.id = $1 WHERE lp.id = $1
`, `,
[id] [id]
); );
if (!post) {
return null;
}
const commentsByPost = await lifeCommentsForPosts([post.id]);
return {
...post,
comments: commentsByPost.get(post.id) ?? []
};
} }
export async function createLifePost(payload: Record<string, unknown>, userId: number) { export async function createLifePost(payload: Record<string, unknown>, userId: number) {
@@ -1265,6 +1400,63 @@ export async function deleteLifePost(id: number, userId: number) {
return Boolean(result); return Boolean(result);
} }
export async function createLifeComment(postId: number, payload: Record<string, unknown>, 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<string, unknown>,
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 { export function isConfigType(type: string): type is ConfigType {
return Object.hasOwn(configDefinitions, type); return Object.hasOwn(configDefinitions, type);
} }

View File

@@ -10,6 +10,8 @@ import {
createHabitat, createHabitat,
createItem, createItem,
createLanguage, createLanguage,
createLifeComment,
createLifeCommentReply,
createLifePost, createLifePost,
createPokemon, createPokemon,
createRecipe, createRecipe,
@@ -18,6 +20,7 @@ import {
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
deleteLifeComment,
deleteLifePost, deleteLifePost,
deletePokemon, deletePokemon,
deleteRecipe, 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<string, unknown>, user.id)) : undefined; return user ? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, 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<string, unknown>, 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<string, unknown>,
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) => { app.put('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
if (!user) { 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' }); 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) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );

View File

@@ -229,6 +229,26 @@ const messages = {
composerPrompt: 'What would you like to share?', composerPrompt: 'What would you like to share?',
bodyLabel: 'Post', bodyLabel: 'Post',
bodyPlaceholder: 'Share a thought, tip, or discovery...', 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', publish: 'Post',
publishing: 'Posting', publishing: 'Posting',
update: 'Update', update: 'Update',
@@ -542,6 +562,26 @@ const messages = {
composerPrompt: '想分享什么?', composerPrompt: '想分享什么?',
bodyLabel: '动态内容', bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……', bodyPlaceholder: '分享一段想法、心得或发现……',
comments: '评论',
commentsCount: '{count} 条评论',
comment: '评论',
hideComments: '收起评论',
commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……',
postComment: '发表评论',
postingComment: '评论中',
reply: '回复',
postReply: '发布回复',
postingReply: '回复中',
cancelReply: '取消回复',
noComments: '暂无评论',
deleteComment: '删除评论',
deleteCommentConfirm: '确认删除这条评论?',
commentDeleted: '评论已删除',
commentRequired: '请输入评论内容。',
commentFailed: '评论失败',
replyFailed: '回复失败',
deleteCommentFailed: '删除评论失败',
publish: '发布', publish: '发布',
publishing: '发布中', publishing: '发布中',
update: '更新', update: '更新',

View File

@@ -8,6 +8,7 @@ export const iconCheck: AppIcon = 'mdi:check';
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline'; export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
export const iconChevronDown: AppIcon = 'mdi:chevron-down'; export const iconChevronDown: AppIcon = 'mdi:chevron-down';
export const iconClose: AppIcon = 'mdi:close'; export const iconClose: AppIcon = 'mdi:close';
export const iconComment: AppIcon = 'mdi:comment-outline';
export const iconDelete: AppIcon = 'mdi:trash-can-outline'; export const iconDelete: AppIcon = 'mdi:trash-can-outline';
export const iconDragHandle: AppIcon = 'mdi:drag'; export const iconDragHandle: AppIcon = 'mdi:drag';
export const iconEdit: AppIcon = 'mdi:pencil-outline'; 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 iconPokemon: AppIcon = 'mdi:pokeball';
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline'; export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
export const iconRegister: AppIcon = 'mdi:account-plus-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 iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline'; export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate'; export const iconTranslate: AppIcon = 'mdi:translate';

View File

@@ -180,6 +180,19 @@ export interface LifePost {
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
updatedBy: 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 { export interface RecipeDetail extends Recipe {
@@ -288,6 +301,10 @@ export interface LifePostPayload {
body: string; body: string;
} }
export interface LifeCommentPayload {
body: string;
}
export function buildQuery(params: Record<string, string | number | undefined>): string { export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
@@ -422,6 +439,11 @@ export const api = {
updateLifePost: (id: string | number, payload: LifePostPayload) => updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload), sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`), deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
createDailyChecklistItem: (payload: DailyChecklistPayload) => createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload), sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>

View File

@@ -1316,6 +1316,212 @@ button:disabled,
white-space: pre-wrap; 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 { .reorderable-row {
position: relative; position: relative;
flex-wrap: wrap; flex-wrap: wrap;
@@ -2490,6 +2696,15 @@ button:disabled,
justify-content: flex-start; justify-content: flex-start;
} }
.life-comment-replies {
padding-left: 10px;
}
.life-comment__main,
.life-comment--reply {
gap: 8px;
}
.appearance-list li { .appearance-list li {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -5,13 +5,14 @@ import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.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 { import {
api, api,
getAuthToken, getAuthToken,
onAuthTokenChange, onAuthTokenChange,
setAuthToken, setAuthToken,
type AuthUser, type AuthUser,
type LifeComment,
type LifePost type LifePost
} from '../services/api'; } from '../services/api';
@@ -25,6 +26,12 @@ const body = ref('');
const editingPostId = ref<number | null>(null); const editingPostId = ref<number | null>(null);
const formError = ref(''); const formError = ref('');
const loadError = ref(''); const loadError = ref('');
const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({});
const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null); const bodyInput = ref<HTMLTextAreaElement | null>(null);
const skeletonPostCount = 3; const skeletonPostCount = 3;
let removeAuthListener: (() => void) | null = null; let removeAuthListener: (() => void) | null = null;
@@ -117,6 +124,60 @@ function canManage(post: LifePost) {
return currentUser.value?.id === post.author?.id; 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) { function startEdit(post: LifePost) {
editingPostId.value = post.id; editingPostId.value = post.id;
body.value = post.body; 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) { function formatPostTime(value: string) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
@@ -268,6 +422,162 @@ onUnmounted(() => {
</header> </header>
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div class="life-post__engagement">
<button
class="life-post__engagement-button"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</button>
<button
class="life-post__comment-count"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
</button>
</div>
<section
v-if="areCommentsExpanded(post.id)"
:id="`life-comments-${post.id}`"
class="life-comments"
:aria-label="t('pages.life.comments')"
>
<div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
<div class="field">
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
<textarea
:id="`life-comment-${post.id}`"
v-model="commentBodies[post.id]"
maxlength="1000"
:placeholder="t('pages.life.commentPlaceholder')"
></textarea>
</div>
<p v-if="commentErrors[commentKey(post.id)]" class="life-form__error" role="alert">
{{ commentErrors[commentKey(post.id)] }}
</p>
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isCommentBusy(commentKey(post.id)) || !(commentBodies[post.id] ?? '').trim()"
type="submit"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ isCommentBusy(commentKey(post.id)) ? t('pages.life.postingComment') : t('pages.life.postComment') }}
</button>
</form>
<div v-if="post.comments.length" class="life-comment-list">
<article
v-for="comment in post.comments"
:key="comment.id"
class="life-comment"
:class="{ 'is-deleted': comment.deleted }"
>
<div class="life-comment__main">
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
</div>
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
<div v-if="!comment.deleted" class="life-comment__actions">
<button v-if="canPost" class="life-comment__link-button" type="button" @click="startReply(comment)">
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.reply') }}
</button>
<button
v-if="canManageComment(comment)"
class="life-comment__link-button"
type="button"
@click="deleteComment(post, comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deleteComment') }}
</button>
</div>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }}
</p>
<form
v-if="canPost && replyTargetId === comment.id"
class="life-comment-form life-comment-form--reply"
@submit.prevent="submitReply(post, comment)"
>
<div class="field">
<label :for="`life-reply-${comment.id}`">{{ t('pages.life.reply') }}</label>
<textarea
:id="`life-reply-${comment.id}`"
v-model="replyBodies[comment.id]"
maxlength="1000"
:placeholder="t('pages.life.commentReplyPlaceholder')"
></textarea>
</div>
<div class="life-form__actions">
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isCommentBusy(replyKey(comment.id)) || !(replyBodies[comment.id] ?? '').trim()"
type="submit"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.postingReply') : t('pages.life.postReply') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.cancelReply') }}
</button>
</div>
</form>
<div v-if="comment.replies.length" class="life-comment-replies">
<article
v-for="reply in comment.replies"
:key="reply.id"
class="life-comment life-comment--reply"
:class="{ 'is-deleted': reply.deleted }"
>
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
</div>
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="life-comment__actions">
<button class="life-comment__link-button" type="button" @click="deleteComment(post, reply)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deleteComment') }}
</button>
</div>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}
</p>
</div>
</article>
</div>
</div>
</div>
</article>
</div>
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
</section>
</article> </article>
</div> </div>