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

@@ -233,6 +233,16 @@ const messages = {
commentsCount: '{count} comments',
comment: 'Comment',
hideComments: 'Hide comments',
react: 'Like',
reactions: 'Reactions',
reactionsCount: '{count} reactions',
reactionCountLabel: '{reaction}: {count}',
reactionLike: 'Like',
reactionHelpful: 'Helpful',
reactionFun: 'Fun',
reactionThanks: 'Thanks',
removeReaction: 'Remove reaction',
reactionFailed: 'Reaction failed',
commentPlaceholder: 'Write a comment...',
commentReplyPlaceholder: 'Write a reply...',
postComment: 'Post comment',
@@ -566,6 +576,16 @@ const messages = {
commentsCount: '{count} 条评论',
comment: '评论',
hideComments: '收起评论',
react: '点赞',
reactions: '互动',
reactionsCount: '{count} 次互动',
reactionCountLabel: '{reaction}{count}',
reactionLike: '喜欢',
reactionHelpful: '有帮助',
reactionFun: '有趣',
reactionThanks: '感谢',
removeReaction: '取消互动',
reactionFailed: '互动失败',
commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……',
postComment: '发表评论',

View File

@@ -25,6 +25,10 @@ export const iconPokemon: AppIcon = 'mdi:pokeball';
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
export const iconReply: AppIcon = 'mdi:reply-outline';
export const iconReactionFun: AppIcon = 'mdi:party-popper';
export const iconReactionHelpful: AppIcon = 'mdi:lightbulb-on-outline';
export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';

View File

@@ -173,6 +173,9 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export interface LifePost {
id: number;
body: string;
@@ -181,6 +184,8 @@ export interface LifePost {
author: UserSummary | null;
updatedBy: UserSummary | null;
comments: LifeComment[];
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
}
export interface LifeComment {
@@ -417,6 +422,19 @@ async function deleteJson(path: string): Promise<void> {
}
}
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE',
headers: requestHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
export const api = {
languages: () => getJson<Language[]>('/api/languages'),
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
@@ -439,6 +457,9 @@ export const api = {
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
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),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>

View File

@@ -1326,6 +1326,19 @@ button:disabled,
border-top: 1px solid var(--line);
}
.life-post__engagement-actions,
.life-post__metrics {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.life-post__metrics {
justify-content: flex-end;
min-width: 0;
}
.life-post__engagement-button,
.life-post__comment-count {
min-height: 44px;
@@ -1342,7 +1355,8 @@ button:disabled,
.life-post__engagement-button:hover,
.life-post__comment-count:hover,
.life-post__engagement-button[aria-expanded="true"] {
.life-post__engagement-button[aria-expanded="true"],
.life-post__engagement-button.is-active {
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
color: var(--pokemon-blue-deep);
}
@@ -1357,6 +1371,136 @@ button:disabled,
font-size: 14px;
}
.life-reactions {
position: relative;
}
.life-reaction-trigger {
position: relative;
width: 44px;
justify-content: center;
padding: 7px;
}
.life-reaction-picker {
position: absolute;
z-index: 10;
top: calc(100% + 6px);
left: 0;
width: max-content;
max-width: calc(100vw - 48px);
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-control);
}
.life-reaction-option {
position: relative;
width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
font-weight: 900;
cursor: pointer;
}
.life-reaction-option:hover,
.life-reaction-option.is-active {
border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line));
background: color-mix(in srgb, var(--pokemon-blue) 12%, var(--surface-soft));
color: var(--pokemon-blue-deep);
}
.life-reaction-option .ui-icon,
.life-reaction-summary .ui-icon {
width: 20px;
height: 20px;
}
.life-reaction-summary {
min-height: 44px;
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 6px;
color: var(--muted);
font-size: 14px;
font-weight: 850;
}
.life-reaction-summary__item {
position: relative;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 7px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
}
.life-reaction-tooltip {
position: absolute;
z-index: 30;
bottom: calc(100% + 8px);
left: 50%;
min-width: max-content;
max-width: 220px;
padding: 6px 8px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--ink);
box-shadow: var(--shadow-soft);
color: var(--surface);
font-size: 12px;
font-weight: 850;
line-height: 1.25;
opacity: 0;
pointer-events: none;
text-align: center;
transform: translate(-50%, 4px);
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
visibility: hidden;
white-space: nowrap;
}
.life-reaction-tooltip::after {
position: absolute;
top: 100%;
left: 50%;
width: 8px;
height: 8px;
border-right: 1px solid var(--line-strong);
border-bottom: 1px solid var(--line-strong);
background: var(--ink);
content: '';
transform: translate(-50%, -4px) rotate(45deg);
}
.life-reaction-trigger:hover .life-reaction-tooltip,
.life-reaction-trigger:focus-visible .life-reaction-tooltip,
.life-reaction-option:hover .life-reaction-tooltip,
.life-reaction-option:focus-visible .life-reaction-tooltip,
.life-reaction-summary__item:hover .life-reaction-tooltip {
opacity: 1;
transform: translate(-50%, 0);
visibility: visible;
}
.life-comments {
display: grid;
gap: 12px;
@@ -2696,6 +2840,10 @@ button:disabled,
justify-content: flex-start;
}
.life-post__metrics {
justify-content: flex-start;
}
.life-comment-replies {
padding-left: 10px;
}

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}`"