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:
@@ -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: '发表评论',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
Reference in New Issue
Block a user