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:
178
frontend/src/components/LifeReactionUsersModal.vue
Normal file
178
frontend/src/components/LifeReactionUsersModal.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
iconReactionFun,
|
||||
iconReactionHelpful,
|
||||
iconReactionLike,
|
||||
iconReactionThanks
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
type LifeReactionType,
|
||||
type LifeReactionUser
|
||||
} from '../services/api';
|
||||
import Modal from './Modal.vue';
|
||||
import Skeleton from './Skeleton.vue';
|
||||
import Tabs, { type TabOption } from './Tabs.vue';
|
||||
|
||||
type ReactionFilter = LifeReactionType | 'all';
|
||||
|
||||
const props = defineProps<{
|
||||
postId: number;
|
||||
initialReactionType?: LifeReactionType | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const reactionUsers = ref<LifeReactionUser[]>([]);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMore = ref(false);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const loadError = ref('');
|
||||
const activeReactionType = ref<ReactionFilter>(props.initialReactionType ?? 'all');
|
||||
const pageSize = 20;
|
||||
|
||||
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 reactionTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'all', label: t('pages.life.allReactions') },
|
||||
...reactionOptions.map((option) => ({ value: option.type, label: reactionLabel(option.type) }))
|
||||
]);
|
||||
|
||||
function reactionLabel(type: LifeReactionType) {
|
||||
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
|
||||
}
|
||||
|
||||
function reactionIcon(type: LifeReactionType) {
|
||||
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
|
||||
}
|
||||
|
||||
function selectedReactionType() {
|
||||
return activeReactionType.value === 'all' ? undefined : activeReactionType.value;
|
||||
}
|
||||
|
||||
function formatReactedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
async function loadReactionUsers(reset = false) {
|
||||
if (loading.value || loadingMore.value || (!reset && !hasMore.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = reset ? null : nextCursor.value;
|
||||
loading.value = reset;
|
||||
loadingMore.value = !reset;
|
||||
loadError.value = '';
|
||||
|
||||
if (reset) {
|
||||
reactionUsers.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMore.value = false;
|
||||
total.value = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await api.lifeReactionUsers(props.postId, {
|
||||
cursor,
|
||||
limit: pageSize,
|
||||
reactionType: selectedReactionType()
|
||||
});
|
||||
reactionUsers.value = reset ? page.items : [...reactionUsers.value, ...page.items];
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMore.value = page.hasMore;
|
||||
total.value = page.total;
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialReactionType,
|
||||
(nextReactionType) => {
|
||||
activeReactionType.value = nextReactionType ?? 'all';
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
[() => props.postId, activeReactionType, locale],
|
||||
() => {
|
||||
void loadReactionUsers(true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="t('pages.life.reactionUsersTitle')" :subtitle="t('pages.life.reactionUsersSubtitle')" :close-label="t('common.close')" @close="emit('close')">
|
||||
<div class="life-reaction-users-modal">
|
||||
<Tabs id="life-reaction-users-filter" v-model="activeReactionType" :tabs="reactionTabs" :label="t('pages.life.reactionFiltersLabel')" />
|
||||
|
||||
<p class="life-reaction-users-modal__count">{{ t('pages.life.reactionsCount', { count: total }) }}</p>
|
||||
|
||||
<p v-if="loadError" class="life-form__error" role="alert">{{ loadError }}</p>
|
||||
|
||||
<div v-if="loading" class="life-reaction-user-list" aria-hidden="true">
|
||||
<article v-for="index in 4" :key="index" class="life-reaction-user">
|
||||
<Skeleton variant="box" width="38px" height="38px" />
|
||||
<div class="life-reaction-user__copy">
|
||||
<Skeleton width="140px" />
|
||||
<Skeleton width="190px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else-if="reactionUsers.length" class="life-reaction-user-list">
|
||||
<article v-for="item in reactionUsers" :key="`${item.user.id}-${item.reactedAt}`" class="life-reaction-user">
|
||||
<RouterLink class="life-reaction-user__avatar" :to="`/profile/${item.user.id}`" :aria-label="item.user.displayName">
|
||||
{{ item.user.displayName.slice(0, 1).toUpperCase() || '#' }}
|
||||
</RouterLink>
|
||||
<div class="life-reaction-user__copy">
|
||||
<RouterLink class="user-profile-link" :to="`/profile/${item.user.id}`">
|
||||
{{ item.user.displayName }}
|
||||
</RouterLink>
|
||||
<span>
|
||||
<Icon :icon="reactionIcon(item.reactionType)" class="ui-icon" aria-hidden="true" />
|
||||
{{ reactionLabel(item.reactionType) }}
|
||||
<time :datetime="item.reactedAt">{{ formatReactedAt(item.reactedAt) }}</time>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="life-reaction-users-empty">
|
||||
<Icon :icon="iconReactionLike" class="life-reaction-users-empty__icon" aria-hidden="true" />
|
||||
<h3>{{ t('pages.life.reactionUsersEmpty') }}</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore && !loading" class="life-feed__retry">
|
||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" :disabled="loadingMore" @click="loadReactionUsers(false)">
|
||||
{{ loadingMore ? t('common.loading') : t('pages.life.loadMoreReactions') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -404,6 +404,25 @@ export interface LifeCommentsPage {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LifeReactionUser {
|
||||
user: UserSummary;
|
||||
reactionType: LifeReactionType;
|
||||
reactedAt: string;
|
||||
}
|
||||
|
||||
export interface LifeReactionUsersPage {
|
||||
items: LifeReactionUser[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LifeReactionUsersParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
reactionType?: LifeReactionType;
|
||||
}
|
||||
|
||||
export interface RecipeDetail extends Recipe {
|
||||
acquisition_methods: NamedEntity[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
@@ -1040,6 +1059,14 @@ export const api = {
|
||||
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`),
|
||||
lifeReactionUsers: (id: string | number, params: LifeReactionUsersParams = {}) =>
|
||||
getJson<LifeReactionUsersPage>(
|
||||
`/api/life-posts/${id}/reactions${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
reactionType: params.reactionType
|
||||
})}`
|
||||
),
|
||||
setLifeRating: (id: string | number, rating: number) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
|
||||
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
|
||||
|
||||
@@ -2670,6 +2670,21 @@ button:disabled,
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.life-reaction-summary--button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.life-reaction-summary--button:hover .life-reaction-summary__item,
|
||||
.life-reaction-summary--button:focus-visible .life-reaction-summary__item {
|
||||
border-color: color-mix(in srgb, var(--pokemon-blue) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
|
||||
color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.life-action-tooltip {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
@@ -2868,6 +2883,101 @@ button:disabled,
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.life-reaction-users-modal {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.life-reaction-users-modal__count {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.life-reaction-user-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.life-reaction-user {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.life-reaction-user__avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: var(--font-display);
|
||||
font-weight: 950;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.life-reaction-user__avatar:hover {
|
||||
border-color: color-mix(in srgb, var(--pokemon-blue) 45%, var(--line));
|
||||
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.life-reaction-user__copy {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.life-reaction-user__copy > span {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.life-reaction-user__copy .ui-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.life-reaction-users-empty {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 22px 14px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.life-reaction-users-empty h3 {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-reaction-users-empty__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.life-empty {
|
||||
width: min(100%, 680px);
|
||||
justify-self: center;
|
||||
@@ -5870,6 +5980,26 @@ button:disabled,
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.profile-reaction-open-button {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-weight: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-reaction-open-button:hover {
|
||||
color: var(--pokemon-blue-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.profile-feed-card__detail-link,
|
||||
.profile-post-preview__detail {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -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