From 960898c858465a86b873ff906a12c5f789261a15 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 15:20:05 +0800 Subject: [PATCH] feat(comments): paginate life post and entity discussion comments Implement cursor-based pagination for Life and Entity comments Optimize Life Post queries to return comment counts and previews Add "Load more" functionality to frontend discussion panels --- DESIGN.md | 8 +- backend/src/queries.ts | 256 ++++++++++++++++-- backend/src/server.ts | 13 +- .../src/components/EntityDiscussionPanel.vue | 51 +++- frontend/src/services/api.ts | 38 ++- frontend/src/views/LifeView.vue | 159 ++++++++++- frontend/src/views/UserProfileView.vue | 2 +- system-wordings.ts | 6 + 8 files changed, 488 insertions(+), 45 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index eed73d7..18223e3 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -292,6 +292,7 @@ - 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。 - 被删除实体的讨论会随实体删除一并清理。 - 讨论按创建时间正序展示。 +- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items`、`nextCursor`、`hasMore`、`total`。 - 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 - API 对外只返回评论作者的 `id` 和 `displayName`。 - API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。 @@ -623,6 +624,7 @@ Life Post 可配置: - 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。 - 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 +- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 @@ -637,7 +639,8 @@ API 暴露边界: - Life Post 标签只返回 `id` 和按当前语言解析后的 `name`。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 - Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 -- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。 +- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。 +- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。 - 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。 @@ -725,11 +728,12 @@ API 暴露边界: - `GET /api/recipes` - `GET /api/recipes/:id` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选。 +- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论。 - `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 - `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。 - `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。 -- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。 +- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。 认证 API: diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 87f6e6e..555702a 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -196,12 +196,19 @@ type EntityDiscussionCommentRow = { body: string; deleted: boolean; createdAt: Date; + createdAtCursor?: string; updatedAt: Date; author: { id: number; displayName: string } | null; }; -type EntityDiscussionComment = EntityDiscussionCommentRow & { +type EntityDiscussionComment = Omit & { replies: EntityDiscussionComment[]; }; +type EntityDiscussionCommentsPage = { + items: EntityDiscussionComment[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +}; type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; type LifeReactionCounts = Record; @@ -213,11 +220,12 @@ type LifeCommentRow = { body: string; deleted: boolean; createdAt: Date; + createdAtCursor?: string; updatedAt: Date; author: { id: number; displayName: string } | null; }; -type LifeComment = LifeCommentRow & { +type LifeComment = Omit & { replies: LifeComment[]; }; @@ -233,7 +241,8 @@ type LifePostRow = { }; type LifePost = Omit & { - comments: LifeComment[]; + commentPreview: LifeComment[]; + commentCount: number; reactionCounts: LifeReactionCounts; myReaction: LifeReactionType | null; }; @@ -253,6 +262,13 @@ type LifePostsPage = { hasMore: boolean; }; +type LifeCommentsPage = { + items: LifeComment[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +}; + type PublicProfileUser = { id: number; displayName: string; @@ -405,6 +421,9 @@ const defaultLocale = 'en'; const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/; const defaultLifePostLimit = 20; const maxLifePostLimit = 50; +const defaultCommentLimit = 20; +const maxCommentLimit = 50; +const lifeCommentPreviewLimit = 2; const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1)); const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com'; @@ -2267,6 +2286,16 @@ function cleanLifePostLimit(value: QueryValue): number { return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit; } +function cleanCommentLimit(value: QueryValue): number { + const rawLimit = asString(value); + if (rawLimit === undefined || rawLimit === '') { + return defaultCommentLimit; + } + + const limit = Number(rawLimit); + return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit; +} + function decodeLifePostCursor(value: QueryValue): LifePostCursor | null { const rawCursor = asString(value); if (!rawCursor) { @@ -2336,7 +2365,8 @@ function encodeUserCommentActivityCursor(cursor: UserCommentActivityCursor): str function hydrateLifePost( post: LifePostRow, - commentsByPost: Map, + commentPreviewByPost: Map, + commentCountsByPost: Map, countsByPost: Map, myReactionsByPost: Map ): LifePost { @@ -2348,7 +2378,8 @@ function hydrateLifePost( author: post.author, updatedBy: post.updatedBy, tags: post.tags, - comments: commentsByPost.get(post.id) ?? [], + commentPreview: commentPreviewByPost.get(post.id) ?? [], + commentCount: commentCountsByPost.get(post.id) ?? 0, reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), myReaction: myReactionsByPost.get(post.id) ?? null }; @@ -2363,6 +2394,7 @@ function lifeCommentProjection(whereClause: string): string { 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.created_at::text AS "createdAtCursor", lc.updated_at AS "updatedAt", CASE WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL @@ -2379,7 +2411,8 @@ function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] { const topLevelComments: LifeComment[] = []; for (const row of rows) { - comments.set(row.id, { ...row, replies: [] }); + const { createdAtCursor: _createdAtCursor, ...comment } = row; + comments.set(row.id, { ...comment, replies: [] }); } for (const comment of comments.values()) { @@ -2399,7 +2432,34 @@ function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] { return topLevelComments; } -async function lifeCommentsForPosts(postIds: number[]): Promise> { +async function lifeCommentCountsForPosts(postIds: number[]): Promise> { + const countsByPost = new Map(); + for (const postId of postIds) { + countsByPost.set(postId, 0); + } + + if (postIds.length === 0) { + return countsByPost; + } + + const rows = await query<{ postId: number; total: number }>( + ` + SELECT post_id AS "postId", COUNT(*)::integer AS total + FROM life_post_comments + WHERE post_id = ANY($1::integer[]) + GROUP BY post_id + `, + [postIds] + ); + + for (const row of rows) { + countsByPost.set(row.postId, row.total); + } + + return countsByPost; +} + +async function lifeCommentPreviewForPosts(postIds: number[]): Promise> { const commentsByPost = new Map(); if (postIds.length === 0) { return commentsByPost; @@ -2407,10 +2467,22 @@ async function lifeCommentsForPosts(postIds: number[]): Promise( ` - ${lifeCommentProjection('WHERE lc.post_id = ANY($1::integer[])')} - ORDER BY lc.created_at, lc.id + WITH preview_top AS ( + SELECT id + FROM ( + SELECT + lc.id, + ROW_NUMBER() OVER (PARTITION BY lc.post_id ORDER BY lc.created_at DESC, lc.id DESC) AS preview_rank + FROM life_post_comments lc + WHERE lc.post_id = ANY($1::integer[]) + AND lc.parent_comment_id IS NULL + ) ranked + WHERE preview_rank <= $2 + ) + ${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)')} + ORDER BY lc.post_id, lc.created_at, lc.id `, - [postIds] + [postIds, lifeCommentPreviewLimit] ); for (const postId of postIds) { @@ -2420,6 +2492,80 @@ async function lifeCommentsForPosts(postIds: number[]): Promise { + const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid'); + const cursor = decodeLifePostCursor(paramsQuery.cursor); + const limit = cleanCommentLimit(paramsQuery.limit); + const exists = await queryOne<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM life_posts + WHERE id = $1 + AND deleted_at IS NULL + ) AS exists + `, + [postId] + ); + + if (exists?.exists !== true) { + return null; + } + + const params: unknown[] = [postId]; + const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL']; + + if (cursor) { + params.push(cursor.createdAt, cursor.id); + topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`); + } + + params.push(limit + 1); + const topLevelRows = await query( + ` + ${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)} + ORDER BY lc.created_at, lc.id + LIMIT $${params.length} + `, + params + ); + const hasMore = topLevelRows.length > limit; + const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows; + const topLevelIds = topLevelComments.map((comment) => comment.id); + const replyRows = topLevelIds.length + ? await query( + ` + ${lifeCommentProjection('WHERE lc.parent_comment_id = ANY($1::integer[])')} + ORDER BY lc.created_at, lc.id + `, + [topLevelIds] + ) + : []; + const total = await queryOne<{ total: number }>( + ` + SELECT COUNT(*)::integer AS total + FROM life_post_comments + WHERE post_id = $1 + `, + [postId] + ); + + return { + items: buildLifeCommentTree([...topLevelComments, ...replyRows]), + nextCursor: + hasMore && topLevelComments.length > 0 + ? encodeProfileCursor({ + createdAt: + topLevelComments[topLevelComments.length - 1].createdAtCursor ?? + topLevelComments[topLevelComments.length - 1].createdAt.toISOString(), + id: topLevelComments[topLevelComments.length - 1].id + }) + : null, + hasMore, + total: total?.total ?? 0 + }; +} + async function lifeReactionsForPosts( postIds: number[], userId: number | null @@ -2487,7 +2633,12 @@ async function getLifeCommentById(id: number): Promise { [id] ); - return row ? { ...row, replies: [] } : null; + if (!row) { + return null; + } + + const { createdAtCursor: _createdAtCursor, ...comment } = row; + return { ...comment, replies: [] }; } async function listLifePostsWithFilters( @@ -2544,11 +2695,12 @@ async function listLifePostsWithFilters( const posts = hasMore ? rows.slice(0, limit) : rows; const postIds = posts.map((post) => post.id); - const commentsByPost = await lifeCommentsForPosts(postIds); + const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds); + const commentCountsByPost = await lifeCommentCountsForPosts(postIds); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId); return { - items: posts.map((post) => hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)), + items: posts.map((post) => hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)), nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null, hasMore }; @@ -2690,11 +2842,12 @@ async function hydrateLifePostsById( `, [postIds] ); - const commentsByPost = await lifeCommentsForPosts(postIds); + const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds); + const commentCountsByPost = await lifeCommentCountsForPosts(postIds); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId); for (const post of posts) { - postById.set(post.id, hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost)); + postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)); } return postById; @@ -2934,9 +3087,10 @@ async function getLifePostById(id: number, userId: number | null = null, locale return null; } - const commentsByPost = await lifeCommentsForPosts([post.id]); + const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id]); + const commentCountsByPost = await lifeCommentCountsForPosts([post.id]); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId); - return hydrateLifePost(post, commentsByPost, countsByPost, myReactionsByPost); + return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost); } async function replaceLifePostTags(client: DbClient, postId: number, tagIds: number[]): Promise { @@ -3180,6 +3334,7 @@ function entityDiscussionCommentProjection(whereClause: string): string { CASE WHEN edc.deleted_at IS NULL THEN edc.body ELSE '' END AS body, edc.deleted_at IS NOT NULL AS deleted, edc.created_at AS "createdAt", + edc.created_at::text AS "createdAtCursor", edc.updated_at AS "updatedAt", CASE WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL @@ -3196,7 +3351,8 @@ function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): E const topLevelComments: EntityDiscussionComment[] = []; for (const row of rows) { - comments.set(row.id, { ...row, replies: [] }); + const { createdAtCursor: _createdAtCursor, ...comment } = row; + comments.set(row.id, { ...comment, replies: [] }); } for (const comment of comments.values()) { @@ -3224,29 +3380,81 @@ async function getEntityDiscussionCommentById(id: number): Promise { + entityIdValue: number, + paramsQuery: QueryParams = {} +): Promise { const entityType = cleanDiscussionEntityType(entityTypeValue); const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid'); + const cursor = decodeLifePostCursor(paramsQuery.cursor); + const limit = cleanCommentLimit(paramsQuery.limit); if (!(await entityDiscussionExists(pool, entityType, entityId))) { return null; } - const rows = await query( + const params: unknown[] = [entityType, entityId]; + const topLevelConditions = ['edc.entity_type = $1', 'edc.entity_id = $2', 'edc.parent_comment_id IS NULL']; + + if (cursor) { + params.push(cursor.createdAt, cursor.id); + topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`); + } + + params.push(limit + 1); + const topLevelRows = await query( ` - ${entityDiscussionCommentProjection('WHERE edc.entity_type = $1 AND edc.entity_id = $2')} + ${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)} ORDER BY edc.created_at, edc.id + LIMIT $${params.length} + `, + params + ); + const hasMore = topLevelRows.length > limit; + const topLevelComments = hasMore ? topLevelRows.slice(0, limit) : topLevelRows; + const topLevelIds = topLevelComments.map((comment) => comment.id); + const replyRows = topLevelIds.length + ? await query( + ` + ${entityDiscussionCommentProjection('WHERE edc.parent_comment_id = ANY($1::integer[])')} + ORDER BY edc.created_at, edc.id + `, + [topLevelIds] + ) + : []; + const total = await queryOne<{ total: number }>( + ` + SELECT COUNT(*)::integer AS total + FROM entity_discussion_comments + WHERE entity_type = $1 + AND entity_id = $2 `, [entityType, entityId] ); - return buildEntityDiscussionCommentTree(rows); + return { + items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]), + nextCursor: + hasMore && topLevelComments.length > 0 + ? encodeProfileCursor({ + createdAt: + topLevelComments[topLevelComments.length - 1].createdAtCursor ?? + topLevelComments[topLevelComments.length - 1].createdAt.toISOString(), + id: topLevelComments[topLevelComments.length - 1].id + }) + : null, + hasMore, + total: total?.total ?? 0 + }; } export async function createEntityDiscussionComment( diff --git a/backend/src/server.ts b/backend/src/server.ts index 6490689..0f29c52 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -72,6 +72,7 @@ import { listDailyChecklistItems, listHabitats, listItems, + listLifeComments, listLanguages, listLifePosts, listPokemon, @@ -793,6 +794,12 @@ app.get('/api/life-posts', async (request) => { return listLifePosts(request.query as Record, user?.id ?? null, requestLocale(request)); }); +app.get('/api/life-posts/:postId/comments', async (request, reply) => { + const { postId } = request.params as { postId: string }; + const comments = await listLifeComments(Number(postId), request.query as Record); + return comments ? comments : notFound(reply, request); +}); + app.post('/api/life-posts', async (request, reply) => { const user = await requirePermissionWithRateLimits(request, reply, 'life.posts.create', 'communityWrite'); return user @@ -898,7 +905,11 @@ app.delete('/api/life-comments/:id', async (request, reply) => { app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => { const { entityType, entityId } = request.params as { entityType: string; entityId: string }; - const comments = await listEntityDiscussionComments(entityType, Number(entityId)); + const comments = await listEntityDiscussionComments( + entityType, + Number(entityId), + request.query as Record + ); return comments ? comments : notFound(reply, request); }); diff --git a/frontend/src/components/EntityDiscussionPanel.vue b/frontend/src/components/EntityDiscussionPanel.vue index fdbf1c1..cfe39e7 100644 --- a/frontend/src/components/EntityDiscussionPanel.vue +++ b/frontend/src/components/EntityDiscussionPanel.vue @@ -23,6 +23,7 @@ const { locale, t } = useI18n(); const comments = ref([]); const currentUser = ref(null); const loading = ref(true); +const loadingMore = ref(false); const authReady = ref(false); const body = ref(''); const replyBodies = ref>({}); @@ -33,8 +34,12 @@ const formError = ref(''); const commentErrors = ref>({}); const commentInput = ref(null); const commentMaxLength = 1000; +const discussionPageSize = 20; let requestId = 0; let removeAuthListener: (() => void) | null = null; +const nextCursor = ref(null); +const hasMoreComments = ref(false); +const commentTotal = ref(0); function can(permissionKey: string) { return currentUser.value?.permissions.includes(permissionKey) === true; @@ -42,7 +47,6 @@ function can(permissionKey: string) { const canComment = computed(() => can('discussions.comments.create')); const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length)); -const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0)); async function loadCurrentUser() { authReady.value = false; @@ -64,15 +68,34 @@ async function loadCurrentUser() { } } -async function loadDiscussion() { +function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) { + const ids = new Set(existing.map((comment) => comment.id)); + return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))]; +} + +async function loadDiscussion(reset = true) { + if (!reset && (loadingMore.value || !hasMoreComments.value)) { + return; + } + const nextRequestId = ++requestId; - loading.value = true; + if (reset) { + loading.value = true; + } else { + loadingMore.value = true; + } loadError.value = ''; try { - const rows = await api.entityDiscussion(props.entityType, props.entityId); + const page = await api.entityDiscussion(props.entityType, props.entityId, { + limit: discussionPageSize, + cursor: reset ? null : nextCursor.value + }); if (nextRequestId === requestId) { - comments.value = rows; + comments.value = reset ? page.items : mergeComments(comments.value, page.items); + nextCursor.value = page.nextCursor; + hasMoreComments.value = page.hasMore; + commentTotal.value = page.total; } } catch (error) { if (nextRequestId === requestId) { @@ -81,6 +104,7 @@ async function loadDiscussion() { } finally { if (nextRequestId === requestId) { loading.value = false; + loadingMore.value = false; } } } @@ -168,6 +192,7 @@ async function submitComment() { try { const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody }); comments.value = [...comments.value, comment]; + commentTotal.value += 1; body.value = ''; } catch (error) { formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed'); @@ -190,6 +215,7 @@ async function submitReply(comment: EntityDiscussionComment) { try { const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody }); comment.replies.push(reply); + commentTotal.value += 1; cancelReply(comment.id); } catch (error) { setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed')); @@ -239,6 +265,9 @@ watch( () => { resetComposer(); comments.value = []; + nextCursor.value = null; + hasMoreComments.value = false; + commentTotal.value = 0; void loadDiscussion(); } ); @@ -419,6 +448,18 @@ onUnmounted(() => { + +
+ +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 19aa786..eaaf4d1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -260,7 +260,8 @@ export interface LifePost { author: UserSummary | null; updatedBy: UserSummary | null; tags: NamedEntity[]; - comments: LifeComment[]; + commentPreview: LifeComment[]; + commentCount: number; reactionCounts: LifeReactionCounts; myReaction: LifeReactionType | null; } @@ -278,6 +279,11 @@ export interface LifePostsParams { tagId?: string | number; } +export interface CommentPageParams { + cursor?: string | null; + limit?: number; +} + export interface LifeComment { id: number; postId: number; @@ -290,6 +296,13 @@ export interface LifeComment { replies: LifeComment[]; } +export interface LifeCommentsPage { + items: LifeComment[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +} + export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; editHistory: EditHistoryEntry[]; @@ -566,6 +579,13 @@ export interface EntityDiscussionComment { replies: EntityDiscussionComment[]; } +export interface EntityDiscussionCommentsPage { + items: EntityDiscussionComment[]; + nextCursor: string | null; + hasMore: boolean; + total: number; +} + export interface UserCommentActivity { id: number; source: ProfileCommentSource; @@ -833,11 +853,23 @@ export const api = { deleteLifeReaction: (id: string | number) => deleteAndGetJson(`/api/life-posts/${id}/reaction`), createLifeComment: (postId: string | number, payload: LifeCommentPayload) => sendJson(`/api/life-posts/${postId}/comments`, 'POST', payload), + lifeComments: (postId: string | number, params: CommentPageParams = {}) => + getJson( + `/api/life-posts/${postId}/comments${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), 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}`), - entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) => - getJson(`/api/discussions/${entityType}/${entityId}/comments`), + entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) => + getJson( + `/api/discussions/${entityType}/${entityId}/comments${buildQuery({ + cursor: params.cursor ?? undefined, + limit: params.limit + })}` + ), createEntityDiscussionComment: ( entityType: DiscussionEntityType, entityId: string | number, diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index aed404d..35b4850 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -38,6 +38,17 @@ import { type NamedEntity } from '../services/api'; +type LifeCommentPageState = { + items: LifeComment[]; + nextCursor: string | null; + hasMore: boolean; + total: number; + loading: boolean; + loadingMore: boolean; + loaded: boolean; + error: string; +}; + const { locale, t } = useI18n(); const posts = ref([]); const lifeTags = ref([]); @@ -59,6 +70,7 @@ const commentBodies = ref>({}); const replyBodies = ref>({}); const replyTargetId = ref(null); const expandedComments = ref>({}); +const commentPages = ref>({}); const commentBusyKey = ref(''); const commentErrors = ref>({}); const reactionPickerPostId = ref(null); @@ -67,6 +79,7 @@ const reactionErrors = ref>({}); const bodyInput = ref(null); const loadMoreSentinel = ref(null); const lifePostPageSize = 20; +const lifeCommentPageSize = 20; const bodyMaxLength = 2000; const commentMaxLength = 1000; const skeletonPostCount = 3; @@ -158,6 +171,8 @@ async function loadPosts() { return; } posts.value = page.items; + expandedComments.value = {}; + commentPages.value = {}; nextCursor.value = page.nextCursor; hasMorePosts.value = page.hasMore; } catch (error) { @@ -338,8 +353,36 @@ function replyKey(commentId: number) { return `reply-${commentId}`; } +function initialCommentPage(post: LifePost): LifeCommentPageState { + return { + items: post.commentPreview, + nextCursor: null, + hasMore: post.commentCount > post.commentPreview.reduce((count, comment) => count + 1 + comment.replies.length, 0), + total: post.commentCount, + loading: false, + loadingMore: false, + loaded: false, + error: '' + }; +} + +function commentPage(post: LifePost) { + return commentPages.value[post.id] ?? initialCommentPage(post); +} + +function setCommentPage(postId: number, page: LifeCommentPageState) { + commentPages.value = { + ...commentPages.value, + [postId]: page + }; +} + +function commentsForPost(post: LifePost) { + return commentPage(post).items; +} + function commentCount(post: LifePost) { - return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0); + return commentPage(post).total; } function reactionTotal(post: LifePost) { @@ -375,6 +418,13 @@ function replacePost(updatedPost: LifePost) { return; } + const existingComments = commentPages.value[updatedPost.id]; + if (existingComments) { + setCommentPage(updatedPost.id, { + ...existingComments, + total: updatedPost.commentCount + }); + } posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post)); } @@ -389,8 +439,56 @@ function setCommentsExpanded(postId: number, expanded: boolean) { }; } -function toggleComments(postId: number) { - setCommentsExpanded(postId, !areCommentsExpanded(postId)); +function mergeComments(existing: LifeComment[], incoming: LifeComment[]) { + const ids = new Set(existing.map((comment) => comment.id)); + return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))]; +} + +async function loadComments(post: LifePost, reset = false) { + const existing = commentPage(post); + if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) { + return; + } + + const cursor = reset || !existing.loaded ? null : existing.nextCursor; + setCommentPage(post.id, { + ...existing, + items: reset || !existing.loaded ? [] : existing.items, + loading: reset || !existing.loaded, + loadingMore: !reset && existing.loaded, + error: '' + }); + + try { + const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor }); + const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items); + setCommentPage(post.id, { + items: nextItems, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + total: page.total, + loading: false, + loadingMore: false, + loaded: true, + error: '' + }); + post.commentCount = page.total; + } catch (error) { + setCommentPage(post.id, { + ...existing, + loading: false, + loadingMore: false, + error: error instanceof Error && error.message ? error.message : t('errors.loadFailed') + }); + } +} + +function toggleComments(post: LifePost) { + const expanded = !areCommentsExpanded(post.id); + setCommentsExpanded(post.id, expanded); + if (expanded) { + void loadComments(post); + } } function isCommentBusy(key: string) { @@ -420,6 +518,10 @@ function clearCommentError(key: string) { commentErrors.value = nextErrors; } +function updateCommentPage(post: LifePost, updater: (page: LifeCommentPageState) => LifeCommentPageState) { + setCommentPage(post.id, updater(commentPage(post))); +} + function setReactionError(postId: number, message: string) { reactionErrors.value = { ...reactionErrors.value, [postId]: message }; } @@ -555,7 +657,14 @@ async function submitComment(post: LifePost) { try { const comment = await api.createLifeComment(post.id, { body: nextBody }); - post.comments.push(comment); + const nextTotal = commentCount(post) + 1; + post.commentCount = nextTotal; + updateCommentPage(post, (page) => ({ + ...page, + items: mergeComments(page.items, [comment]), + total: nextTotal, + loaded: page.loaded || areCommentsExpanded(post.id) + })); commentBodies.value[post.id] = ''; setCommentsExpanded(post.id, true); } catch (error) { @@ -578,7 +687,13 @@ async function submitReply(post: LifePost, comment: LifeComment) { try { const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody }); + const nextTotal = commentCount(post) + 1; + post.commentCount = nextTotal; comment.replies.push(reply); + updateCommentPage(post, (page) => ({ + ...page, + total: nextTotal + })); setCommentsExpanded(post.id, true); cancelReply(comment.id); } catch (error) { @@ -615,7 +730,7 @@ async function deleteComment(post: LifePost, comment: LifeComment) { try { await api.deleteLifeComment(comment.id); - markCommentDeleted(post.comments, comment.id); + markCommentDeleted(commentsForPost(post), comment.id); if (replyTargetId.value === comment.id) { cancelReply(comment.id); } @@ -921,7 +1036,7 @@ onUnmounted(() => { :aria-controls="`life-comments-${post.id}`" :aria-expanded="areCommentsExpanded(post.id)" :aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')" - @click="toggleComments(post.id)" + @click="toggleComments(post)" >