feat(life): add Life Post detail page and endpoint
Implement GET /api/life-posts/:id with moderation and visibility rules Add /life/:id route and LifePostDetail view Update feeds and user profiles to link to the new detail page
This commit is contained in:
@@ -775,6 +775,7 @@ Life Post 可配置:
|
|||||||
|
|
||||||
- 所有人都可以浏览 Life 信息流。
|
- 所有人都可以浏览 Life 信息流。
|
||||||
- 信息流按创建时间倒序展示。
|
- 信息流按创建时间倒序展示。
|
||||||
|
- Life Post 有独立详情页 `/life/:id`;用户可从 Life 信息流、User Profile 的 Feeds、Reactions 和 Comments 进入。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
||||||
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
||||||
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
||||||
@@ -782,7 +783,9 @@ Life Post 可配置:
|
|||||||
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
|
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
|
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
|
||||||
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
|
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
@@ -798,6 +801,7 @@ Life Post 可配置:
|
|||||||
- 当前没有图片上传、转发或置顶。
|
- 当前没有图片上传、转发或置顶。
|
||||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||||
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
||||||
|
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
||||||
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
|
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
|
||||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||||
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
||||||
@@ -814,6 +818,7 @@ API 暴露边界:
|
|||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction,不返回其他用户的 Reaction 明细。
|
||||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
||||||
|
- Life Post 详情 API 返回单条 Life Post,字段边界与列表项一致;评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。
|
||||||
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论。
|
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论。
|
||||||
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误或不必要的审计 payload。
|
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误或不必要的审计 payload。
|
||||||
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||||
@@ -922,8 +927,9 @@ API 暴露边界:
|
|||||||
- `/recipes`
|
- `/recipes`
|
||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
|
- `/life/:id`
|
||||||
- `/project-updates`
|
- `/project-updates`
|
||||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
||||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
||||||
@@ -957,6 +963,7 @@ API 暴露边界:
|
|||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||||
|
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
|
|||||||
@@ -3626,27 +3626,49 @@ export async function listUserCommentActivities(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLifePostById(id: number, userId: number | null = null, locale = defaultLocale): Promise<LifePost | null> {
|
async function getLifePostById(
|
||||||
|
id: number,
|
||||||
|
userId: number | null = null,
|
||||||
|
locale = defaultLocale,
|
||||||
|
options: { enforceVisibility?: boolean; canViewAll?: boolean } = {}
|
||||||
|
): Promise<LifePost | null> {
|
||||||
|
const params: unknown[] = [id];
|
||||||
|
const conditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
||||||
|
|
||||||
|
if (options.enforceVisibility) {
|
||||||
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, options.canViewAll === true);
|
||||||
|
}
|
||||||
|
|
||||||
const post = await queryOne<LifePostRow>(
|
const post = await queryOne<LifePostRow>(
|
||||||
`
|
`
|
||||||
${lifePostProjection(locale)}
|
${lifePostProjection(locale)}
|
||||||
WHERE lp.id = $1
|
WHERE ${conditions.join(' AND ')}
|
||||||
AND lp.deleted_at IS NULL
|
|
||||||
`,
|
`,
|
||||||
[id]
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, false);
|
const canViewAll = options.canViewAll === true;
|
||||||
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, false);
|
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
|
||||||
|
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
|
||||||
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
|
||||||
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
|
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
|
||||||
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
|
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLifePost(
|
||||||
|
idValue: number,
|
||||||
|
userId: number | null = null,
|
||||||
|
locale = defaultLocale,
|
||||||
|
canViewAll = false
|
||||||
|
): Promise<LifePost | null> {
|
||||||
|
const id = requirePositiveInteger(idValue, 'server.validation.recordInvalid');
|
||||||
|
return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
|
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
|
||||||
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
|
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
getAncientArtifact,
|
getAncientArtifact,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
|
getLifePost,
|
||||||
getOptions,
|
getOptions,
|
||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
@@ -1198,6 +1199,16 @@ app.get('/api/life-posts', async (request) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/life-posts/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
const canViewAll = user
|
||||||
|
? userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any')
|
||||||
|
: false;
|
||||||
|
const post = await getLifePost(Number(id), user?.id ?? null, requestLocale(request), canViewAll);
|
||||||
|
return post ? post : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/life-posts/:postId/comments', async (request, reply) => {
|
app.get('/api/life-posts/:postId/comments', async (request, reply) => {
|
||||||
const { postId } = request.params as { postId: string };
|
const { postId } = request.params as { postId: string };
|
||||||
const user = await optionalUser(request);
|
const user = await optionalUser(request);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
|
|||||||
import RecipeList from '../views/RecipeList.vue';
|
import RecipeList from '../views/RecipeList.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
|
import LifePostDetail from '../views/LifePostDetail.vue';
|
||||||
import LifeView from '../views/LifeView.vue';
|
import LifeView from '../views/LifeView.vue';
|
||||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||||
import LegalView from '../views/LegalView.vue';
|
import LegalView from '../views/LegalView.vue';
|
||||||
@@ -300,6 +301,7 @@ export const router = createRouter({
|
|||||||
},
|
},
|
||||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||||
|
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
|
||||||
{
|
{
|
||||||
path: '/project-updates',
|
path: '/project-updates',
|
||||||
component: ProjectUpdatesView,
|
component: ProjectUpdatesView,
|
||||||
|
|||||||
@@ -1030,6 +1030,7 @@ export const api = {
|
|||||||
sort: params.sort
|
sort: params.sort
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
|
lifePost: (id: string | number) => getJson<LifePost>(`/api/life-posts/${id}`),
|
||||||
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||||
|
|||||||
@@ -2150,6 +2150,17 @@ button:disabled,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-detail-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.life-detail-layout {
|
||||||
|
width: min(100%, 880px);
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-feed__list {
|
.life-feed__list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-self: stretch;
|
justify-self: stretch;
|
||||||
@@ -2487,6 +2498,10 @@ button:disabled,
|
|||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-metric-button--static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.life-icon-button:hover,
|
.life-icon-button:hover,
|
||||||
.life-icon-button[aria-expanded="true"],
|
.life-icon-button[aria-expanded="true"],
|
||||||
.life-icon-button.is-active,
|
.life-icon-button.is-active,
|
||||||
@@ -2497,6 +2512,12 @@ button:disabled,
|
|||||||
color: var(--pokemon-blue-deep);
|
color: var(--pokemon-blue-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.life-metric-button--static:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.life-icon-button--flat {
|
.life-icon-button--flat {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -5849,12 +5870,30 @@ button:disabled,
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-feed-card__metrics .ui-icon {
|
.profile-feed-card__detail-link,
|
||||||
|
.profile-post-preview__detail {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 950;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-feed-card__metrics .ui-icon,
|
||||||
|
.profile-post-preview__detail .ui-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
color: var(--pokemon-blue);
|
color: var(--pokemon-blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-feed-card__detail-link:hover,
|
||||||
|
.profile-post-preview__detail:hover {
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-load-more {
|
.profile-load-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
923
frontend/src/views/LifePostDetail.vue
Normal file
923
frontend/src/views/LifePostDetail.vue
Normal file
@@ -0,0 +1,923 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import {
|
||||||
|
iconBack,
|
||||||
|
iconCancel,
|
||||||
|
iconChevronDown,
|
||||||
|
iconComment,
|
||||||
|
iconDelete,
|
||||||
|
iconLife,
|
||||||
|
iconReactionFun,
|
||||||
|
iconReactionHelpful,
|
||||||
|
iconReactionLike,
|
||||||
|
iconReactionThanks,
|
||||||
|
iconReply,
|
||||||
|
iconVersion,
|
||||||
|
iconWarning
|
||||||
|
} from '../icons';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
getAuthToken,
|
||||||
|
onAuthTokenChange,
|
||||||
|
setAuthToken,
|
||||||
|
type AiModerationStatus,
|
||||||
|
type AuthUser,
|
||||||
|
type LifeComment,
|
||||||
|
type LifePost,
|
||||||
|
type LifeReactionType
|
||||||
|
} from '../services/api';
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const post = ref<LifePost | null>(null);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadError = ref('');
|
||||||
|
const comments = ref<LifeComment[]>([]);
|
||||||
|
const commentsNextCursor = ref<string | null>(null);
|
||||||
|
const commentsHasMore = ref(false);
|
||||||
|
const commentsTotal = ref(0);
|
||||||
|
const commentsLoading = ref(false);
|
||||||
|
const commentsLoadingMore = ref(false);
|
||||||
|
const commentsLoaded = ref(false);
|
||||||
|
const commentsError = ref('');
|
||||||
|
const commentBodies = ref<Record<number, string>>({});
|
||||||
|
const replyBodies = ref<Record<number, string>>({});
|
||||||
|
const replyTargetId = ref<number | null>(null);
|
||||||
|
const commentBusyKey = ref('');
|
||||||
|
const commentErrors = ref<Record<string, string>>({});
|
||||||
|
const reactionPickerPostId = ref<number | null>(null);
|
||||||
|
const reactionBusyPostId = ref<number | null>(null);
|
||||||
|
const reactionErrors = ref<Record<number, string>>({});
|
||||||
|
const ratingBusyPostId = ref<number | null>(null);
|
||||||
|
const ratingErrors = ref<Record<number, string>>({});
|
||||||
|
const moderationBusyPostId = ref<number | null>(null);
|
||||||
|
const moderationErrors = ref<Record<number, string>>({});
|
||||||
|
const lifeCommentPageSize = 20;
|
||||||
|
const commentMaxLength = 1000;
|
||||||
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
const reactionOptions = [
|
||||||
|
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||||
|
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
|
||||||
|
{ type: 'fun', icon: iconReactionFun, labelKey: 'pages.life.reactionFun' },
|
||||||
|
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
|
||||||
|
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
|
||||||
|
|
||||||
|
function can(permissionKey: string) {
|
||||||
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canComment = computed(() => can('life.comments.create'));
|
||||||
|
const canReact = computed(() => can('life.reactions.set'));
|
||||||
|
const canRate = computed(() => can('life.ratings.set'));
|
||||||
|
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
|
||||||
|
|
||||||
|
function routePostId() {
|
||||||
|
const value = route.params.id;
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
if (!getAuthToken()) {
|
||||||
|
currentUser.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
setAuthToken(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentTreeCount(items: LifeComment[]) {
|
||||||
|
return items.reduce((count, item) => count + 1 + item.replies.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCommentsFromPost(nextPost: LifePost) {
|
||||||
|
comments.value = nextPost.commentPreview;
|
||||||
|
commentsNextCursor.value = null;
|
||||||
|
commentsHasMore.value = nextPost.commentCount > commentTreeCount(nextPost.commentPreview);
|
||||||
|
commentsTotal.value = nextPost.commentCount;
|
||||||
|
commentsLoaded.value = false;
|
||||||
|
commentsError.value = '';
|
||||||
|
commentBodies.value = {};
|
||||||
|
replyBodies.value = {};
|
||||||
|
replyTargetId.value = null;
|
||||||
|
commentErrors.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPost() {
|
||||||
|
const id = routePostId();
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = '';
|
||||||
|
post.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPost = await api.lifePost(id);
|
||||||
|
post.value = nextPost;
|
||||||
|
resetCommentsFromPost(nextPost);
|
||||||
|
void loadComments(true);
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
||||||
|
const ids = new Set(existing.map((comment) => comment.id));
|
||||||
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadComments(reset = false) {
|
||||||
|
const currentPost = post.value;
|
||||||
|
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = reset || !commentsLoaded.value ? null : commentsNextCursor.value;
|
||||||
|
commentsLoading.value = reset || !commentsLoaded.value;
|
||||||
|
commentsLoadingMore.value = !reset && commentsLoaded.value;
|
||||||
|
commentsError.value = '';
|
||||||
|
|
||||||
|
if (reset || !commentsLoaded.value) {
|
||||||
|
comments.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor });
|
||||||
|
comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items);
|
||||||
|
commentsNextCursor.value = page.nextCursor;
|
||||||
|
commentsHasMore.value = page.hasMore;
|
||||||
|
commentsTotal.value = page.total;
|
||||||
|
commentsLoaded.value = true;
|
||||||
|
currentPost.commentCount = page.total;
|
||||||
|
} catch (error) {
|
||||||
|
commentsError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
} finally {
|
||||||
|
commentsLoading.value = false;
|
||||||
|
commentsLoadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentKey(postId: number) {
|
||||||
|
return `post-${postId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyKey(commentId: number) {
|
||||||
|
return `reply-${commentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommentBusy(key: string) {
|
||||||
|
return commentBusyKey.value === key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReactionBusy(postId: number) {
|
||||||
|
return reactionBusyPostId.value === postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRatingBusy(postId: number) {
|
||||||
|
return ratingBusyPostId.value === postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManage(currentPost: LifePost) {
|
||||||
|
return (currentUser.value?.id === currentPost.author?.id && can('life.posts.update')) || can('life.posts.update-any');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageComment(comment: LifeComment) {
|
||||||
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseReactions() {
|
||||||
|
return canReact.value && reactionBusyPostId.value === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseRatings(currentPost: LifePost) {
|
||||||
|
return canRate.value && ratingBusyPostId.value === null && currentPost.moderationStatus === 'approved' && currentPost.category?.isRateable === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionTotal(currentPost: LifePost) {
|
||||||
|
return reactionOptions.reduce((count, option) => count + (currentPost.reactionCounts[option.type] ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionLabel(type: LifeReactionType) {
|
||||||
|
return t(reactionOptions.find((option) => option.type === type)?.labelKey ?? 'pages.life.react');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionIcon(type: LifeReactionType | null) {
|
||||||
|
return reactionOptions.find((option) => option.type === type)?.icon ?? iconReactionLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionButtonLabel(currentPost: LifePost) {
|
||||||
|
return currentPost.myReaction ? reactionLabel(currentPost.myReaction) : t('pages.life.reactionLike');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionOptionLabel(currentPost: LifePost, type: LifeReactionType) {
|
||||||
|
return currentPost.myReaction === type ? t('pages.life.removeReaction') : reactionLabel(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) {
|
||||||
|
return t('pages.life.reactionCountLabel', {
|
||||||
|
reaction: reactionLabel(type),
|
||||||
|
count: currentPost.reactionCounts[type] ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationLabel(status: AiModerationStatus) {
|
||||||
|
const labels: Record<AiModerationStatus, string> = {
|
||||||
|
unreviewed: t('pages.life.moderationUnreviewed'),
|
||||||
|
reviewing: t('pages.life.moderationReviewing'),
|
||||||
|
approved: t('pages.life.moderationApproved'),
|
||||||
|
rejected: t('pages.life.moderationRejected'),
|
||||||
|
failed: t('pages.life.moderationFailed')
|
||||||
|
};
|
||||||
|
return labels[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationTone(status: AiModerationStatus) {
|
||||||
|
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||||
|
unreviewed: 'neutral',
|
||||||
|
reviewing: 'info',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger',
|
||||||
|
failed: 'warning'
|
||||||
|
};
|
||||||
|
return tones[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryModeration(currentPost: LifePost) {
|
||||||
|
return currentPost.moderationStatus !== 'approved' && canManage(currentPost);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replacePost(updatedPost: LifePost) {
|
||||||
|
post.value = updatedPost;
|
||||||
|
commentsTotal.value = updatedPost.commentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryPostModeration(currentPost: LifePost) {
|
||||||
|
moderationBusyPostId.value = currentPost.id;
|
||||||
|
const nextErrors = { ...moderationErrors.value };
|
||||||
|
delete nextErrors[currentPost.id];
|
||||||
|
moderationErrors.value = nextErrors;
|
||||||
|
|
||||||
|
try {
|
||||||
|
replacePost(await api.retryLifePostModeration(currentPost.id));
|
||||||
|
} catch (error) {
|
||||||
|
moderationErrors.value = {
|
||||||
|
...moderationErrors.value,
|
||||||
|
[currentPost.id]: error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed')
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
moderationBusyPostId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReactionPicker() {
|
||||||
|
reactionPickerPostId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReactionPicker(postId: number) {
|
||||||
|
if (!canUseReactions()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReactionError(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) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleReactionPicker(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReactionKeydown(event: KeyboardEvent, postId: number) {
|
||||||
|
if (event.key !== 'ContextMenu' && !(event.shiftKey && event.key === 'F10')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
toggleReactionPicker(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDefaultReaction(currentPost: LifePost) {
|
||||||
|
await toggleReaction(currentPost, 'like');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReaction(currentPost: LifePost, reactionType: LifeReactionType) {
|
||||||
|
if (!canUseReactions() || currentPost.moderationStatus !== 'approved') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reactionBusyPostId.value = currentPost.id;
|
||||||
|
clearReactionError(currentPost.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedPost =
|
||||||
|
currentPost.myReaction === reactionType
|
||||||
|
? await api.deleteLifeReaction(currentPost.id)
|
||||||
|
: await api.setLifeReaction(currentPost.id, reactionType);
|
||||||
|
replacePost(updatedPost);
|
||||||
|
reactionPickerPostId.value = null;
|
||||||
|
} catch (error) {
|
||||||
|
setReactionError(currentPost.id, error instanceof Error && error.message ? error.message : t('pages.life.reactionFailed'));
|
||||||
|
} finally {
|
||||||
|
reactionBusyPostId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRating(currentPost: LifePost, rating: number) {
|
||||||
|
if (!canUseRatings(currentPost)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ratingBusyPostId.value = currentPost.id;
|
||||||
|
clearRatingError(currentPost.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedPost =
|
||||||
|
currentPost.myRating === rating ? await api.deleteLifeRating(currentPost.id) : await api.setLifeRating(currentPost.id, rating);
|
||||||
|
replacePost(updatedPost);
|
||||||
|
} catch (error) {
|
||||||
|
setRatingError(currentPost.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
|
||||||
|
} finally {
|
||||||
|
ratingBusyPostId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReactionError(postId: number, message: string) {
|
||||||
|
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReactionError(postId: number) {
|
||||||
|
const nextErrors = { ...reactionErrors.value };
|
||||||
|
delete nextErrors[postId];
|
||||||
|
reactionErrors.value = nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRatingError(postId: number, message: string) {
|
||||||
|
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRatingError(postId: number) {
|
||||||
|
const nextErrors = { ...ratingErrors.value };
|
||||||
|
delete nextErrors[postId];
|
||||||
|
ratingErrors.value = nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCommentError(key: string, message: string) {
|
||||||
|
commentErrors.value = { ...commentErrors.value, [key]: message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCommentError(key: string) {
|
||||||
|
const nextErrors = { ...commentErrors.value };
|
||||||
|
delete nextErrors[key];
|
||||||
|
commentErrors.value = nextErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComment(currentPost: LifePost) {
|
||||||
|
const key = commentKey(currentPost.id);
|
||||||
|
const nextBody = (commentBodies.value[currentPost.id] ?? '').trim();
|
||||||
|
if (!nextBody) {
|
||||||
|
setCommentError(key, t('pages.life.commentRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await api.createLifeComment(currentPost.id, {
|
||||||
|
body: nextBody,
|
||||||
|
languageCode: currentPost.moderationLanguageCode
|
||||||
|
});
|
||||||
|
comments.value = mergeComments(comments.value, [comment]);
|
||||||
|
commentsTotal.value += 1;
|
||||||
|
currentPost.commentCount = commentsTotal.value;
|
||||||
|
commentsLoaded.value = true;
|
||||||
|
commentBodies.value[currentPost.id] = '';
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||||
|
} finally {
|
||||||
|
commentBusyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(comment: LifeComment) {
|
||||||
|
replyTargetId.value = comment.id;
|
||||||
|
clearCommentError(replyKey(comment.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply(commentId: number) {
|
||||||
|
replyTargetId.value = null;
|
||||||
|
replyBodies.value[commentId] = '';
|
||||||
|
clearCommentError(replyKey(commentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReply(currentPost: LifePost, comment: LifeComment) {
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
const nextBody = (replyBodies.value[comment.id] ?? '').trim();
|
||||||
|
if (!nextBody) {
|
||||||
|
setCommentError(key, t('pages.life.commentRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reply = await api.createLifeCommentReply(currentPost.id, comment.id, {
|
||||||
|
body: nextBody,
|
||||||
|
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
|
||||||
|
});
|
||||||
|
comment.replies.push(reply);
|
||||||
|
commentsTotal.value += 1;
|
||||||
|
currentPost.commentCount = commentsTotal.value;
|
||||||
|
commentsLoaded.value = true;
|
||||||
|
cancelReply(comment.id);
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||||
|
} finally {
|
||||||
|
commentBusyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCommentDeleted(items: LifeComment[], id: number): boolean {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === id) {
|
||||||
|
item.deleted = true;
|
||||||
|
item.body = '';
|
||||||
|
item.author = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markCommentDeleted(item.replies, id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(comment: LifeComment) {
|
||||||
|
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteLifeComment(comment.id);
|
||||||
|
markCommentDeleted(comments.value, comment.id);
|
||||||
|
if (replyTargetId.value === comment.id) {
|
||||||
|
cancelReply(comment.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentAuthorName(comment: LifeComment) {
|
||||||
|
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentInitial(comment: LifeComment) {
|
||||||
|
return commentAuthorName(comment).slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorInitial(currentPost: LifePost) {
|
||||||
|
const name = currentPost.author?.displayName.trim() || t('pages.life.byUnknown');
|
||||||
|
return name.slice(0, 1).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPostTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
void loadPost();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(locale, () => {
|
||||||
|
void loadPost();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||||
|
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
void loadCurrentUser();
|
||||||
|
void loadPost();
|
||||||
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
|
void loadCurrentUser();
|
||||||
|
void loadPost();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeReactionPickerFromDocument);
|
||||||
|
document.removeEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||||
|
removeAuthListener?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="life-detail-page">
|
||||||
|
<PageHeader :title="t('pages.life.detailTitle')" :subtitle="t('pages.life.detailSubtitle')">
|
||||||
|
<template #kicker>{{ t('pages.life.detailKicker') }}</template>
|
||||||
|
<template #actions>
|
||||||
|
<RouterLink class="ui-button ui-button--ghost" to="/life">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.life.backToLife') }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<StatusMessage v-if="loadError" variant="danger" :duration="0">{{ loadError }}</StatusMessage>
|
||||||
|
|
||||||
|
<div class="life-detail-layout" :aria-busy="loading || commentsLoading">
|
||||||
|
<article v-if="loading" class="life-post life-post--skeleton" aria-hidden="true">
|
||||||
|
<div class="life-post__header">
|
||||||
|
<Skeleton variant="box" width="46px" height="46px" />
|
||||||
|
<div class="life-post__byline">
|
||||||
|
<Skeleton width="140px" />
|
||||||
|
<Skeleton width="180px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton width="90%" />
|
||||||
|
<Skeleton width="68%" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="post" class="life-post life-post--detail">
|
||||||
|
<header class="life-post__header">
|
||||||
|
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
|
||||||
|
<div class="life-post__byline">
|
||||||
|
<RouterLink v-if="post.author" class="user-profile-link" :to="`/profile/${post.author.id}`">
|
||||||
|
{{ post.author.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ 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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="life-post__body">{{ post.body }}</p>
|
||||||
|
|
||||||
|
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
|
||||||
|
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
|
||||||
|
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
|
||||||
|
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ post.gameVersion.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details v-if="post.gameVersion?.changeLog" class="life-version-note">
|
||||||
|
<summary>{{ t('pages.life.changeLog') }}</summary>
|
||||||
|
<p>{{ post.gameVersion.changeLog }}</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="life-post__engagement">
|
||||||
|
<div class="life-post__engagement-actions">
|
||||||
|
<LifeRatingControl
|
||||||
|
v-if="post.category?.isRateable"
|
||||||
|
:rating-average="post.ratingAverage"
|
||||||
|
:rating-count="post.ratingCount"
|
||||||
|
:my-rating="post.myRating"
|
||||||
|
:disabled="!canUseRatings(post)"
|
||||||
|
:busy="isRatingBusy(post.id)"
|
||||||
|
@rate="toggleRating(post, $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="!canReact || post.moderationStatus !== 'approved' || 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="!canReact || post.moderationStatus !== 'approved' || 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 && canReact"
|
||||||
|
: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="post.moderationStatus !== 'approved' || 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>
|
||||||
|
|
||||||
|
<div class="life-post__review-actions">
|
||||||
|
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
||||||
|
<button
|
||||||
|
v-if="canRetryModeration(post)"
|
||||||
|
class="life-icon-button life-review-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
|
||||||
|
:disabled="moderationBusyPostId === post.id"
|
||||||
|
@click="retryPostModeration(post)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">
|
||||||
|
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<span class="life-metric-button life-metric-button--static" :aria-label="t('pages.life.commentsCount', { count: commentsTotal })">
|
||||||
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span>{{ commentsTotal }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
|
||||||
|
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
|
||||||
|
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
|
||||||
|
|
||||||
|
<section :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>{{ commentsTotal }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="canCommentOnPost" 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="commentsLoading && !comments.length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
|
||||||
|
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
|
||||||
|
<div class="life-comment__main">
|
||||||
|
<Skeleton variant="box" width="36px" height="36px" />
|
||||||
|
<div class="life-comment__content">
|
||||||
|
<Skeleton width="132px" />
|
||||||
|
<Skeleton width="86%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="commentsError" class="life-form__error" role="alert">{{ commentsError }}</p>
|
||||||
|
|
||||||
|
<div v-else-if="comments.length" class="life-comment-list">
|
||||||
|
<article
|
||||||
|
v-for="comment in 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">
|
||||||
|
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
|
{{ comment.author.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ 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="canCommentOnPost"
|
||||||
|
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(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="canCommentOnPost && 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">
|
||||||
|
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
|
{{ reply.author.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ 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(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>
|
||||||
|
|
||||||
|
<div v-if="commentsHasMore && !commentsLoading" class="life-feed__retry">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small"
|
||||||
|
type="button"
|
||||||
|
:disabled="commentsLoadingMore"
|
||||||
|
@click="loadComments(false)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ commentsLoadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div v-else-if="!loadError" class="life-empty">
|
||||||
|
<Icon :icon="iconLife" class="life-empty__icon" aria-hidden="true" />
|
||||||
|
<div class="life-empty__copy">
|
||||||
|
<h2>{{ t('pages.life.empty') }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
iconComment,
|
iconComment,
|
||||||
iconDelete,
|
iconDelete,
|
||||||
iconEdit,
|
iconEdit,
|
||||||
|
iconExternal,
|
||||||
iconLife,
|
iconLife,
|
||||||
iconReactionFun,
|
iconReactionFun,
|
||||||
iconReactionHelpful,
|
iconReactionHelpful,
|
||||||
@@ -1222,7 +1223,11 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canManage(post) || canDeletePost(post)" class="life-post__actions">
|
<div class="life-post__actions">
|
||||||
|
<RouterLink class="life-icon-button" :to="`/life/${post.id}`" :aria-label="t('pages.life.viewPost')">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.viewPost') }}</span>
|
||||||
|
</RouterLink>
|
||||||
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
|
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|||||||
import {
|
import {
|
||||||
iconComment,
|
iconComment,
|
||||||
iconCopy,
|
iconCopy,
|
||||||
|
iconExternal,
|
||||||
iconKey,
|
iconKey,
|
||||||
iconLife,
|
iconLife,
|
||||||
iconProfile,
|
iconProfile,
|
||||||
@@ -609,7 +610,7 @@ function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commentTargetRoute(comment: UserCommentActivity): string {
|
function commentTargetRoute(comment: UserCommentActivity): string {
|
||||||
return comment.target.type === 'life-post' ? '/life' : discussionTargetRoute(comment.target.type, comment.target.id);
|
return comment.target.type === 'life-post' ? `/life/${comment.target.id}` : discussionTargetRoute(comment.target.type, comment.target.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentTargetTitle(comment: UserCommentActivity): string {
|
function commentTargetTitle(comment: UserCommentActivity): string {
|
||||||
@@ -740,6 +741,10 @@ onMounted(() => {
|
|||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('pages.life.commentsCount', { count: commentTotal(post) }) }}
|
{{ t('pages.life.commentsCount', { count: commentTotal(post) }) }}
|
||||||
</span>
|
</span>
|
||||||
|
<RouterLink class="profile-feed-card__detail-link" :to="`/life/${post.id}`">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.life.viewPost') }}
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -871,6 +876,10 @@ onMounted(() => {
|
|||||||
<span>{{ formatDateTime(activity.post.createdAt) }}</span>
|
<span>{{ formatDateTime(activity.post.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ postExcerpt(activity.post) }}</p>
|
<p>{{ postExcerpt(activity.post) }}</p>
|
||||||
|
<RouterLink class="profile-post-preview__detail" :to="`/life/${activity.post.id}`">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.life.viewPost') }}
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -793,6 +793,11 @@ export const systemWordingMessages = {
|
|||||||
title: 'Life',
|
title: 'Life',
|
||||||
subtitle: 'Share favourite thoughts, tips, and community finds.',
|
subtitle: 'Share favourite thoughts, tips, and community finds.',
|
||||||
kicker: 'Community Feed',
|
kicker: 'Community Feed',
|
||||||
|
detailTitle: 'Life Post',
|
||||||
|
detailSubtitle: 'Read this community post and its discussion.',
|
||||||
|
detailKicker: 'Life Detail',
|
||||||
|
backToLife: 'Back to Life',
|
||||||
|
viewPost: 'View post',
|
||||||
composerTitle: 'Share something',
|
composerTitle: 'Share something',
|
||||||
composerPrompt: 'What would you like to share?',
|
composerPrompt: 'What would you like to share?',
|
||||||
bodyLabel: 'Post',
|
bodyLabel: 'Post',
|
||||||
@@ -2019,6 +2024,11 @@ export const systemWordingMessages = {
|
|||||||
title: 'Life',
|
title: 'Life',
|
||||||
subtitle: '分享喜欢的心得、想法和社区发现。',
|
subtitle: '分享喜欢的心得、想法和社区发现。',
|
||||||
kicker: '社区动态',
|
kicker: '社区动态',
|
||||||
|
detailTitle: 'Life 动态',
|
||||||
|
detailSubtitle: '查看这条社区动态和相关讨论。',
|
||||||
|
detailKicker: 'Life 详情',
|
||||||
|
backToLife: '返回 Life',
|
||||||
|
viewPost: '查看动态',
|
||||||
composerTitle: '分享动态',
|
composerTitle: '分享动态',
|
||||||
composerPrompt: '想分享什么?',
|
composerPrompt: '想分享什么?',
|
||||||
bodyLabel: '动态内容',
|
bodyLabel: '动态内容',
|
||||||
|
|||||||
Reference in New Issue
Block a user