feat(life): add reactions to life posts

Support 'like', 'helpful', 'fun', and 'thanks' reactions.
Add API endpoints and database schema for post reactions.
Update UI with reaction picker and summary counts.
This commit is contained in:
2026-05-01 21:49:56 +08:00
parent a683982b80
commit 71b7e838ed
9 changed files with 605 additions and 32 deletions

View File

@@ -5,7 +5,19 @@ import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconCancel, iconComment, iconDelete, iconEdit, iconLife, iconReply, iconSave } from '../icons';
import {
iconCancel,
iconComment,
iconDelete,
iconEdit,
iconLife,
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
iconReactionThanks,
iconReply,
iconSave
} from '../icons';
import {
api,
getAuthToken,
@@ -13,7 +25,8 @@ import {
setAuthToken,
type AuthUser,
type LifeComment,
type LifePost
type LifePost,
type LifeReactionType
} from '../services/api';
const { locale, t } = useI18n();
@@ -32,10 +45,20 @@ const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({});
const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const skeletonPostCount = 3;
let removeAuthListener: (() => void) | null = null;
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const canPost = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
@@ -102,7 +125,7 @@ async function submitPost() {
try {
if (editingPostId.value !== null) {
const updated = await api.updateLifePost(editingPostId.value, payload());
posts.value = posts.value.map((post) => (post.id === updated.id ? updated : post));
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
posts.value = [created, ...posts.value];
@@ -140,6 +163,37 @@ function commentCount(post: LifePost) {
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
}
function reactionTotal(post: LifePost) {
return reactionOptions.reduce((count, option) => count + (post.reactionCounts[option.type] ?? 0), 0);
}
function reactionLabel(type: LifeReactionType) {
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
}
function reactionIcon(type: LifeReactionType | null) {
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
}
function reactionButtonLabel(post: LifePost) {
return post.myReaction ? reactionLabel(post.myReaction) : t('pages.life.reactionLike');
}
function reactionOptionLabel(post: LifePost, type: LifeReactionType) {
return post.myReaction === type ? t('pages.life.removeReaction') : reactionLabel(type);
}
function reactionCountLabel(post: LifePost, type: LifeReactionType) {
return t('pages.life.reactionCountLabel', {
reaction: reactionLabel(type),
count: post.reactionCounts[type] ?? 0
});
}
function replacePost(updatedPost: LifePost) {
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
}
function areCommentsExpanded(postId: number) {
return expandedComments.value[postId] === true;
}
@@ -159,6 +213,10 @@ function isCommentBusy(key: string) {
return commentBusyKey.value === key;
}
function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
}
@@ -178,6 +236,69 @@ function clearCommentError(key: string) {
commentErrors.value = nextErrors;
}
function setReactionError(postId: number, message: string) {
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
}
function clearReactionError(postId: number) {
const nextErrors = { ...reactionErrors.value };
delete nextErrors[postId];
reactionErrors.value = nextErrors;
}
function canUseReactions() {
return canPost.value && reactionBusyPostId.value === null;
}
function toggleReactionPicker(postId: number) {
if (!canUseReactions()) {
return;
}
clearReactionError(postId);
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
}
function handleReactionContextMenu(event: MouseEvent, postId: number) {
event.preventDefault();
toggleReactionPicker(postId);
}
function handleReactionKeydown(event: KeyboardEvent, postId: number) {
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) {
return;
}
event.preventDefault();
toggleReactionPicker(postId);
}
async function toggleDefaultReaction(post: LifePost) {
await toggleReaction(post, 'like');
}
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
if (!canUseReactions()) {
return;
}
reactionBusyPostId.value = post.id;
clearReactionError(post.id);
try {
const updatedPost =
post.myReaction === reactionType
? await api.deleteLifeReaction(post.id)
: await api.setLifeReaction(post.id, reactionType);
replacePost(updatedPost);
reactionPickerPostId.value = null;
} catch (error) {
setReactionError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.reactionFailed'));
} finally {
reactionBusyPostId.value = null;
}
}
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
@@ -318,6 +439,7 @@ onMounted(() => {
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
void loadPosts();
});
});
@@ -424,27 +546,103 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div class="life-post__engagement">
<button
class="life-post__engagement-button"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</button>
<button
class="life-post__comment-count"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
</button>
<div class="life-post__engagement-actions">
<div class="life-reactions">
<button
class="life-post__engagement-button life-reaction-trigger"
:class="{ 'is-active': post.myReaction !== null }"
type="button"
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="reactionButtonLabel(post)"
:aria-describedby="`life-reaction-tooltip-${post.id}`"
:disabled="!canPost || reactionBusyPostId !== null"
@click="toggleDefaultReaction(post)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
>
<Icon :icon="reactionIcon(post.myReaction)" class="ui-icon" aria-hidden="true" />
<span :id="`life-reaction-tooltip-${post.id}`" class="life-reaction-tooltip" role="tooltip">
{{ reactionButtonLabel(post) }}
</span>
</button>
<div
v-if="reactionPickerPostId === post.id && canPost"
:id="`life-reactions-${post.id}`"
class="life-reaction-picker"
role="group"
:aria-label="t('pages.life.reactions')"
>
<button
v-for="option in reactionOptions"
:key="option.type"
class="life-reaction-option"
:class="{ 'is-active': post.myReaction === option.type }"
type="button"
:aria-pressed="post.myReaction === option.type"
:aria-label="reactionOptionLabel(post, option.type)"
:aria-describedby="`life-reaction-option-tooltip-${post.id}-${option.type}`"
:disabled="isReactionBusy(post.id)"
@click="toggleReaction(post, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span
:id="`life-reaction-option-tooltip-${post.id}-${option.type}`"
class="life-reaction-tooltip"
role="tooltip"
>
{{ reactionOptionLabel(post, option.type) }}
</span>
</button>
</div>
</div>
<button
class="life-post__engagement-button"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</button>
</div>
<div class="life-post__metrics">
<div
v-if="reactionTotal(post) > 0"
class="life-reaction-summary"
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
>
<template v-for="option in reactionOptions" :key="option.type">
<span
v-if="post.reactionCounts[option.type] > 0"
class="life-reaction-summary__item"
:aria-label="reactionCountLabel(post, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
{{ post.reactionCounts[option.type] }}
<span class="life-reaction-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
</span>
</template>
</div>
<button
class="life-post__comment-count"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
@click="toggleComments(post.id)"
>
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
</button>
</div>
</div>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
<section
v-if="areCommentsExpanded(post.id)"
:id="`life-comments-${post.id}`"