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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user