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:
@@ -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`。
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user