feat(comments): add sorting and liking functionality

Support sorting by oldest, latest, most-liked, and most-replied.
Implement like/unlike actions for Life and Entity Discussion comments.
This commit is contained in:
2026-05-04 17:29:09 +08:00
parent 504849c14a
commit 2ff2519647
10 changed files with 993 additions and 65 deletions

View File

@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons';
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import {
api,
getAuthToken,
@@ -13,6 +13,7 @@ import {
setAuthToken,
type AiModerationStatus,
type AuthUser,
type CommentSort,
type DiscussionEntityType,
type EntityDiscussionComment,
type Language,
@@ -41,7 +42,9 @@ const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const activeLanguageCode = ref('all');
const activeSort = ref<CommentSort>('oldest');
const moderationBusyId = ref<number | null>(null);
const likeBusyId = ref<number | null>(null);
const commentMaxLength = 1000;
const discussionPageSize = 20;
const allLanguageValue = 'all';
@@ -56,12 +59,19 @@ function can(permissionKey: string) {
}
const canComment = computed(() => can('discussions.comments.create'));
const canLikeComments = computed(() => can('discussions.comments.like'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('discussion.sortOldest') },
{ value: 'latest', label: t('discussion.sortLatest') },
{ value: 'most-liked', label: t('discussion.sortMostLiked') },
{ value: 'most-replied', label: t('discussion.sortMostReplied') }
]);
async function loadCurrentUser() {
authReady.value = false;
@@ -119,7 +129,8 @@ async function loadDiscussion(reset = true) {
const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize,
cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value
language: selectedLanguageCode.value,
sort: activeSort.value
});
if (nextRequestId === requestId) {
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
@@ -151,6 +162,17 @@ function commentKey(commentId: number) {
return `comment-${commentId}`;
}
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeSort.value = event.target.value as CommentSort;
void loadDiscussion();
}
}
function replyBody(commentId: number) {
return replyBodies.value[commentId] ?? '';
}
@@ -181,6 +203,14 @@ function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
}
function canLikeComment(comment: EntityDiscussionComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function commentLikeLabel(comment: EntityDiscussionComment) {
return comment.myLiked ? t('discussion.unlikeComment') : t('discussion.likeComment');
}
function moderationReasonVisible(comment: EntityDiscussionComment) {
return (
!comment.deleted &&
@@ -267,6 +297,9 @@ async function submitComment() {
comments.value = [...comments.value, comment];
commentTotal.value += 1;
body.value = '';
if (activeSort.value !== 'oldest') {
void loadDiscussion();
}
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally {
@@ -291,8 +324,12 @@ async function submitReply(comment: EntityDiscussionComment) {
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
});
comment.replies.push(reply);
comment.replyCount += 1;
commentTotal.value += 1;
cancelReply(comment.id);
if (activeSort.value === 'most-replied') {
void loadDiscussion();
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
} finally {
@@ -317,6 +354,49 @@ async function retryModeration(comment: EntityDiscussionComment) {
}
}
function replaceCommentInTree(items: EntityDiscussionComment[], updated: EntityDiscussionComment): boolean {
for (let index = 0; index < items.length; index += 1) {
const comment = items[index];
if (!comment) {
continue;
}
if (comment.id === updated.id) {
items[index] = { ...updated, replies: comment.replies };
return true;
}
if (replaceCommentInTree(comment.replies, updated)) {
return true;
}
}
return false;
}
async function toggleCommentLike(comment: EntityDiscussionComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
likeBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = comment.myLiked
? await api.deleteEntityDiscussionCommentLike(comment.id)
: await api.setEntityDiscussionCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeSort.value === 'most-liked') {
void loadDiscussion();
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.commentLikeFailed'));
} finally {
likeBusyId.value = null;
}
}
function updateDiscussionCommentModeration(
items: EntityDiscussionComment[],
commentId: number,
@@ -455,6 +535,14 @@ onUnmounted(() => {
</div>
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
<label class="entity-discussion-sort">
<span>{{ t('discussion.sort') }}</span>
<select :value="activeSort" @change="handleSortChange">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
@@ -528,6 +616,18 @@ onUnmounted(() => {
</p>
<div v-if="!comment.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || likeBusyId === comment.id"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button
v-if="canComment"
class="life-icon-button life-icon-button--flat"
@@ -563,6 +663,9 @@ onUnmounted(() => {
</button>
</div>
<p v-if="commentErrors[likeKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(comment.id)] }}
</p>
@@ -624,7 +727,19 @@ onUnmounted(() => {
<strong>{{ t('discussion.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span>
</p>
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
<div v-if="!reply.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || likeBusyId === reply.id"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button
v-if="canRetryModeration(reply)"
class="life-icon-button life-icon-button--flat"
@@ -639,6 +754,7 @@ onUnmounted(() => {
</span>
</button>
<button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@@ -648,6 +764,9 @@ onUnmounted(() => {
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[likeKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(reply.id)] }}
</p>

View File

@@ -421,8 +421,11 @@ export interface CommentPageParams {
cursor?: string | null;
limit?: number;
language?: string;
sort?: CommentSort;
}
export type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
export interface LifeComment {
id: number;
postId: number;
@@ -435,6 +438,9 @@ export interface LifeComment {
createdAt: string;
updatedAt: string;
author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: LifeComment[];
}
@@ -831,6 +837,9 @@ export interface EntityDiscussionComment {
createdAt: string;
updatedAt: string;
author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: EntityDiscussionComment[];
}
@@ -1229,7 +1238,8 @@ export const api = {
`/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
language: params.language
language: params.language,
sort: params.sort
})}`
),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
@@ -1237,13 +1247,16 @@ export const api = {
retryLifeCommentModeration: (id: string | number) =>
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}),
setLifeCommentLike: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/like`, 'PUT', {}),
deleteLifeCommentLike: (id: string | number) => deleteAndGetJson<LifeComment>(`/api/life-comments/${id}/like`),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
language: params.language
language: params.language,
sort: params.sort
})}`
),
createEntityDiscussionComment: (
@@ -1259,6 +1272,10 @@ export const api = {
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
retryEntityDiscussionModeration: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
setEntityDiscussionCommentLike: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`, 'PUT', {}),
deleteEntityDiscussionCommentLike: (id: string | number) =>
deleteAndGetJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: (
entityType: ImageUploadEntityType,

View File

@@ -3262,7 +3262,8 @@ button:disabled,
font-weight: 950;
}
.life-comments__header span {
.life-comments__header > span,
.life-comments__header > div > span {
min-width: 32px;
padding: 2px 8px;
border: 1px solid var(--line);
@@ -3274,6 +3275,25 @@ button:disabled,
text-align: center;
}
.life-comments__sort {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.life-comments__sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.life-comment-form {
display: grid;
gap: 8px;
@@ -3386,6 +3406,13 @@ button:disabled,
gap: 8px;
}
.life-comment__action-count {
min-width: 1ch;
font-size: 12px;
font-weight: 900;
line-height: 1;
}
.life-comments__empty {
margin: 0;
}
@@ -4529,6 +4556,26 @@ button:disabled,
font-weight: 800;
}
.entity-discussion-sort {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-skeleton,
.entity-discussion-form,
.entity-discussion-list {

View File

@@ -33,6 +33,7 @@ import {
setAuthToken,
type AiModerationStatus,
type AuthUser,
type CommentSort,
type LifeComment,
type LifePost,
type LifeReactionType,
@@ -53,6 +54,7 @@ const commentsLoading = ref(false);
const commentsLoadingMore = ref(false);
const commentsLoaded = ref(false);
const commentsError = ref('');
const activeCommentSort = ref<CommentSort>('oldest');
const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
@@ -82,9 +84,16 @@ function can(permissionKey: string) {
}
const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
function routePostId() {
const value = route.params.id;
@@ -185,7 +194,7 @@ async function loadComments(reset = false) {
}
try {
const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor });
const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor, sort: activeCommentSort.value });
comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items);
commentsNextCursor.value = page.nextCursor;
commentsHasMore.value = page.hasMore;
@@ -208,6 +217,17 @@ function replyKey(commentId: number) {
return `reply-${commentId}`;
}
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleCommentSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeCommentSort.value = event.target.value as CommentSort;
void loadComments(true);
}
}
function isCommentBusy(key: string) {
return commentBusyKey.value === key;
}
@@ -232,6 +252,19 @@ function canRestoreComment(comment: LifeComment) {
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
}
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function canSeeCommentModeration(comment: LifeComment) {
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
}
@@ -271,6 +304,10 @@ function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) {
});
}
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
reactionUsersModal.value = { postId, reactionType };
}
@@ -536,6 +573,9 @@ async function submitComment(currentPost: LifePost) {
currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true;
commentBodies.value[currentPost.id] = '';
if (activeCommentSort.value !== 'oldest') {
void loadComments(true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally {
@@ -571,10 +611,14 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
});
comment.replies.push(reply);
comment.replyCount += 1;
commentsTotal.value += 1;
currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true;
cancelReply(comment.id);
if (activeCommentSort.value === 'most-replied') {
void loadComments(true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally {
@@ -669,6 +713,45 @@ async function restoreComment(comment: LifeComment) {
}
}
async function retryCommentModeration(comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeCommentSort.value === 'most-liked') {
void loadComments(true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} finally {
commentBusyKey.value = '';
}
}
function commentAuthorName(comment: LifeComment) {
return comment.author?.displayName ?? t('pages.life.byUnknown');
}
@@ -921,8 +1004,18 @@ onUnmounted(() => {
<section :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>{{ commentsTotal }}</span>
<div>
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentsTotal }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="activeCommentSort" @change="handleCommentSortChange">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -995,6 +1088,19 @@ onUnmounted(() => {
</p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button
v-if="!comment.deleted && canCommentOnPost"
class="life-icon-button life-icon-button--flat"
@@ -1026,8 +1132,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }}
</p>
@@ -1092,7 +1214,20 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span>
</p>
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
<div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -1114,7 +1249,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}
</p>

View File

@@ -40,6 +40,7 @@ import {
setAuthToken,
type AiModerationStatus,
type AuthUser,
type CommentSort,
type GameVersion,
type Language,
type LifeCategory,
@@ -93,6 +94,7 @@ 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 commentSorts = ref<Record<number, CommentSort>>({});
const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
@@ -134,6 +136,7 @@ function can(permissionKey: string) {
const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
@@ -183,6 +186,12 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') }
@@ -505,6 +514,28 @@ function replyKey(commentId: number) {
return `reply-${commentId}`;
}
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function commentSort(postId: number): CommentSort {
return commentSorts.value[postId] ?? 'oldest';
}
function setCommentSort(post: LifePost, sort: CommentSort) {
commentSorts.value = {
...commentSorts.value,
[post.id]: sort
};
void loadComments(post, true);
}
function handleCommentSortChange(post: LifePost, event: Event) {
if (event.target instanceof HTMLSelectElement) {
setCommentSort(post, event.target.value as CommentSort);
}
}
function initialCommentPage(post: LifePost): LifeCommentPageState {
return {
items: post.commentPreview,
@@ -768,7 +799,12 @@ async function loadComments(post: LifePost, reset = false) {
});
try {
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value });
const page = await api.lifeComments(post.id, {
limit: lifeCommentPageSize,
cursor,
language: selectedFeedLanguageCode.value,
sort: commentSort(post.id)
});
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
setCommentPage(post.id, {
items: nextItems,
@@ -858,6 +894,23 @@ function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null;
}
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
@@ -1018,6 +1071,9 @@ async function submitComment(post: LifePost) {
}));
commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true);
if (commentSort(post.id) !== 'oldest') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally {
@@ -1044,12 +1100,16 @@ async function submitReply(post: LifePost, comment: LifeComment) {
const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal;
comment.replies.push(reply);
comment.replyCount += 1;
updateCommentPage(post, (page) => ({
...page,
total: nextTotal
}));
setCommentsExpanded(post.id, true);
cancelReply(comment.id);
if (commentSort(post.id) === 'most-replied') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally {
@@ -1152,6 +1212,51 @@ async function restoreComment(post: LifePost, comment: LifeComment) {
}
}
async function retryCommentModeration(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(post: LifePost, comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
if (commentSort(post.id) === 'most-liked') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} finally {
commentBusyKey.value = '';
}
}
function formatPostTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -1635,8 +1740,18 @@ onUnmounted(() => {
:aria-label="t('pages.life.comments')"
>
<div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
<div>
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="commentSort(post.id)" @change="handleCommentSortChange(post, $event)">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -1709,6 +1824,19 @@ onUnmounted(() => {
</p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(post, comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button
v-if="!comment.deleted && canComment"
class="life-icon-button life-icon-button--flat"
@@ -1740,8 +1868,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(post, comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }}
</p>
@@ -1806,7 +1950,20 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span>
</p>
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
<div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(post, reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -1828,7 +1985,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(post, reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}
</p>