feat(life): enhance search, empty states, and reaction controls

Add clear button to search input and improve empty state UI
Add split button for reactions and close picker on outside click
Add retry button for paused feed pagination
This commit is contained in:
2026-05-02 00:47:42 +08:00
parent f1ed1e7e40
commit 0ca6f779ec
4 changed files with 349 additions and 51 deletions

View File

@@ -236,7 +236,9 @@ const messages = {
searchTags: 'Search tags',
search: 'Search Life',
searchPlaceholder: 'Search post content...',
clearSearch: 'Clear search',
searchEmpty: 'No posts match your search',
searchEmptyHint: 'Try another keyword or clear the search.',
comments: 'Comments',
commentsCount: '{count} comments',
comment: 'Comment',
@@ -249,6 +251,8 @@ const messages = {
reactionHelpful: 'Helpful',
reactionFun: 'Fun',
reactionThanks: 'Thanks',
chooseReaction: 'Choose reaction',
reactionMenu: 'Reaction menu',
removeReaction: 'Remove reaction',
reactionFailed: 'Reaction failed',
commentPlaceholder: 'Write a comment...',
@@ -273,7 +277,9 @@ const messages = {
updating: 'Updating',
cancelEdit: 'Cancel edit',
empty: 'No posts yet',
emptyHint: 'Verified members can start the first Life post.',
loading: 'Loading Life feed',
retryFeed: 'Retry loading',
loginPrompt: 'Log in with a verified email to post.',
verifyPrompt: 'Complete email verification to post.',
editPost: 'Edit post',
@@ -588,7 +594,9 @@ const messages = {
searchTags: '搜索标签',
search: '搜索动态',
searchPlaceholder: '搜索动态内容……',
clearSearch: '清除搜索',
searchEmpty: '没有匹配的动态',
searchEmptyHint: '换个关键词或清除搜索。',
comments: '评论',
commentsCount: '{count} 条评论',
comment: '评论',
@@ -601,6 +609,8 @@ const messages = {
reactionHelpful: '有帮助',
reactionFun: '有趣',
reactionThanks: '感谢',
chooseReaction: '选择互动',
reactionMenu: '互动菜单',
removeReaction: '取消互动',
reactionFailed: '互动失败',
commentPlaceholder: '写下评论……',
@@ -625,7 +635,9 @@ const messages = {
updating: '更新中',
cancelEdit: '取消编辑',
empty: '暂无动态',
emptyHint: '已验证成员可以发布第一条 Life 动态。',
loading: '正在加载 Life 动态',
retryFeed: '重试加载',
loginPrompt: '使用已验证邮箱登录后即可发布。',
verifyPrompt: '完成邮箱验证后即可发布。',
editPost: '编辑动态',

View File

@@ -1189,13 +1189,51 @@ button:disabled,
.life-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 16px;
}
.life-toolbar__search {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(220px, 1fr) auto;
align-items: end;
gap: 10px;
min-width: 0;
}
.life-toolbar__field {
min-width: 0;
}
.life-search-control {
position: relative;
}
.life-search-control input {
padding-right: 48px;
}
.life-search-control__clear {
position: absolute;
top: 0;
right: 0;
width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border-radius: 0 var(--radius-control) var(--radius-control) 0;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.life-search-control__clear:hover {
background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent);
color: var(--pokemon-blue-deep);
}
.life-search-control__clear .ui-icon {
width: 18px;
height: 18px;
}
.life-toolbar__actions {
@@ -1251,10 +1289,25 @@ button:disabled,
gap: 14px;
}
.life-feed {
display: grid;
}
.life-feed__list {
width: min(100%, 920px);
justify-self: center;
}
.life-feed__sentinel {
min-height: 1px;
}
.life-feed__retry {
display: flex;
justify-content: center;
padding: 4px 0 8px;
}
.life-form__counter {
justify-self: end;
}
@@ -1288,6 +1341,8 @@ button:disabled,
}
.life-post {
gap: 16px;
padding: 18px;
box-shadow: var(--shadow-soft);
}
@@ -1345,8 +1400,10 @@ button:disabled,
}
.life-post__body {
max-width: 72ch;
margin: 0;
color: var(--ink);
font-size: 16px;
line-height: 1.65;
overflow-wrap: anywhere;
white-space: pre-wrap;
@@ -1381,8 +1438,8 @@ button:disabled,
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 8px;
gap: 10px 14px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
@@ -1411,6 +1468,10 @@ button:disabled,
color: var(--ink-soft);
font-weight: 900;
cursor: pointer;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease;
}
.life-post__engagement-button:hover,
@@ -1435,11 +1496,49 @@ button:disabled,
position: relative;
}
.life-reaction-control {
display: inline-flex;
align-items: stretch;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
}
.life-reaction-trigger {
position: relative;
min-width: 96px;
justify-content: flex-start;
padding: 7px 10px;
}
.life-reaction-trigger__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.life-reaction-menu-button {
width: 44px;
justify-content: center;
padding: 7px;
min-height: 44px;
display: inline-grid;
place-items: center;
border-left: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
cursor: pointer;
}
.life-reaction-menu-button:hover,
.life-reaction-menu-button[aria-expanded="true"] {
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
color: var(--pokemon-blue-deep);
}
.life-post__engagement-button:disabled,
.life-reaction-menu-button:disabled {
cursor: not-allowed;
opacity: 0.54;
}
.life-reaction-picker {
@@ -1447,10 +1546,10 @@ button:disabled,
z-index: 10;
top: calc(100% + 6px);
left: 0;
width: max-content;
width: min(280px, calc(100vw - 48px));
max-width: calc(100vw - 48px);
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 8px;
border: 2px solid var(--line-strong);
@@ -1461,20 +1560,29 @@ button:disabled,
.life-reaction-option {
position: relative;
width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px;
justify-content: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.life-reaction-option span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.life-reaction-option:hover,
.life-reaction-option.is-active {
border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line));
@@ -1564,6 +1672,8 @@ button:disabled,
.life-comments {
display: grid;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.life-comments__header {
@@ -1596,6 +1706,7 @@ button:disabled,
.life-comment-form {
display: grid;
gap: 8px;
max-width: 760px;
}
.life-comment-form textarea {
@@ -1700,11 +1811,12 @@ button:disabled,
}
.life-comment__link-button {
min-height: 32px;
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 0;
padding: 8px 10px;
border-radius: var(--radius-control);
background: transparent;
color: var(--pokemon-blue);
font-size: 13px;
@@ -1713,8 +1825,8 @@ button:disabled,
}
.life-comment__link-button:hover {
background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent);
color: var(--pokemon-blue-deep);
text-decoration: underline;
}
.life-comment__link-button .ui-icon {
@@ -1726,6 +1838,49 @@ button:disabled,
margin: 0;
}
.life-empty {
width: min(100%, 680px);
justify-self: center;
display: grid;
justify-items: center;
gap: 12px;
padding: 28px 20px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-control);
text-align: center;
}
.life-empty__icon {
width: 38px;
height: 38px;
color: var(--pokemon-blue);
}
.life-empty__copy {
display: grid;
gap: 4px;
}
.life-empty__copy h2,
.life-empty__copy p {
margin: 0;
}
.life-empty__copy h2 {
color: var(--ink);
font-family: var(--font-display);
font-size: 22px;
font-weight: 950;
line-height: 1.15;
}
.life-empty__copy p {
color: var(--muted);
font-weight: 800;
}
.reorderable-row {
position: relative;
flex-wrap: wrap;
@@ -1823,12 +1978,19 @@ button:disabled,
}
@media (prefers-reduced-motion: reduce) {
.life-page .ui-button,
.life-post__engagement-button,
.life-reaction-menu-button,
.life-reaction-option,
.life-reaction-tooltip,
.life-search-control__clear,
.reorderable-row,
.reorderable-list-move,
.drag-handle {
transition: none;
}
.life-page .ui-button:hover,
.reorderable-row.is-dragging,
.drag-handle:active {
transform: none;
@@ -2910,7 +3072,47 @@ button:disabled,
width: 100%;
}
.life-feed__list {
width: 100%;
}
.life-post {
padding: 16px;
}
.life-post__engagement {
align-items: stretch;
}
.life-post__engagement-actions,
.life-post__metrics {
width: 100%;
}
.life-post__engagement-actions {
align-items: stretch;
}
.life-reactions,
.life-reaction-control {
min-width: 0;
flex: 1 1 auto;
}
.life-reaction-trigger {
flex: 1 1 auto;
}
.life-reaction-picker {
width: min(100%, calc(100vw - 64px));
grid-template-columns: 1fr;
}
.life-post__metrics {
justify-content: flex-start;
}
.life-reaction-summary {
justify-content: flex-start;
}

View File

@@ -12,6 +12,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
import {
iconAdd,
iconCancel,
iconChevronDown,
iconComment,
iconDelete,
iconEdit,
@@ -22,7 +23,8 @@ import {
iconReactionThanks,
iconReply,
iconSave,
iconSearch
iconSearch,
iconWarning
} from '../icons';
import {
api,
@@ -65,6 +67,8 @@ 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;
@@ -83,7 +87,7 @@ const reactionOptions = [
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const canPost = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, 2000 - body.value.length));
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(() => {
@@ -222,6 +226,25 @@ function submitSearch() {
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;
@@ -391,6 +414,10 @@ function canUseReactions() {
return canPost.value && reactionBusyPostId.value === null;
}
function closeReactionPicker() {
reactionPickerPostId.value = null;
}
function toggleReactionPicker(postId: number) {
if (!canUseReactions()) {
return;
@@ -400,6 +427,22 @@ function toggleReactionPicker(postId: number) {
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);
@@ -616,6 +659,8 @@ watch(locale, () => {
});
onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
void loadCurrentUser();
void loadLifeTags();
void loadPosts();
@@ -626,6 +671,8 @@ onMounted(() => {
});
onUnmounted(() => {
document.removeEventListener('click', closeReactionPickerFromDocument);
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
disconnectFeedObserver();
removeAuthListener?.();
});
@@ -638,10 +685,21 @@ onUnmounted(() => {
</PageHeader>
<FilterPanel class="life-toolbar">
<form class="life-toolbar__search" @submit.prevent="submitSearch">
<div class="field">
<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>
<input id="life-search" v-model="searchDraft" type="search" :placeholder="t('pages.life.searchPlaceholder')" />
<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" />
@@ -681,7 +739,7 @@ onUnmounted(() => {
id="life-post-body"
ref="bodyInput"
v-model="body"
maxlength="2000"
:maxlength="bodyMaxLength"
:placeholder="t('pages.life.bodyPlaceholder')"
required
></textarea>
@@ -771,31 +829,44 @@ onUnmounted(() => {
<div class="life-post__engagement">
<div class="life-post__engagement-actions">
<div class="life-reactions">
<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)"
:aria-describedby="`life-reaction-tooltip-${post.id}`"
: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 :id="`life-reaction-tooltip-${post.id}`" class="life-reaction-tooltip" role="tooltip">
{{ reactionButtonLabel(post) }}
</span>
</button>
<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.reactions')"
:aria-label="t('pages.life.reactionMenu')"
>
<button
v-for="option in reactionOptions"
@@ -805,18 +876,11 @@ onUnmounted(() => {
type="button"
:aria-pressed="post.myReaction === option.type"
:aria-label="reactionOptionLabel(post, option.type)"
:aria-describedby="`life-reaction-option-tooltip-${post.id}-${option.type}`"
:disabled="isReactionBusy(post.id)"
@click="toggleReaction(post, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
<span
:id="`life-reaction-option-tooltip-${post.id}-${option.type}`"
class="life-reaction-tooltip"
role="tooltip"
>
{{ reactionOptionLabel(post, option.type) }}
</span>
<span>{{ reactionLabel(option.type) }}</span>
</button>
</div>
</div>
@@ -883,7 +947,7 @@ onUnmounted(() => {
<textarea
:id="`life-comment-${post.id}`"
v-model="commentBodies[post.id]"
maxlength="1000"
:maxlength="commentMaxLength"
:placeholder="t('pages.life.commentPlaceholder')"
></textarea>
</div>
@@ -946,7 +1010,7 @@ onUnmounted(() => {
<textarea
:id="`life-reply-${comment.id}`"
v-model="replyBodies[comment.id]"
maxlength="1000"
:maxlength="commentMaxLength"
:placeholder="t('pages.life.commentReplyPlaceholder')"
></textarea>
</div>
@@ -1015,9 +1079,29 @@ onUnmounted(() => {
</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>
<p v-else class="status">{{ searchQuery ? t('pages.life.searchEmpty') : t('pages.life.empty') }}</p>
<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>