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:
@@ -385,7 +385,7 @@ Life Post 可配置:
|
|||||||
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 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 标签 Tabs,包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
|
||||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ const messages = {
|
|||||||
searchTags: 'Search tags',
|
searchTags: 'Search tags',
|
||||||
search: 'Search Life',
|
search: 'Search Life',
|
||||||
searchPlaceholder: 'Search post content...',
|
searchPlaceholder: 'Search post content...',
|
||||||
|
clearSearch: 'Clear search',
|
||||||
searchEmpty: 'No posts match your search',
|
searchEmpty: 'No posts match your search',
|
||||||
|
searchEmptyHint: 'Try another keyword or clear the search.',
|
||||||
comments: 'Comments',
|
comments: 'Comments',
|
||||||
commentsCount: '{count} comments',
|
commentsCount: '{count} comments',
|
||||||
comment: 'Comment',
|
comment: 'Comment',
|
||||||
@@ -249,6 +251,8 @@ const messages = {
|
|||||||
reactionHelpful: 'Helpful',
|
reactionHelpful: 'Helpful',
|
||||||
reactionFun: 'Fun',
|
reactionFun: 'Fun',
|
||||||
reactionThanks: 'Thanks',
|
reactionThanks: 'Thanks',
|
||||||
|
chooseReaction: 'Choose reaction',
|
||||||
|
reactionMenu: 'Reaction menu',
|
||||||
removeReaction: 'Remove reaction',
|
removeReaction: 'Remove reaction',
|
||||||
reactionFailed: 'Reaction failed',
|
reactionFailed: 'Reaction failed',
|
||||||
commentPlaceholder: 'Write a comment...',
|
commentPlaceholder: 'Write a comment...',
|
||||||
@@ -273,7 +277,9 @@ const messages = {
|
|||||||
updating: 'Updating',
|
updating: 'Updating',
|
||||||
cancelEdit: 'Cancel edit',
|
cancelEdit: 'Cancel edit',
|
||||||
empty: 'No posts yet',
|
empty: 'No posts yet',
|
||||||
|
emptyHint: 'Verified members can start the first Life post.',
|
||||||
loading: 'Loading Life feed',
|
loading: 'Loading Life feed',
|
||||||
|
retryFeed: 'Retry loading',
|
||||||
loginPrompt: 'Log in with a verified email to post.',
|
loginPrompt: 'Log in with a verified email to post.',
|
||||||
verifyPrompt: 'Complete email verification to post.',
|
verifyPrompt: 'Complete email verification to post.',
|
||||||
editPost: 'Edit post',
|
editPost: 'Edit post',
|
||||||
@@ -588,7 +594,9 @@ const messages = {
|
|||||||
searchTags: '搜索标签',
|
searchTags: '搜索标签',
|
||||||
search: '搜索动态',
|
search: '搜索动态',
|
||||||
searchPlaceholder: '搜索动态内容……',
|
searchPlaceholder: '搜索动态内容……',
|
||||||
|
clearSearch: '清除搜索',
|
||||||
searchEmpty: '没有匹配的动态',
|
searchEmpty: '没有匹配的动态',
|
||||||
|
searchEmptyHint: '换个关键词或清除搜索。',
|
||||||
comments: '评论',
|
comments: '评论',
|
||||||
commentsCount: '{count} 条评论',
|
commentsCount: '{count} 条评论',
|
||||||
comment: '评论',
|
comment: '评论',
|
||||||
@@ -601,6 +609,8 @@ const messages = {
|
|||||||
reactionHelpful: '有帮助',
|
reactionHelpful: '有帮助',
|
||||||
reactionFun: '有趣',
|
reactionFun: '有趣',
|
||||||
reactionThanks: '感谢',
|
reactionThanks: '感谢',
|
||||||
|
chooseReaction: '选择互动',
|
||||||
|
reactionMenu: '互动菜单',
|
||||||
removeReaction: '取消互动',
|
removeReaction: '取消互动',
|
||||||
reactionFailed: '互动失败',
|
reactionFailed: '互动失败',
|
||||||
commentPlaceholder: '写下评论……',
|
commentPlaceholder: '写下评论……',
|
||||||
@@ -625,7 +635,9 @@ const messages = {
|
|||||||
updating: '更新中',
|
updating: '更新中',
|
||||||
cancelEdit: '取消编辑',
|
cancelEdit: '取消编辑',
|
||||||
empty: '暂无动态',
|
empty: '暂无动态',
|
||||||
|
emptyHint: '已验证成员可以发布第一条 Life 动态。',
|
||||||
loading: '正在加载 Life 动态',
|
loading: '正在加载 Life 动态',
|
||||||
|
retryFeed: '重试加载',
|
||||||
loginPrompt: '使用已验证邮箱登录后即可发布。',
|
loginPrompt: '使用已验证邮箱登录后即可发布。',
|
||||||
verifyPrompt: '完成邮箱验证后即可发布。',
|
verifyPrompt: '完成邮箱验证后即可发布。',
|
||||||
editPost: '编辑动态',
|
editPost: '编辑动态',
|
||||||
|
|||||||
@@ -1189,13 +1189,51 @@ button:disabled,
|
|||||||
.life-toolbar {
|
.life-toolbar {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-toolbar__search {
|
.life-toolbar__search {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(220px, 1fr) auto;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 10px;
|
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 {
|
.life-toolbar__actions {
|
||||||
@@ -1251,10 +1289,25 @@ button:disabled,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-feed {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-feed__list {
|
||||||
|
width: min(100%, 920px);
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.life-feed__sentinel {
|
.life-feed__sentinel {
|
||||||
min-height: 1px;
|
min-height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-feed__retry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-form__counter {
|
.life-form__counter {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
}
|
}
|
||||||
@@ -1288,6 +1341,8 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.life-post {
|
.life-post {
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
box-shadow: var(--shadow-soft);
|
box-shadow: var(--shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,8 +1400,10 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.life-post__body {
|
.life-post__body {
|
||||||
|
max-width: 72ch;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
font-size: 16px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -1381,8 +1438,8 @@ button:disabled,
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 10px 14px;
|
||||||
padding-top: 8px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1411,6 +1468,10 @@ button:disabled,
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.14s ease,
|
||||||
|
border-color 0.14s ease,
|
||||||
|
color 0.14s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-post__engagement-button:hover,
|
.life-post__engagement-button:hover,
|
||||||
@@ -1435,11 +1496,49 @@ button:disabled,
|
|||||||
position: relative;
|
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 {
|
.life-reaction-trigger {
|
||||||
position: relative;
|
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;
|
width: 44px;
|
||||||
justify-content: center;
|
min-height: 44px;
|
||||||
padding: 7px;
|
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 {
|
.life-reaction-picker {
|
||||||
@@ -1447,10 +1546,10 @@ button:disabled,
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
left: 0;
|
left: 0;
|
||||||
width: max-content;
|
width: min(280px, calc(100vw - 48px));
|
||||||
max-width: calc(100vw - 48px);
|
max-width: calc(100vw - 48px);
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 2px solid var(--line-strong);
|
border: 2px solid var(--line-strong);
|
||||||
@@ -1461,20 +1560,29 @@ button:disabled,
|
|||||||
|
|
||||||
.life-reaction-option {
|
.life-reaction-option {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 44px;
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
padding: 7px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
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);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-reaction-option span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.life-reaction-option:hover,
|
.life-reaction-option:hover,
|
||||||
.life-reaction-option.is-active {
|
.life-reaction-option.is-active {
|
||||||
border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line));
|
border-color: color-mix(in srgb, var(--pokemon-blue) 50%, var(--line));
|
||||||
@@ -1564,6 +1672,8 @@ button:disabled,
|
|||||||
.life-comments {
|
.life-comments {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-comments__header {
|
.life-comments__header {
|
||||||
@@ -1596,6 +1706,7 @@ button:disabled,
|
|||||||
.life-comment-form {
|
.life-comment-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-comment-form textarea {
|
.life-comment-form textarea {
|
||||||
@@ -1700,11 +1811,12 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.life-comment__link-button {
|
.life-comment__link-button {
|
||||||
min-height: 32px;
|
min-height: 44px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 3px 0;
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--pokemon-blue);
|
color: var(--pokemon-blue);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1713,8 +1825,8 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.life-comment__link-button:hover {
|
.life-comment__link-button:hover {
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 9%, transparent);
|
||||||
color: var(--pokemon-blue-deep);
|
color: var(--pokemon-blue-deep);
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.life-comment__link-button .ui-icon {
|
.life-comment__link-button .ui-icon {
|
||||||
@@ -1726,6 +1838,49 @@ button:disabled,
|
|||||||
margin: 0;
|
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 {
|
.reorderable-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1823,12 +1978,19 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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-row,
|
||||||
.reorderable-list-move,
|
.reorderable-list-move,
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-page .ui-button:hover,
|
||||||
.reorderable-row.is-dragging,
|
.reorderable-row.is-dragging,
|
||||||
.drag-handle:active {
|
.drag-handle:active {
|
||||||
transform: none;
|
transform: none;
|
||||||
@@ -2910,7 +3072,47 @@ button:disabled,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-feed__list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-post {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-post__engagement {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-post__engagement-actions,
|
||||||
.life-post__metrics {
|
.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;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|||||||
import {
|
import {
|
||||||
iconAdd,
|
iconAdd,
|
||||||
iconCancel,
|
iconCancel,
|
||||||
|
iconChevronDown,
|
||||||
iconComment,
|
iconComment,
|
||||||
iconDelete,
|
iconDelete,
|
||||||
iconEdit,
|
iconEdit,
|
||||||
@@ -22,7 +23,8 @@ import {
|
|||||||
iconReactionThanks,
|
iconReactionThanks,
|
||||||
iconReply,
|
iconReply,
|
||||||
iconSave,
|
iconSave,
|
||||||
iconSearch
|
iconSearch,
|
||||||
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -65,6 +67,8 @@ const reactionErrors = ref<Record<number, string>>({});
|
|||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
const lifePostPageSize = 20;
|
const lifePostPageSize = 20;
|
||||||
|
const bodyMaxLength = 2000;
|
||||||
|
const commentMaxLength = 1000;
|
||||||
const skeletonPostCount = 3;
|
const skeletonPostCount = 3;
|
||||||
const loadingMoreSkeletonCount = 2;
|
const loadingMoreSkeletonCount = 2;
|
||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
@@ -83,7 +87,7 @@ const reactionOptions = [
|
|||||||
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
||||||
|
|
||||||
const canPost = computed(() => currentUser.value?.emailVerified === true);
|
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 isEditing = computed(() => editingPostId.value !== null);
|
||||||
const searchQuery = computed(() => submittedSearch.value.trim());
|
const searchQuery = computed(() => submittedSearch.value.trim());
|
||||||
const selectedFeedTagId = computed(() => {
|
const selectedFeedTagId = computed(() => {
|
||||||
@@ -222,6 +226,25 @@ function submitSearch() {
|
|||||||
void loadPosts();
|
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) {
|
function matchesCurrentFilters(post: LifePost) {
|
||||||
const keyword = searchQuery.value.toLowerCase();
|
const keyword = searchQuery.value.toLowerCase();
|
||||||
const tagId = selectedFeedTagId.value;
|
const tagId = selectedFeedTagId.value;
|
||||||
@@ -391,6 +414,10 @@ function canUseReactions() {
|
|||||||
return canPost.value && reactionBusyPostId.value === null;
|
return canPost.value && reactionBusyPostId.value === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeReactionPicker() {
|
||||||
|
reactionPickerPostId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleReactionPicker(postId: number) {
|
function toggleReactionPicker(postId: number) {
|
||||||
if (!canUseReactions()) {
|
if (!canUseReactions()) {
|
||||||
return;
|
return;
|
||||||
@@ -400,6 +427,22 @@ function toggleReactionPicker(postId: number) {
|
|||||||
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
|
reactionPickerPostId.value = reactionPickerPostId.value === postId ? null : postId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeReactionPickerFromDocument(event: MouseEvent) {
|
||||||
|
if (reactionPickerPostId.value === null || !(event.target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest('.life-reactions')) {
|
||||||
|
closeReactionPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReactionPickerFromKeyboard(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape' && reactionPickerPostId.value !== null) {
|
||||||
|
closeReactionPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleReactionContextMenu(event: MouseEvent, postId: number) {
|
function handleReactionContextMenu(event: MouseEvent, postId: number) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleReactionPicker(postId);
|
toggleReactionPicker(postId);
|
||||||
@@ -616,6 +659,8 @@ watch(locale, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLifeTags();
|
void loadLifeTags();
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
@@ -626,6 +671,8 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
||||||
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
disconnectFeedObserver();
|
disconnectFeedObserver();
|
||||||
removeAuthListener?.();
|
removeAuthListener?.();
|
||||||
});
|
});
|
||||||
@@ -638,10 +685,21 @@ onUnmounted(() => {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<FilterPanel class="life-toolbar">
|
<FilterPanel class="life-toolbar">
|
||||||
<form class="life-toolbar__search" @submit.prevent="submitSearch">
|
<form class="life-toolbar__search" role="search" @submit.prevent="submitSearch">
|
||||||
<div class="field">
|
<div class="field life-toolbar__field">
|
||||||
<label for="life-search">{{ t('pages.life.search') }}</label>
|
<label for="life-search">{{ t('pages.life.search') }}</label>
|
||||||
|
<div class="life-search-control">
|
||||||
<input id="life-search" v-model="searchDraft" type="search" :placeholder="t('pages.life.searchPlaceholder')" />
|
<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>
|
</div>
|
||||||
<button class="ui-button ui-button--ghost" type="submit">
|
<button class="ui-button ui-button--ghost" type="submit">
|
||||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
@@ -681,7 +739,7 @@ onUnmounted(() => {
|
|||||||
id="life-post-body"
|
id="life-post-body"
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
maxlength="2000"
|
:maxlength="bodyMaxLength"
|
||||||
:placeholder="t('pages.life.bodyPlaceholder')"
|
:placeholder="t('pages.life.bodyPlaceholder')"
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -771,6 +829,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-post__engagement">
|
<div class="life-post__engagement">
|
||||||
<div class="life-post__engagement-actions">
|
<div class="life-post__engagement-actions">
|
||||||
<div class="life-reactions">
|
<div class="life-reactions">
|
||||||
|
<div class="life-reaction-control">
|
||||||
<button
|
<button
|
||||||
class="life-post__engagement-button life-reaction-trigger"
|
class="life-post__engagement-button life-reaction-trigger"
|
||||||
:class="{ 'is-active': post.myReaction !== null }"
|
:class="{ 'is-active': post.myReaction !== null }"
|
||||||
@@ -778,24 +837,36 @@ onUnmounted(() => {
|
|||||||
:aria-controls="`life-reactions-${post.id}`"
|
:aria-controls="`life-reactions-${post.id}`"
|
||||||
:aria-expanded="reactionPickerPostId === post.id"
|
:aria-expanded="reactionPickerPostId === post.id"
|
||||||
:aria-label="reactionButtonLabel(post)"
|
:aria-label="reactionButtonLabel(post)"
|
||||||
:aria-describedby="`life-reaction-tooltip-${post.id}`"
|
|
||||||
:disabled="!canPost || reactionBusyPostId !== null"
|
:disabled="!canPost || reactionBusyPostId !== null"
|
||||||
@click="toggleDefaultReaction(post)"
|
@click="toggleDefaultReaction(post)"
|
||||||
@contextmenu="handleReactionContextMenu($event, post.id)"
|
@contextmenu="handleReactionContextMenu($event, post.id)"
|
||||||
@keydown="handleReactionKeydown($event, post.id)"
|
@keydown="handleReactionKeydown($event, post.id)"
|
||||||
>
|
>
|
||||||
<Icon :icon="reactionIcon(post.myReaction)" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="reactionIcon(post.myReaction)" class="ui-icon" aria-hidden="true" />
|
||||||
<span :id="`life-reaction-tooltip-${post.id}`" class="life-reaction-tooltip" role="tooltip">
|
<span class="life-reaction-trigger__label">{{ reactionButtonLabel(post) }}</span>
|
||||||
{{ reactionButtonLabel(post) }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</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
|
<div
|
||||||
v-if="reactionPickerPostId === post.id && canPost"
|
v-if="reactionPickerPostId === post.id && canPost"
|
||||||
:id="`life-reactions-${post.id}`"
|
:id="`life-reactions-${post.id}`"
|
||||||
class="life-reaction-picker"
|
class="life-reaction-picker"
|
||||||
role="group"
|
role="group"
|
||||||
:aria-label="t('pages.life.reactions')"
|
:aria-label="t('pages.life.reactionMenu')"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="option in reactionOptions"
|
v-for="option in reactionOptions"
|
||||||
@@ -805,18 +876,11 @@ onUnmounted(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
:aria-pressed="post.myReaction === option.type"
|
:aria-pressed="post.myReaction === option.type"
|
||||||
:aria-label="reactionOptionLabel(post, option.type)"
|
:aria-label="reactionOptionLabel(post, option.type)"
|
||||||
:aria-describedby="`life-reaction-option-tooltip-${post.id}-${option.type}`"
|
|
||||||
:disabled="isReactionBusy(post.id)"
|
:disabled="isReactionBusy(post.id)"
|
||||||
@click="toggleReaction(post, option.type)"
|
@click="toggleReaction(post, option.type)"
|
||||||
>
|
>
|
||||||
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
|
||||||
<span
|
<span>{{ reactionLabel(option.type) }}</span>
|
||||||
:id="`life-reaction-option-tooltip-${post.id}-${option.type}`"
|
|
||||||
class="life-reaction-tooltip"
|
|
||||||
role="tooltip"
|
|
||||||
>
|
|
||||||
{{ reactionOptionLabel(post, option.type) }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -883,7 +947,7 @@ onUnmounted(() => {
|
|||||||
<textarea
|
<textarea
|
||||||
:id="`life-comment-${post.id}`"
|
:id="`life-comment-${post.id}`"
|
||||||
v-model="commentBodies[post.id]"
|
v-model="commentBodies[post.id]"
|
||||||
maxlength="1000"
|
:maxlength="commentMaxLength"
|
||||||
:placeholder="t('pages.life.commentPlaceholder')"
|
:placeholder="t('pages.life.commentPlaceholder')"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -946,7 +1010,7 @@ onUnmounted(() => {
|
|||||||
<textarea
|
<textarea
|
||||||
:id="`life-reply-${comment.id}`"
|
:id="`life-reply-${comment.id}`"
|
||||||
v-model="replyBodies[comment.id]"
|
v-model="replyBodies[comment.id]"
|
||||||
maxlength="1000"
|
:maxlength="commentMaxLength"
|
||||||
:placeholder="t('pages.life.commentReplyPlaceholder')"
|
:placeholder="t('pages.life.commentReplyPlaceholder')"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,9 +1079,29 @@ onUnmounted(() => {
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="hasMorePosts" ref="loadMoreSentinel" class="life-feed__sentinel" aria-hidden="true"></div>
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user