From 71b7e838ed202d58ab78349b828bd45775689d56 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Fri, 1 May 2026 21:49:56 +0800 Subject: [PATCH] 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. --- DESIGN.md | 9 +- backend/db/schema.sql | 12 ++ backend/src/queries.ts | 139 +++++++++++++++++- backend/src/server.ts | 40 +++++- frontend/src/i18n.ts | 20 +++ frontend/src/icons.ts | 4 + frontend/src/services/api.ts | 21 +++ frontend/src/styles/main.css | 150 +++++++++++++++++++- frontend/src/views/LifeView.vue | 242 +++++++++++++++++++++++++++++--- 9 files changed, 605 insertions(+), 32 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 36590ca..f8fb79b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -364,6 +364,7 @@ Life Post 可配置: - 创建者、最后编辑者、创建时间、最后编辑时间 - 评论 - 评论回复:仅支持回复顶层评论,不做无限嵌套 +- Reactions:`like`、`helpful`、`fun`、`thanks` 前台行为: @@ -374,13 +375,16 @@ Life Post 可配置: - 已注册并完成邮箱验证的用户可以评论 Life Post,并回复顶层评论。 - 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 -- 当前没有点赞、图片上传、转发、分页、置顶或单独审核流程。 +- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。 +- Life Reaction 的其他类型通过右键 / context menu 打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 +- 当前没有图片上传、转发、分页、置顶或单独审核流程。 - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。 API 暴露边界: - Life Post 作者信息只返回 `id` 和 `displayName`。 - Life Comment 作者信息只返回 `id` 和 `displayName`。 +- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。 - API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。 - 非作者不能编辑或删除其他用户的 Life Post。 - 非作者不能删除其他用户的 Life Comment。 @@ -443,6 +447,9 @@ API 暴露边界: - `POST /api/life-posts/:postId/comments` - `POST /api/life-posts/:postId/comments/:commentId/replies` - `DELETE /api/life-comments/:id` +- Life Reaction 的设置、替换和取消。 + - `PUT /api/life-posts/:id/reaction` + - `DELETE /api/life-posts/:id/reaction` - 每日 CheckList 的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index afa653d..8bf14a4 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -152,6 +152,18 @@ CREATE INDEX IF NOT EXISTS life_post_comments_post_idx CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx ON life_post_comments(parent_comment_id, created_at, id); +CREATE TABLE IF NOT EXISTS life_post_reactions ( + post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, + user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx + ON life_post_reactions(post_id, reaction_type); + CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 8b584a6..5dd4dfa 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -112,6 +112,9 @@ type LifeCommentPayload = { body: string; }; +type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; +type LifeReactionCounts = Record; + type LifeCommentRow = { id: number; postId: number; @@ -138,6 +141,8 @@ type LifePostRow = { type LifePost = LifePostRow & { comments: LifeComment[]; + reactionCounts: LifeReactionCounts; + myReaction: LifeReactionType | null; }; type HabitatPayload = { @@ -210,6 +215,7 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; const defaultLocale = 'en'; const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/; +const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ { key: 'hp', label: 'HP' }, { key: 'attack', label: 'Attack' }, @@ -1222,6 +1228,27 @@ function cleanLifeCommentPayload(payload: Record): LifeCommentP return { body }; } +function emptyLifeReactionCounts(): LifeReactionCounts { + return { + like: 0, + helpful: 0, + fun: 0, + thanks: 0 + }; +} + +function isLifeReactionType(value: unknown): value is LifeReactionType { + return typeof value === 'string' && lifeReactionTypes.includes(value as LifeReactionType); +} + +function cleanLifeReactionType(value: unknown): LifeReactionType { + if (!isLifeReactionType(value)) { + throw validationError('Reaction is invalid'); + } + + return value; +} + function lifePostProjection(): string { return ` SELECT @@ -1309,6 +1336,65 @@ async function lifeCommentsForPosts(postIds: number[]): Promise; + myReactionsByPost: Map; +}> { + const countsByPost = new Map(); + const myReactionsByPost = new Map(); + + for (const postId of postIds) { + countsByPost.set(postId, emptyLifeReactionCounts()); + } + + if (postIds.length === 0) { + return { countsByPost, myReactionsByPost }; + } + + const countRows = await query<{ postId: number; reactionType: LifeReactionType; count: number }>( + ` + SELECT + post_id AS "postId", + reaction_type AS "reactionType", + COUNT(*)::integer AS count + FROM life_post_reactions + WHERE post_id = ANY($1::integer[]) + GROUP BY post_id, reaction_type + `, + [postIds] + ); + + for (const row of countRows) { + const counts = countsByPost.get(row.postId); + if (counts && isLifeReactionType(row.reactionType)) { + counts[row.reactionType] = row.count; + } + } + + if (userId !== null) { + const myRows = await query<{ postId: number; reactionType: LifeReactionType }>( + ` + SELECT post_id AS "postId", reaction_type AS "reactionType" + FROM life_post_reactions + WHERE post_id = ANY($1::integer[]) + AND user_id = $2 + `, + [postIds, userId] + ); + + for (const row of myRows) { + if (isLifeReactionType(row.reactionType)) { + myReactionsByPost.set(row.postId, row.reactionType); + } + } + } + + return { countsByPost, myReactionsByPost }; +} + async function getLifeCommentById(id: number): Promise { const row = await queryOne( ` @@ -1320,21 +1406,25 @@ async function getLifeCommentById(id: number): Promise { return row ? { ...row, replies: [] } : null; } -export async function listLifePosts(): Promise { +export async function listLifePosts(userId: number | null = null): Promise { const posts = await query(` ${lifePostProjection()} ORDER BY lp.created_at DESC, lp.id DESC `); - const commentsByPost = await lifeCommentsForPosts(posts.map((post) => post.id)); + const postIds = posts.map((post) => post.id); + const commentsByPost = await lifeCommentsForPosts(postIds); + const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId); return posts.map((post) => ({ ...post, - comments: commentsByPost.get(post.id) ?? [] + comments: commentsByPost.get(post.id) ?? [], + reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), + myReaction: myReactionsByPost.get(post.id) ?? null })); } -async function getLifePostById(id: number): Promise { +async function getLifePostById(id: number, userId: number | null = null): Promise { const post = await queryOne( ` ${lifePostProjection()} @@ -1348,9 +1438,12 @@ async function getLifePostById(id: number): Promise { } const commentsByPost = await lifeCommentsForPosts([post.id]); + const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId); return { ...post, - comments: commentsByPost.get(post.id) ?? [] + comments: commentsByPost.get(post.id) ?? [], + reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), + myReaction: myReactionsByPost.get(post.id) ?? null }; } @@ -1366,7 +1459,7 @@ export async function createLifePost(payload: Record, userId: n [cleanPayload.body, userId] ); - return getLifePostById(result?.id ?? 0); + return getLifePostById(result?.id ?? 0, userId); } export async function updateLifePost(id: number, payload: Record, userId: number) { @@ -1383,7 +1476,7 @@ export async function updateLifePost(id: number, payload: Record, userId: number) { + const reactionType = cleanLifeReactionType(payload.reactionType); + + const result = await queryOne<{ postId: number }>( + ` + INSERT INTO life_post_reactions (post_id, user_id, reaction_type) + SELECT $1, $2, $3 + WHERE EXISTS (SELECT 1 FROM life_posts WHERE id = $1) + ON CONFLICT (post_id, user_id) + DO UPDATE SET reaction_type = EXCLUDED.reaction_type, updated_at = now() + RETURNING post_id AS "postId" + `, + [postId, userId, reactionType] + ); + + return result ? getLifePostById(result.postId, userId) : null; +} + +export async function deleteLifePostReaction(postId: number, userId: number) { + await queryOne<{ postId: number }>( + ` + DELETE FROM life_post_reactions + WHERE post_id = $1 + AND user_id = $2 + RETURNING post_id AS "postId" + `, + [postId, userId] + ); + + return getLifePostById(postId, userId); +} + export async function createLifeComment(postId: number, payload: Record, userId: number) { const cleanPayload = cleanLifeCommentPayload(payload); diff --git a/backend/src/server.ts b/backend/src/server.ts index 20b0f27..860e4b0 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -22,6 +22,7 @@ import { deleteLanguage, deleteLifeComment, deleteLifePost, + deleteLifePostReaction, deletePokemon, deleteRecipe, getHabitat, @@ -45,6 +46,7 @@ import { reorderLanguages, reorderPokemon, reorderRecipes, + setLifePostReaction, updateConfig, updateDailyChecklistItem, updateHabitat, @@ -144,6 +146,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply) return user; } +async function optionalUser(request: FastifyRequest): Promise { + const token = getBearerToken(request.headers.authorization); + if (!token) { + return null; + } + + try { + return await getUserBySessionToken(token); + } catch { + return null; + } +} + app.post('/api/auth/register', async (request, reply) => reply.code(201).send(await registerUser(request.body as Record, requestLocale(request))) ); @@ -178,7 +193,10 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request))); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); -app.get('/api/life-posts', async () => listLifePosts()); +app.get('/api/life-posts', async (request) => { + const user = await optionalUser(request); + return listLifePosts(user?.id ?? null); +}); app.post('/api/life-posts', async (request, reply) => { const user = await requireVerifiedUser(request, reply); @@ -220,6 +238,26 @@ app.put('/api/life-posts/:id', async (request, reply) => { return post ? post : reply.code(404).send({ message: 'Not found' }); }); +app.put('/api/life-posts/:id/reaction', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const post = await setLifePostReaction(Number(id), request.body as Record, user.id); + return post ? post : reply.code(404).send({ message: 'Not found' }); +}); + +app.delete('/api/life-posts/:id/reaction', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const post = await deleteLifePostReaction(Number(id), user.id); + return post ? post : reply.code(404).send({ message: 'Not found' }); +}); + app.delete('/api/life-posts/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 2379be1..7ffdd63 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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: '发表评论', diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 79aed93..fc6cf7e 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -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'; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f4537b6..979dfe7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -173,6 +173,9 @@ export interface DailyChecklistItem { translations?: TranslationMap; } +export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks'; +export type LifeReactionCounts = Record; + 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 { } } +async function deleteAndGetJson(path: string): Promise { + const response = await fetch(`${apiBaseUrl}${path}`, { + method: 'DELETE', + headers: requestHeaders() + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response)); + } + + return response.json() as Promise; +} + export const api = { languages: () => getJson('/api/languages'), adminLanguages: () => getJson('/api/admin/languages'), @@ -439,6 +457,9 @@ export const api = { updateLifePost: (id: string | number, payload: LifePostPayload) => sendJson(`/api/life-posts/${id}`, 'PUT', payload), deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`), + setLifeReaction: (id: string | number, reactionType: LifeReactionType) => + sendJson(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }), + deleteLifeReaction: (id: string | number) => deleteAndGetJson(`/api/life-posts/${id}/reaction`), createLifeComment: (postId: string | number, payload: LifeCommentPayload) => sendJson(`/api/life-posts/${postId}/comments`, 'POST', payload), createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 3e676e0..c6c53a3 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index b335ca2..757db68 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -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(null); const expandedComments = ref>({}); const commentBusyKey = ref(''); const commentErrors = ref>({}); +const reactionPickerPostId = ref(null); +const reactionBusyPostId = ref(null); +const reactionErrors = ref>({}); const bodyInput = ref(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(() => {

{{ post.body }}

+ +