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:
13
DESIGN.md
13
DESIGN.md
@@ -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 的创建、更新、删除、排序。
|
||||||
- 全局配置项的创建、更新、删除、排序。
|
- 全局配置项的创建、更新、删除、排序。
|
||||||
- 语言的创建、更新、删除、排序。
|
- 语言的创建、更新、删除、排序。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: '更新',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user