diff --git a/DESIGN.md b/DESIGN.md index f5b0473..9685f59 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -385,7 +385,7 @@ Life Post 可配置: - 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 已注册并完成邮箱验证的用户可以对每条 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 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - Feed 顶部展示 Life 标签 Tabs,包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index f25c32c..728a527 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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: '编辑动态', diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index de1e5a7..5a5e475 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; } diff --git a/frontend/src/views/LifeView.vue b/frontend/src/views/LifeView.vue index 0275f1f..58b863c 100644 --- a/frontend/src/views/LifeView.vue +++ b/frontend/src/views/LifeView.vue @@ -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>({}); const bodyInput = ref(null); const loadMoreSentinel = ref(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(() => { -