feat(life): add Life Post reaction users modal and API
Add GET /api/life-posts/:id/reactions endpoint with pagination Add LifeReactionUsersModal to view and filter reaction users Make reaction summaries clickable in feeds, details, and profiles
This commit is contained in:
@@ -4,6 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
@@ -61,6 +62,7 @@ const ratingBusyPostId = ref<number | null>(null);
|
||||
const ratingErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||
const lifeCommentPageSize = 20;
|
||||
const commentMaxLength = 1000;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
@@ -239,6 +241,14 @@ function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
|
||||
reactionUsersModal.value = { postId, reactionType };
|
||||
}
|
||||
|
||||
function closeReactionUsersModal() {
|
||||
reactionUsersModal.value = null;
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('pages.life.moderationUnreviewed'),
|
||||
@@ -577,6 +587,13 @@ onUnmounted(() => {
|
||||
|
||||
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
|
||||
|
||||
<LifeReactionUsersModal
|
||||
v-if="reactionUsersModal"
|
||||
:post-id="reactionUsersModal.postId"
|
||||
:initial-reaction-type="reactionUsersModal.reactionType"
|
||||
@close="closeReactionUsersModal"
|
||||
/>
|
||||
|
||||
<div class="life-detail-layout" :aria-busy="loading || commentsLoading">
|
||||
<article v-if="loading" class="life-post life-post--skeleton" aria-hidden="true">
|
||||
<div class="life-post__header">
|
||||
@@ -709,10 +726,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="life-post__metrics">
|
||||
<div
|
||||
<button
|
||||
v-if="reactionTotal(post) > 0"
|
||||
class="life-reaction-summary"
|
||||
class="life-reaction-summary life-reaction-summary--button"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
||||
@click="openReactionUsersModal(post.id)"
|
||||
>
|
||||
<template v-for="option in reactionOptions" :key="option.type">
|
||||
<span
|
||||
@@ -725,7 +744,7 @@ onUnmounted(() => {
|
||||
<span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span class="life-metric-button life-metric-button--static" :aria-label="t('pages.life.commentsCount', { count: commentsTotal })">
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -96,6 +97,7 @@ const ratingBusyPostId = ref<number | null>(null);
|
||||
const ratingErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
const lifePostPageSize = 20;
|
||||
@@ -541,6 +543,14 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
|
||||
});
|
||||
}
|
||||
|
||||
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
|
||||
reactionUsersModal.value = { postId, reactionType };
|
||||
}
|
||||
|
||||
function closeReactionUsersModal() {
|
||||
reactionUsersModal.value = null;
|
||||
}
|
||||
|
||||
function moderationLabel(status: AiModerationStatus) {
|
||||
const labels: Record<AiModerationStatus, string> = {
|
||||
unreviewed: t('pages.life.moderationUnreviewed'),
|
||||
@@ -1189,6 +1199,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<LifeReactionUsersModal
|
||||
v-if="reactionUsersModal"
|
||||
:post-id="reactionUsersModal.postId"
|
||||
:initial-reaction-type="reactionUsersModal.reactionType"
|
||||
@close="closeReactionUsersModal"
|
||||
/>
|
||||
|
||||
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
|
||||
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
|
||||
|
||||
@@ -1363,10 +1380,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="life-post__metrics">
|
||||
<div
|
||||
<button
|
||||
v-if="reactionTotal(post) > 0"
|
||||
class="life-reaction-summary"
|
||||
class="life-reaction-summary life-reaction-summary--button"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
||||
@click="openReactionUsersModal(post.id)"
|
||||
>
|
||||
<template v-for="option in reactionOptions" :key="option.type">
|
||||
<span
|
||||
@@ -1379,7 +1398,7 @@ onUnmounted(() => {
|
||||
<span class="life-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="life-metric-button"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
@@ -92,6 +93,7 @@ const commentsCursor = ref<string | null>(null);
|
||||
const commentsHasMore = ref(false);
|
||||
const commentsLoading = ref(false);
|
||||
const commentsError = ref('');
|
||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||
const activityLimit = 10;
|
||||
let profileRequestId = 0;
|
||||
|
||||
@@ -574,6 +576,14 @@ function reactionLabel(type: LifeReactionType): string {
|
||||
return t(`pages.life.reaction${type.charAt(0).toUpperCase()}${type.slice(1)}`);
|
||||
}
|
||||
|
||||
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
|
||||
reactionUsersModal.value = { postId, reactionType };
|
||||
}
|
||||
|
||||
function closeReactionUsersModal() {
|
||||
reactionUsersModal.value = null;
|
||||
}
|
||||
|
||||
function contributionCategory(contentType: string): ContributionFilter {
|
||||
return primaryContributionFilters.includes(contentType as PrimaryContributionFilter)
|
||||
? (contentType as PrimaryContributionFilter)
|
||||
@@ -693,6 +703,13 @@ onMounted(() => {
|
||||
|
||||
<Tabs id="profile-tabs" v-model="activeTab" :tabs="tabs" :label="t('pages.profile.tabsLabel')" />
|
||||
|
||||
<LifeReactionUsersModal
|
||||
v-if="reactionUsersModal"
|
||||
:post-id="reactionUsersModal.postId"
|
||||
:initial-reaction-type="reactionUsersModal.reactionType"
|
||||
@close="closeReactionUsersModal"
|
||||
/>
|
||||
|
||||
<section v-if="activeTab === 'feeds'" class="profile-tab-panel" :aria-label="t('pages.profile.tabFeeds')">
|
||||
<StatusMessage v-if="feedsError" variant="danger" :duration="0">{{ feedsError }}</StatusMessage>
|
||||
|
||||
@@ -733,10 +750,15 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="profile-feed-card__metrics">
|
||||
<span>
|
||||
<button
|
||||
class="profile-reaction-open-button"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.reactionsCount', { count: reactionTotal(post) })"
|
||||
@click="openReactionUsersModal(post.id)"
|
||||
>
|
||||
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.reactionsCount', { count: reactionTotal(post) }) }}
|
||||
</span>
|
||||
</button>
|
||||
<span>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.commentsCount', { count: commentTotal(post) }) }}
|
||||
@@ -860,10 +882,15 @@ onMounted(() => {
|
||||
<div v-else-if="reactions.length" class="profile-activity-list">
|
||||
<article v-for="activity in reactions" :key="`${activity.postId}-${activity.reactedAt}`" class="profile-activity-card">
|
||||
<header class="profile-activity-card__header">
|
||||
<span>
|
||||
<button
|
||||
class="profile-reaction-open-button"
|
||||
type="button"
|
||||
:aria-label="reactionLabel(activity.reactionType)"
|
||||
@click="openReactionUsersModal(activity.post.id, activity.reactionType)"
|
||||
>
|
||||
<Icon :icon="reactionIcon(activity.reactionType)" class="ui-icon" aria-hidden="true" />
|
||||
{{ reactionLabel(activity.reactionType) }}
|
||||
</span>
|
||||
</button>
|
||||
<time :datetime="activity.reactedAt">{{ formatDateTime(activity.reactedAt) }}</time>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user