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
This commit is contained in:
@@ -23,6 +23,7 @@ const { locale, t } = useI18n();
|
||||
const comments = ref<EntityDiscussionComment[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
const loadingMore = ref(false);
|
||||
const authReady = ref(false);
|
||||
const body = ref('');
|
||||
const replyBodies = ref<Record<number, string>>({});
|
||||
@@ -33,8 +34,12 @@ const formError = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const commentMaxLength = 1000;
|
||||
const discussionPageSize = 20;
|
||||
let requestId = 0;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
const nextCursor = ref<string | null>(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(() => {
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="hasMoreComments" class="life-feed__retry">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadDiscussion(false)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="entity-discussion-empty">
|
||||
|
||||
@@ -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<LifePost>(`/api/life-posts/${id}/reaction`),
|
||||
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<LifeCommentsPage>(
|
||||
`/api/life-posts/${postId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
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}`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
|
||||
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<EntityDiscussionCommentsPage>(
|
||||
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
createEntityDiscussionComment: (
|
||||
entityType: DiscussionEntityType,
|
||||
entityId: string | number,
|
||||
|
||||
@@ -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<LifePost[]>([]);
|
||||
const lifeTags = ref<NamedEntity[]>([]);
|
||||
@@ -59,6 +70,7 @@ 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 commentPages = ref<Record<number, LifeCommentPageState>>({});
|
||||
const commentBusyKey = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const reactionPickerPostId = ref<number | null>(null);
|
||||
@@ -67,6 +79,7 @@ const reactionErrors = ref<Record<number, string>>({});
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(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)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">
|
||||
@@ -955,7 +1070,7 @@ onUnmounted(() => {
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
|
||||
@click="toggleComments(post.id)"
|
||||
@click="toggleComments(post)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
<span>{{ commentCount(post) }}</span>
|
||||
@@ -1000,9 +1115,23 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="post.comments.length" class="life-comment-list">
|
||||
<div v-if="commentPage(post).loading && !commentsForPost(post).length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
|
||||
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
|
||||
<div class="life-comment__main">
|
||||
<Skeleton variant="box" width="36px" height="36px" />
|
||||
<div class="life-comment__content">
|
||||
<Skeleton width="132px" />
|
||||
<Skeleton width="86%" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p v-else-if="commentPage(post).error" class="life-form__error" role="alert">{{ commentPage(post).error }}</p>
|
||||
|
||||
<div v-else-if="commentsForPost(post).length" class="life-comment-list">
|
||||
<article
|
||||
v-for="comment in post.comments"
|
||||
v-for="comment in commentsForPost(post)"
|
||||
:key="comment.id"
|
||||
class="life-comment"
|
||||
:class="{ 'is-deleted': comment.deleted }"
|
||||
@@ -1116,6 +1245,18 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||
|
||||
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
type="button"
|
||||
:disabled="commentPage(post).loadingMore"
|
||||
@click="loadComments(post)"
|
||||
>
|
||||
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -548,7 +548,7 @@ function authorInitial(post: LifePost): string {
|
||||
}
|
||||
|
||||
function commentTotal(post: LifePost): number {
|
||||
return post.comments.reduce((total, comment) => total + 1 + comment.replies.length, 0);
|
||||
return post.commentCount;
|
||||
}
|
||||
|
||||
function reactionTotal(post: LifePost): number {
|
||||
|
||||
Reference in New Issue
Block a user