feat(life): redesign feed layout with sidebar and icon buttons

Replace tag tabs with a responsive sidebar for filtering
Convert post and comment action buttons to icon-only with tooltips
Standardize engagement buttons into reusable icon and metric components
This commit is contained in:
2026-05-02 00:54:07 +08:00
parent 0ca6f779ec
commit 6462ed23de
3 changed files with 539 additions and 416 deletions

View File

@@ -387,7 +387,7 @@ Life Post 可配置:
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like` - 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
- Feed 顶部展示 Life 标签 Tabs包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 - Feed 在桌面端通过侧边栏展示 Life 标签筛选,在移动端展示紧凑筛选条;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发、置顶或单独审核流程。 - 当前没有图片上传、转发、置顶或单独审核流程。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`

View File

@@ -1245,8 +1245,73 @@ button:disabled,
min-height: 44px; min-height: 44px;
} }
.life-tag-tabs { .life-layout {
max-width: 100%; display: grid;
grid-template-columns: minmax(184px, 240px) minmax(0, 1fr);
align-items: start;
gap: 16px;
}
.life-sidebar {
position: sticky;
top: 92px;
display: grid;
gap: 12px;
padding: 14px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-control);
}
.life-sidebar__header h2 {
margin: 0;
color: var(--ink);
font-family: var(--font-display);
font-size: 18px;
font-weight: 950;
line-height: 1.15;
}
.life-tag-filter {
display: grid;
gap: 8px;
}
.life-tag-filter__button {
min-height: 44px;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
padding: 8px 10px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
font-weight: 900;
cursor: pointer;
text-align: left;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease,
box-shadow 0.14s ease;
}
.life-tag-filter__button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.life-tag-filter__button:hover,
.life-tag-filter__button.is-active {
border-color: var(--line-strong);
background: var(--pokemon-yellow);
box-shadow: 0 2px 0 var(--line-strong);
color: #172036;
} }
.life-composer, .life-composer,
@@ -1291,11 +1356,12 @@ button:disabled,
.life-feed { .life-feed {
display: grid; display: grid;
min-width: 0;
} }
.life-feed__list { .life-feed__list {
width: min(100%, 920px); width: 100%;
justify-self: center; justify-self: stretch;
} }
.life-feed__sentinel { .life-feed__sentinel {
@@ -1395,10 +1461,6 @@ button:disabled,
gap: 8px; gap: 8px;
} }
.life-post__actions .ui-button {
min-height: 44px;
}
.life-post__body { .life-post__body {
max-width: 72ch; max-width: 72ch;
margin: 0; margin: 0;
@@ -1456,40 +1518,72 @@ button:disabled,
min-width: 0; min-width: 0;
} }
.life-post__engagement-button, .life-icon-button,
.life-post__comment-count { .life-metric-button {
position: relative;
min-height: 44px; min-height: 44px;
display: inline-flex; border: 1px solid var(--line);
align-items: center;
gap: 7px;
padding: 7px 9px;
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: transparent; background: var(--surface-soft);
color: var(--ink-soft); color: var(--ink-soft);
font-weight: 900;
cursor: pointer; cursor: pointer;
font-weight: 900;
transition: transition:
background 0.14s ease, background 0.14s ease,
border-color 0.14s ease, border-color 0.14s ease,
color 0.14s ease; color 0.14s ease,
box-shadow 0.14s ease;
} }
.life-post__engagement-button:hover, .life-icon-button {
.life-post__comment-count:hover, width: 44px;
.life-post__engagement-button[aria-expanded="true"], min-width: 44px;
.life-post__engagement-button.is-active { display: inline-grid;
place-items: center;
padding: 0;
}
.life-metric-button {
display: inline-flex;
align-items: center;
gap: 7px;
justify-content: center;
padding: 7px 10px;
}
.life-icon-button:hover,
.life-icon-button[aria-expanded="true"],
.life-icon-button.is-active,
.life-metric-button:hover,
.life-metric-button[aria-expanded="true"] {
border-color: color-mix(in srgb, var(--pokemon-blue) 45%, var(--line));
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft)); background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
color: var(--pokemon-blue-deep); color: var(--pokemon-blue-deep);
} }
.life-post__engagement-button .ui-icon { .life-icon-button--flat {
width: 18px; border-color: transparent;
height: 18px; background: transparent;
} }
.life-post__comment-count { .life-icon-button--danger:hover,
color: var(--muted); .life-icon-button--danger:focus-visible {
font-size: 14px; border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
background: color-mix(in srgb, var(--danger) 10%, var(--surface-soft));
color: var(--danger);
}
.life-icon-button:disabled,
.life-metric-button:disabled {
cursor: not-allowed;
opacity: 0.54;
box-shadow: none;
}
.life-icon-button .ui-icon,
.life-metric-button .ui-icon {
width: 20px;
height: 20px;
} }
.life-reactions { .life-reactions {
@@ -1499,48 +1593,31 @@ button:disabled,
.life-reaction-control { .life-reaction-control {
display: inline-flex; display: inline-flex;
align-items: stretch; align-items: stretch;
overflow: hidden; overflow: visible;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: var(--surface-soft); background: var(--surface-soft);
} }
.life-reaction-trigger { .life-reaction-control .life-icon-button {
position: relative; border: 0;
min-width: 96px; border-radius: 0;
justify-content: flex-start; background: transparent;
padding: 7px 10px;
}
.life-reaction-trigger__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
} }
.life-reaction-menu-button { .life-reaction-menu-button {
width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border-left: 1px solid var(--line); border-left: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
} }
.life-reaction-control .life-icon-button:hover,
.life-reaction-control .life-icon-button[aria-expanded="true"],
.life-reaction-control .life-icon-button.is-active,
.life-reaction-menu-button:hover, .life-reaction-menu-button:hover,
.life-reaction-menu-button[aria-expanded="true"] { .life-reaction-menu-button[aria-expanded="true"] {
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft)); background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
color: var(--pokemon-blue-deep); color: var(--pokemon-blue-deep);
} }
.life-post__engagement-button:disabled,
.life-reaction-menu-button:disabled {
cursor: not-allowed;
opacity: 0.54;
}
.life-reaction-picker { .life-reaction-picker {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
@@ -1621,7 +1698,7 @@ button:disabled,
color: var(--ink-soft); color: var(--ink-soft);
} }
.life-reaction-tooltip { .life-action-tooltip {
position: absolute; position: absolute;
z-index: 30; z-index: 30;
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
@@ -1646,7 +1723,7 @@ button:disabled,
white-space: nowrap; white-space: nowrap;
} }
.life-reaction-tooltip::after { .life-action-tooltip::after {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 50%; left: 50%;
@@ -1659,11 +1736,11 @@ button:disabled,
transform: translate(-50%, -4px) rotate(45deg); transform: translate(-50%, -4px) rotate(45deg);
} }
.life-reaction-trigger:hover .life-reaction-tooltip, .life-icon-button:hover .life-action-tooltip,
.life-reaction-trigger:focus-visible .life-reaction-tooltip, .life-icon-button:focus-visible .life-action-tooltip,
.life-reaction-option:hover .life-reaction-tooltip, .life-metric-button:hover .life-action-tooltip,
.life-reaction-option:focus-visible .life-reaction-tooltip, .life-metric-button:focus-visible .life-action-tooltip,
.life-reaction-summary__item:hover .life-reaction-tooltip { .life-reaction-summary__item:hover .life-action-tooltip {
opacity: 1; opacity: 1;
transform: translate(-50%, 0); transform: translate(-50%, 0);
visibility: visible; visibility: visible;
@@ -1810,30 +1887,6 @@ button:disabled,
gap: 8px; gap: 8px;
} }
.life-comment__link-button {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 10px;
border-radius: var(--radius-control);
background: transparent;
color: var(--pokemon-blue);
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.life-comment__link-button:hover {
background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent);
color: var(--pokemon-blue-deep);
}
.life-comment__link-button .ui-icon {
width: 15px;
height: 15px;
}
.life-comments__empty { .life-comments__empty {
margin: 0; margin: 0;
} }
@@ -1979,10 +2032,11 @@ button:disabled,
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.life-page .ui-button, .life-page .ui-button,
.life-post__engagement-button, .life-icon-button,
.life-reaction-menu-button, .life-metric-button,
.life-tag-filter__button,
.life-reaction-option, .life-reaction-option,
.life-reaction-tooltip, .life-action-tooltip,
.life-search-control__clear, .life-search-control__clear,
.reorderable-row, .reorderable-row,
.reorderable-list-move, .reorderable-list-move,
@@ -3004,6 +3058,32 @@ button:disabled,
justify-content: flex-start; justify-content: flex-start;
} }
.life-layout {
grid-template-columns: 1fr;
}
.life-sidebar {
position: static;
}
.life-sidebar__header {
display: none;
}
.life-tag-filter {
display: flex;
gap: 8px;
max-width: 100%;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.life-tag-filter__button {
flex: 0 0 auto;
max-width: 180px;
}
.detail-grid, .detail-grid,
.detail-with-sidebar, .detail-with-sidebar,
.pokemon-profile-grid, .pokemon-profile-grid,
@@ -3093,14 +3173,9 @@ button:disabled,
align-items: stretch; align-items: stretch;
} }
.life-reactions, .life-reactions {
.life-reaction-control {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 0 0 auto;
}
.life-reaction-trigger {
flex: 1 1 auto;
} }
.life-reaction-picker { .life-reaction-picker {

View File

@@ -8,7 +8,6 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { import {
iconAdd, iconAdd,
iconCancel, iconCancel,
@@ -94,7 +93,7 @@ const selectedFeedTagId = computed(() => {
const tagId = Number(activeTagId.value); const tagId = Number(activeTagId.value);
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId; return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
}); });
const tagTabs = computed<TabOption[]>(() => [ const tagFilterOptions = computed(() => [
{ value: allTagValue, label: t('pages.life.allTags') }, { value: allTagValue, label: t('pages.life.allTags') },
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name })) ...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
]); ]);
@@ -245,6 +244,12 @@ function retryLoadMore() {
void loadMorePosts(); void loadMorePosts();
} }
function selectTagFilter(value: string) {
if (value !== activeTagId.value) {
activeTagId.value = value;
}
}
function matchesCurrentFilters(post: LifePost) { function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase(); const keyword = searchQuery.value.toLowerCase();
const tagId = selectedFeedTagId.value; const tagId = selectedFeedTagId.value;
@@ -715,8 +720,6 @@ onUnmounted(() => {
</div> </div>
</FilterPanel> </FilterPanel>
<Tabs id="life-tag-filter" v-model="activeTagId" class="life-tag-tabs" :tabs="tagTabs" :label="t('pages.life.tags')" />
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage> <StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
<Modal <Modal
@@ -780,328 +783,373 @@ onUnmounted(() => {
</div> </div>
</Modal> </Modal>
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')"> <div class="life-layout">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')"> <aside class="life-sidebar" aria-labelledby="life-tag-filter-title">
<article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton"> <div class="life-sidebar__header">
<div class="life-post__header"> <h2 id="life-tag-filter-title">{{ t('pages.life.tags') }}</h2>
<Skeleton variant="box" width="46px" height="46px" /> </div>
<div class="life-post__byline"> <div class="life-tag-filter" role="group" :aria-label="t('pages.life.tags')">
<Skeleton width="138px" /> <button
<Skeleton width="96px" /> v-for="option in tagFilterOptions"
</div> :key="option.value"
</div> class="life-tag-filter__button"
<Skeleton width="94%" /> :class="{ 'is-active': activeTagId === option.value }"
<Skeleton width="76%" /> type="button"
<Skeleton width="52%" /> :aria-pressed="activeTagId === option.value"
</article> @click="selectTagFilter(option.value)"
</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="ui-button ui-button--ghost ui-button--small" type="button" @click="startEdit(post)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.editPost') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="deletePost(post)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deletePost') }}
</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-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)"
: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-reaction-trigger__label">{{ reactionButtonLabel(post) }}</span>
</button>
<button
class="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" />
</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-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}`"
class="life-comments"
:aria-label="t('pages.life.comments')"
> >
<div class="life-comments__header"> <span>{{ option.label }}</span>
<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-comment__link-button" type="button" @click="startReply(comment)">
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.reply') }}
</button>
<button
v-if="canManageComment(comment)"
class="life-comment__link-button"
type="button"
@click="deleteComment(post, comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deleteComment') }}
</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-comment__link-button" type="button" @click="deleteComment(post, reply)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.life.deleteComment') }}
</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> </button>
</div> </div>
</div> </aside>
<div v-else class="life-empty"> <section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<Icon :icon="searchQuery ? iconSearch : iconLife" class="life-empty__icon" aria-hidden="true" /> <div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
<div class="life-empty__copy"> <article v-for="index in skeletonPostCount" :key="index" class="life-post life-post--skeleton">
<h2>{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}</h2> <div class="life-post__header">
<p>{{ searchQuery ? t('pages.life.searchEmptyHint') : t('pages.life.emptyHint') }}</p> <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>
<button v-if="searchQuery" class="ui-button ui-button--ghost" type="button" @click="clearSearch">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" /> <div v-else-if="posts.length" class="life-feed__list">
{{ t('pages.life.clearSearch') }} <article v-for="post in posts" :key="post.id" class="life-post">
</button> <header class="life-post__header">
<button v-else class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal"> <div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" /> <div class="life-post__byline">
{{ t('pages.life.newPost') }} <strong>{{ post.author?.displayName ?? t('pages.life.byUnknown') }}</strong>
</button> <span>
</div> <time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
</section> <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>
</div>
</section> </section>
</template> </template>