Files
pokopiawiki.tootaio.com/frontend/src/views/LifeView.vue
xiaomai ec2a21bae6 feat(layout): redesign app navigation and replace sidebars with tabs
Move global navigation to a responsive sidebar drawer in AppShell
Replace sidebars in detail pages and Life feed with Tab components
Add mobile topbar with hamburger menu for navigation
2026-05-02 01:16:39 +08:00

1132 lines
41 KiB
Vue

<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import FilterPanel from '../components/FilterPanel.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import {
iconAdd,
iconCancel,
iconChevronDown,
iconComment,
iconDelete,
iconEdit,
iconLife,
iconReactionFun,
iconReactionHelpful,
iconReactionLike,
iconReactionThanks,
iconReply,
iconSave,
iconSearch,
iconWarning
} from '../icons';
import {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AuthUser,
type LifeComment,
type LifePost,
type LifeReactionType,
type NamedEntity
} from '../services/api';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeTags = ref<NamedEntity[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const authReady = ref(false);
const busy = ref(false);
const searchDraft = ref('');
const submittedSearch = ref('');
const activeTagId = ref('all');
const body = ref('');
const selectedTagIds = ref<string[]>([]);
const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
const loadError = ref('');
const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({});
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 loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20;
const bodyMaxLength = 2000;
const commentMaxLength = 1000;
const skeletonPostCount = 3;
const loadingMoreSkeletonCount = 2;
let removeAuthListener: (() => void) | null = null;
let feedObserver: IntersectionObserver | null = null;
let postsRequestId = 0;
const nextCursor = ref<string | null>(null);
const hasMorePosts = ref(false);
const loadMorePaused = ref(false);
const allTagValue = 'all';
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, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
const selectedFeedTagId = computed(() => {
const tagId = Number(activeTagId.value);
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
});
const tagFilterOptions = computed<TabOption[]>(() => [
{ value: allTagValue, label: t('pages.life.allTags') },
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
]);
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
return isEditing.value ? t('pages.life.update') : t('pages.life.publish');
});
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
}
async function loadLifeTags() {
try {
const options = await api.options();
lifeTags.value = options.lifeTags;
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
activeTagId.value = allTagValue;
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
}
async function loadPosts() {
const requestId = ++postsRequestId;
loading.value = true;
loadingMore.value = false;
loadError.value = '';
nextCursor.value = null;
hasMorePosts.value = false;
loadMorePaused.value = false;
try {
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
if (requestId !== postsRequestId) {
return;
}
posts.value = page.items;
nextCursor.value = page.nextCursor;
hasMorePosts.value = page.hasMore;
} catch (error) {
if (requestId === postsRequestId) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
} finally {
if (requestId === postsRequestId) {
loading.value = false;
}
}
}
async function loadMorePosts() {
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value) {
return;
}
const cursor = nextCursor.value;
if (!cursor) {
hasMorePosts.value = false;
return;
}
const requestId = postsRequestId;
loadingMore.value = true;
loadError.value = '';
try {
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
if (requestId !== postsRequestId) {
return;
}
const existingIds = new Set(posts.value.map((post) => post.id));
posts.value = [...posts.value, ...page.items.filter((post) => !existingIds.has(post.id))];
nextCursor.value = page.nextCursor;
hasMorePosts.value = page.hasMore;
} catch (error) {
if (requestId === postsRequestId) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
loadMorePaused.value = true;
}
} finally {
if (requestId === postsRequestId) {
loadingMore.value = false;
}
}
}
function resetForm() {
body.value = '';
selectedTagIds.value = [];
editingPostId.value = null;
formError.value = '';
}
function payload() {
return {
body: body.value.trim(),
tagIds: selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0)
};
}
function submitSearch() {
const nextSearch = searchDraft.value.trim();
if (nextSearch === submittedSearch.value && !loadError.value) {
return;
}
submittedSearch.value = nextSearch;
void loadPosts();
}
function clearSearch() {
const hadSubmittedSearch = Boolean(submittedSearch.value);
if (!searchDraft.value && !hadSubmittedSearch) {
return;
}
searchDraft.value = '';
submittedSearch.value = '';
if (hadSubmittedSearch) {
void loadPosts();
}
}
function retryLoadMore() {
loadMorePaused.value = false;
void loadMorePosts();
}
function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase();
const tagId = selectedFeedTagId.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
return matchesSearch && matchesTag;
}
function openCreatePostModal() {
resetForm();
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
}
function closePostModal() {
if (busy.value) {
return;
}
postModalOpen.value = false;
resetForm();
}
async function submitPost() {
if (!body.value.trim()) {
formError.value = t('pages.life.bodyRequired');
bodyInput.value?.focus();
return;
}
busy.value = true;
formError.value = '';
try {
if (editingPostId.value !== null) {
const updated = await api.updateLifePost(editingPostId.value, payload());
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value];
}
}
resetForm();
postModalOpen.value = false;
} catch (error) {
formError.value =
error instanceof Error && error.message
? error.message
: isEditing.value
? t('pages.life.saveFailed')
: t('pages.life.postFailed');
} finally {
busy.value = false;
}
}
function canManage(post: LifePost) {
return currentUser.value?.id === post.author?.id;
}
function canManageComment(comment: LifeComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
}
function commentKey(postId: number) {
return `post-${postId}`;
}
function replyKey(commentId: number) {
return `reply-${commentId}`;
}
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) {
if (!matchesCurrentFilters(updatedPost)) {
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
return;
}
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
}
function areCommentsExpanded(postId: number) {
return expandedComments.value[postId] === true;
}
function setCommentsExpanded(postId: number, expanded: boolean) {
expandedComments.value = {
...expandedComments.value,
[postId]: expanded
};
}
function toggleComments(postId: number) {
setCommentsExpanded(postId, !areCommentsExpanded(postId));
}
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');
}
function commentInitial(comment: LifeComment) {
const name = commentAuthorName(comment);
return name.slice(0, 1).toUpperCase();
}
function setCommentError(key: string, message: string) {
commentErrors.value = { ...commentErrors.value, [key]: message };
}
function clearCommentError(key: string) {
const nextErrors = { ...commentErrors.value };
delete nextErrors[key];
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 closeReactionPicker() {
reactionPickerPostId.value = null;
}
function toggleReactionPicker(postId: number) {
if (!canUseReactions()) {
return;
}
clearReactionError(postId);
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
}
function closeReactionPickerFromDocument(event: MouseEvent) {
if (reactionPickerPostId.value === null || !(event.target instanceof Element)) {
return;
}
if (!event.target.closest('.life-reactions')) {
closeReactionPicker();
}
}
function closeReactionPickerFromKeyboard(event: KeyboardEvent) {
if (event.key === 'Escape' && reactionPickerPostId.value !== null) {
closeReactionPicker();
}
}
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;
selectedTagIds.value = post.tags.map((tag) => String(tag.id));
formError.value = '';
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
}
async function deletePost(post: LifePost) {
if (!window.confirm(t('pages.life.deleteConfirm'))) {
return;
}
loadError.value = '';
try {
await api.deleteLifePost(post.id);
posts.value = posts.value.filter((item) => item.id !== post.id);
if (editingPostId.value === post.id) {
resetForm();
postModalOpen.value = false;
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('pages.life.deleteFailed');
}
}
function startReply(comment: LifeComment) {
replyTargetId.value = comment.id;
clearCommentError(replyKey(comment.id));
}
function cancelReply(commentId: number) {
replyTargetId.value = null;
replyBodies.value[commentId] = '';
clearCommentError(replyKey(commentId));
}
async function submitComment(post: LifePost) {
const key = commentKey(post.id);
const nextBody = (commentBodies.value[post.id] ?? '').trim();
if (!nextBody) {
setCommentError(key, t('pages.life.commentRequired'));
return;
}
commentBusyKey.value = key;
clearCommentError(key);
try {
const comment = await api.createLifeComment(post.id, { body: nextBody });
post.comments.push(comment);
commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function submitReply(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
const nextBody = (replyBodies.value[comment.id] ?? '').trim();
if (!nextBody) {
setCommentError(key, t('pages.life.commentRequired'));
return;
}
commentBusyKey.value = key;
clearCommentError(key);
try {
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
comment.replies.push(reply);
setCommentsExpanded(post.id, true);
cancelReply(comment.id);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally {
commentBusyKey.value = '';
}
}
function markCommentDeleted(comments: LifeComment[], id: number): boolean {
for (const comment of comments) {
if (comment.id === id) {
comment.deleted = true;
comment.body = '';
comment.author = null;
return true;
}
if (markCommentDeleted(comment.replies, id)) {
return true;
}
}
return false;
}
async function deleteComment(post: LifePost, comment: LifeComment) {
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
return;
}
const key = replyKey(comment.id);
clearCommentError(key);
try {
await api.deleteLifeComment(comment.id);
markCommentDeleted(post.comments, comment.id);
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed'));
}
}
function formatPostTime(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);
}
function authorInitial(post: LifePost) {
const name = post.author?.displayName.trim() || t('pages.life.byUnknown');
return name.slice(0, 1).toUpperCase();
}
function disconnectFeedObserver() {
feedObserver?.disconnect();
feedObserver = null;
}
function observeLoadMore() {
disconnectFeedObserver();
if (loading.value || loadingMore.value || loadMorePaused.value || !hasMorePosts.value || !loadMoreSentinel.value) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
void loadMorePosts();
return;
}
feedObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
void loadMorePosts();
}
},
{ rootMargin: '360px 0px' }
);
feedObserver.observe(loadMoreSentinel.value);
}
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeTagId, () => {
void loadPosts();
});
watch(locale, () => {
void loadLifeTags();
void loadPosts();
});
onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
void loadCurrentUser();
void loadLifeTags();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
void loadPosts();
});
});
onUnmounted(() => {
document.removeEventListener('click', closeReactionPickerFromDocument);
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
disconnectFeedObserver();
removeAuthListener?.();
});
</script>
<template>
<section class="page-stack life-page">
<PageHeader :title="t('pages.life.title')" :subtitle="t('pages.life.subtitle')">
<template #kicker>{{ t('pages.life.kicker') }}</template>
</PageHeader>
<FilterPanel class="life-toolbar">
<form class="life-toolbar__search" role="search" @submit.prevent="submitSearch">
<div class="field life-toolbar__field">
<label for="life-search">{{ t('pages.life.search') }}</label>
<div class="life-search-control">
<input id="life-search" v-model="searchDraft" type="search" :placeholder="t('pages.life.searchPlaceholder')" />
<button
v-if="searchDraft || submittedSearch"
class="life-search-control__clear"
type="button"
:aria-label="t('pages.life.clearSearch')"
@click="clearSearch"
>
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
</button>
</div>
</div>
<button class="ui-button ui-button--ghost" type="submit">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
{{ t('common.search') }}
</button>
</form>
<div class="life-toolbar__actions">
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.newPost') }}
</button>
</div>
</FilterPanel>
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<Modal
:open="postModalOpen"
:title="postModalTitle"
:subtitle="t('pages.life.composerPrompt')"
:close-label="t('common.close')"
size="wide"
@close="closePostModal"
>
<div v-if="!authReady" class="life-composer__auth-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
<Skeleton width="42%" />
</div>
<form v-else-if="canPost" class="life-form" @submit.prevent="submitPost">
<div class="field">
<label for="life-post-body">{{ t('pages.life.bodyLabel') }}</label>
<textarea
id="life-post-body"
ref="bodyInput"
v-model="body"
:maxlength="bodyMaxLength"
:placeholder="t('pages.life.bodyPlaceholder')"
required
></textarea>
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
</div>
<div class="field">
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
<TagsSelect
id="life-post-tags"
v-model="selectedTagIds"
:options="lifeTags"
:placeholder="t('pages.life.tagPlaceholder')"
:search-placeholder="t('pages.life.searchTags')"
dropdown-strategy="fixed"
/>
</div>
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
<div class="life-form__actions">
<button class="ui-button ui-button--primary" :disabled="busy || !body.trim()" type="submit">
<Icon :icon="isEditing ? iconSave : iconLife" class="ui-icon" aria-hidden="true" />
{{ submitLabel }}
</button>
<button class="ui-button ui-button--ghost" :disabled="busy" type="button" @click="closePostModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</div>
</form>
<div v-else class="life-auth-note">
<p>{{ currentUser ? t('pages.life.verifyPrompt') : t('pages.life.loginPrompt') }}</p>
<RouterLink v-if="!currentUser" class="ui-button ui-button--primary" :to="{ path: '/login', query: { redirect: '/life' } }">
{{ t('nav.login') }}
</RouterLink>
</div>
</Modal>
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
<div class="life-post__header">
<Skeleton variant="box" width="46px" height="46px" />
<div class="life-post__byline">
<Skeleton width="138px" />
<Skeleton width="96px" />
</div>
</div>
<Skeleton width="94%" />
<Skeleton width="76%" />
<Skeleton width="52%" />
</article>
</div>
<div v-else-if="posts.length" class="life-feed__list">
<article v-for="post in posts" :key="post.id" class="life-post">
<header class="life-post__header">
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
<div class="life-post__byline">
<strong>{{ post.author?.displayName ?? t('pages.life.byUnknown') }}</strong>
<span>
<time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
</span>
</div>
<div v-if="canManage(post)" class="life-post__actions">
<button class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
</button>
<button
class="life-icon-button life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deletePost')"
@click="deletePost(post)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
</button>
</div>
</header>
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
</div>
<div class="life-post__engagement">
<div class="life-post__engagement-actions">
<div class="life-reactions">
<div class="life-reaction-control">
<button
class="life-icon-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)"
: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 class="life-action-tooltip" role="tooltip">{{ reactionButtonLabel(post) }}</span>
</button>
<button
class="life-icon-button life-reaction-menu-button"
type="button"
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="t('pages.life.chooseReaction')"
:disabled="!canPost || reactionBusyPostId !== null"
@click="toggleReactionPicker(post.id)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
>
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.chooseReaction') }}</span>
</button>
</div>
<div
v-if="reactionPickerPostId === post.id && canPost"
:id="`life-reactions-${post.id}`"
class="life-reaction-picker"
role="group"
:aria-label="t('pages.life.reactionMenu')"
>
<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)"
:disabled="isReactionBusy(post.id)"
@click="toggleReaction(post, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span>{{ reactionLabel(option.type) }}</span>
</button>
</div>
</div>
<button
class="life-icon-button"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
@click="toggleComments(post.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</span>
</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-action-tooltip" role="tooltip">{{ reactionCountLabel(post, option.type) }}</span>
</span>
</template>
</div>
<button
class="life-metric-button"
type="button"
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
@click="toggleComments(post.id)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span>{{ commentCount(post) }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}</span>
</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}`"
class="life-comments"
:aria-label="t('pages.life.comments')"
>
<div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
<div class="field">
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
<textarea
:id="`life-comment-${post.id}`"
v-model="commentBodies[post.id]"
:maxlength="commentMaxLength"
:placeholder="t('pages.life.commentPlaceholder')"
></textarea>
</div>
<p v-if="commentErrors[commentKey(post.id)]" class="life-form__error" role="alert">
{{ commentErrors[commentKey(post.id)] }}
</p>
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isCommentBusy(commentKey(post.id)) || !(commentBodies[post.id] ?? '').trim()"
type="submit"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ isCommentBusy(commentKey(post.id)) ? t('pages.life.postingComment') : t('pages.life.postComment') }}
</button>
</form>
<div v-if="post.comments.length" class="life-comment-list">
<article
v-for="comment in post.comments"
:key="comment.id"
class="life-comment"
:class="{ 'is-deleted': comment.deleted }"
>
<div class="life-comment__main">
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
</div>
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
<div v-if="!comment.deleted" class="life-comment__actions">
<button
v-if="canPost"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.reply')"
@click="startReply(comment)"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.reply') }}</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }}
</p>
<form
v-if="canPost && replyTargetId === comment.id"
class="life-comment-form life-comment-form--reply"
@submit.prevent="submitReply(post, comment)"
>
<div class="field">
<label :for="`life-reply-${comment.id}`">{{ t('pages.life.reply') }}</label>
<textarea
:id="`life-reply-${comment.id}`"
v-model="replyBodies[comment.id]"
:maxlength="commentMaxLength"
:placeholder="t('pages.life.commentReplyPlaceholder')"
></textarea>
</div>
<div class="life-form__actions">
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isCommentBusy(replyKey(comment.id)) || !(replyBodies[comment.id] ?? '').trim()"
type="submit"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.postingReply') : t('pages.life.postReply') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.cancelReply') }}
</button>
</div>
</form>
<div v-if="comment.replies.length" class="life-comment-replies">
<article
v-for="reply in comment.replies"
:key="reply.id"
class="life-comment life-comment--reply"
:class="{ 'is-deleted': reply.deleted }"
>
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
</div>
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="life-comment__actions">
<button
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, reply)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }}
</p>
</div>
</article>
</div>
</div>
</div>
</article>
</div>
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
</section>
</article>
<article v-for="index in loadingMore ? loadingMoreSkeletonCount : 0" :key="`life-more-${index}`" class="life-post life-post--skeleton">
<div class="life-post__header">
<Skeleton variant="box" width="46px" height="46px" />
<div class="life-post__byline">
<Skeleton width="138px" />
<Skeleton width="96px" />
</div>
</div>
<Skeleton width="94%" />
<Skeleton width="76%" />
<Skeleton width="52%" />
</article>
<div v-if="hasMorePosts" ref="loadMoreSentinel" class="life-feed__sentinel" aria-hidden="true"></div>
<div v-if="loadMorePaused && hasMorePosts" class="life-feed__retry">
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.retryFeed') }}
</button>
</div>
</div>
<div v-else class="life-empty">
<Icon :icon="searchQuery ? iconSearch : iconLife" class="life-empty__icon" aria-hidden="true" />
<div class="life-empty__copy">
<h2>{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}</h2>
<p>{{ searchQuery ? t('pages.life.searchEmptyHint') : t('pages.life.emptyHint') }}</p>
</div>
<button v-if="searchQuery" class="ui-button ui-button--ghost" type="button" @click="clearSearch">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.clearSearch') }}
</button>
<button v-else class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.newPost') }}
</button>
</div>
</section>
</section>
</template>