feat(comments): add sorting and liking functionality

Support sorting by oldest, latest, most-liked, and most-replied.
Implement like/unlike actions for Life and Entity Discussion comments.
This commit is contained in:
2026-05-04 17:29:09 +08:00
parent 504849c14a
commit 2ff2519647
10 changed files with 993 additions and 65 deletions

View File

@@ -377,8 +377,9 @@
- 讨论回复只支持一层回复,不做无限嵌套。 - 讨论回复只支持一层回复,不做无限嵌套。
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。 - 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
- 被删除实体的讨论会随实体删除一并清理。 - 被删除实体的讨论会随实体删除一并清理。
- 讨论按创建时间正序展示。
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items``nextCursor``hasMore``total` - 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items``nextCursor``hasMore``total`
- 讨论列表支持 `sort``oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
- 已注册并完成邮箱验证且拥有 `discussions.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的实体讨论评论;每个用户对每条评论最多 1 个 Like。
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。 - 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。 - 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
- 审核状态包括:`unreviewed``reviewing``approved``rejected``failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。 - 审核状态包括:`unreviewed``reviewing``approved``rejected``failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
@@ -390,6 +391,7 @@
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。 - 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
- API 对外只返回评论作者的 `id``displayName` - API 对外只返回评论作者的 `id``displayName`
- API 对外返回讨论评论的 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
- API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at``deleted_by_user_id` 等内部字段。 - API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at``deleted_by_user_id` 等内部字段。
## AI 审核 ## AI 审核
@@ -828,7 +830,9 @@ Life Post 可配置:
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。 - 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。 - Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口读取,每页顶层评论携带其一层回复。
- Life Comment 列表支持 `sort``oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
- 已注册并完成邮箱验证且拥有 `life.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的 Life Comment每个用户对每条评论最多 1 个 Like。
- 已注册并完成邮箱验证且拥有 `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。
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。 - 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
@@ -850,7 +854,7 @@ Life Post 可配置:
- 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。 - 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。 - Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。 - 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed``rejected``failed` 这类非进行中且未通过状态可触发重新审核。 - `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed``rejected``failed` 这类非进行中且未通过状态可触发重新审核API 也必须拒绝对 `reviewing``approved` 评论重新审核
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界: API 暴露边界:
@@ -861,11 +865,12 @@ API 暴露边界:
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。 - Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName` - Life Comment 作者信息只返回 `id``displayName`
- Life Comment 只返回 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
- Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction不内嵌其他用户明细。 - Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction不内嵌其他用户明细。
- Life Reaction 用户列表 API 只返回公开用户摘要 `id``displayName``reactionType``reactedAt`不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。 - Life Reaction 用户列表 API 只返回公开用户摘要 `id``displayName``reactionType``reactedAt`不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。
- 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 Post 详情 API 返回单条 Life Post字段边界与列表项一致评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。
- Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论。 - Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论;支持 `sort``oldest``latest``most-liked``most-replied`
- Life Comment 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情。 - Life Comment 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情。
- 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` 等内部软删除字段。
@@ -1013,14 +1018,14 @@ API 暴露边界:
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。 - `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。 - `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。 - `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied`
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。 - `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。 - `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。 - `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。 - `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
- `PUT /api/users/:id/follow`:需要 `users.follow`Follow 指定用户并返回更新后的公开 Profile。 - `PUT /api/users/:id/follow`:需要 `users.follow`Follow 指定用户并返回更新后的公开 Profile。
- `DELETE /api/users/:id/follow`:需要 `users.follow`Unfollow 指定用户并返回更新后的公开 Profile。 - `DELETE /api/users/:id/follow`:需要 `users.follow`Unfollow 指定用户并返回更新后的公开 Profile。
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon``items``recipes``habitats``ancient-artifacts` - `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied``entityType` 支持 `pokemon``items``recipes``habitats``ancient-artifacts`
认证 API 认证 API
@@ -1075,11 +1080,17 @@ API 暴露边界:
- `DELETE /api/life-comments/:id` - `DELETE /api/life-comments/:id`
- `POST /api/life-comments/:id/restore` - `POST /api/life-comments/:id/restore`
- `POST /api/life-comments/:id/moderation/retry` - `POST /api/life-comments/:id/moderation/retry`
- Life Comment 的点赞和取消点赞需要 `life.comments.like` 权限。
- `PUT /api/life-comments/:id/like`
- `DELETE /api/life-comments/:id/like`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。 - 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/discussions/:entityType/:entityId/comments` - `POST /api/discussions/:entityType/:entityId/comments`
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies` - `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
- `DELETE /api/discussions/comments/:id` - `DELETE /api/discussions/comments/:id`
- `POST /api/discussions/comments/:id/moderation/retry` - `POST /api/discussions/comments/:id/moderation/retry`
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
- `PUT /api/discussions/comments/:id/like`
- `DELETE /api/discussions/comments/:id/like`
- Life Reaction 的设置、替换和取消。 - Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction` - `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction`

View File

@@ -299,12 +299,14 @@ VALUES
('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true), ('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true),
('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true), ('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true), ('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true), ('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true), ('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true), ('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true), ('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true) ('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true),
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO roles (key, name, description, level, enabled, system_role) INSERT INTO roles (key, name, description, level, enabled, system_role)
@@ -391,12 +393,14 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.delete-any', 'life.comments.delete-any',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete', 'discussions.comments.delete',
'discussions.comments.delete-any' 'discussions.comments.delete-any',
'discussions.comments.like'
]) ])
WHERE r.key = 'admin' WHERE r.key = 'admin'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -460,11 +464,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.posts.delete', 'life.posts.delete',
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete' 'discussions.comments.delete',
'discussions.comments.like'
]) ])
WHERE r.key = 'editor' WHERE r.key = 'editor'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -508,11 +514,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.posts.delete', 'life.posts.delete',
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete' 'discussions.comments.delete',
'discussions.comments.like'
]) ])
WHERE r.key = 'member' WHERE r.key = 'member'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -529,6 +537,20 @@ JOIN permissions p ON p.key = 'life.ratings.set'
WHERE r.key IN ('admin', 'editor', 'member') WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'discussions.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r
@@ -744,6 +766,19 @@ CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
CREATE INDEX IF NOT EXISTS life_post_comments_user_idx CREATE INDEX IF NOT EXISTS life_post_comments_user_idx
ON life_post_comments(created_by_user_id, created_at DESC, id DESC); ON life_post_comments(created_by_user_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS life_comment_likes (
comment_id integer NOT NULL REFERENCES life_post_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_comment_likes_comment_idx
ON life_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS life_comment_likes_user_idx
ON life_comment_likes(user_id, created_at DESC, comment_id DESC);
CREATE TABLE IF NOT EXISTS life_post_reactions ( CREATE TABLE IF NOT EXISTS life_post_reactions (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1235,6 +1270,19 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id); ON entity_discussion_comments(created_by_user_id);
CREATE TABLE IF NOT EXISTS entity_discussion_comment_likes (
comment_id integer NOT NULL REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
ON entity_discussion_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
ALTER TABLE entity_discussion_comments ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check; DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;

View File

@@ -271,6 +271,9 @@ type EntityDiscussionCommentRow = {
createdAtCursor?: string; createdAtCursor?: string;
updatedAt: Date; updatedAt: Date;
author: { id: number; displayName: string } | null; author: { id: number; displayName: string } | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
}; };
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & { type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
replies: EntityDiscussionComment[]; replies: EntityDiscussionComment[];
@@ -313,6 +316,9 @@ type LifeCommentRow = {
createdAtCursor?: string; createdAtCursor?: string;
updatedAt: Date; updatedAt: Date;
author: { id: number; displayName: string } | null; author: { id: number; displayName: string } | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
}; };
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & { type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
@@ -351,6 +357,12 @@ type LifePostCursor = {
}; };
type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
type CommentCursor = {
createdAt: string;
id: number;
count?: number;
};
type LifePostFilters = { type LifePostFilters = {
authorId?: number; authorId?: number;
@@ -2866,6 +2878,20 @@ function addModerationVisibilityCondition(
conditions.push(`${alias}.ai_moderation_status = 'approved'`); conditions.push(`${alias}.ai_moderation_status = 'approved'`);
} }
function moderationVisibilitySql(alias: string, ownerColumn: string, userId: number | null, canViewAll: boolean): string {
if (canViewAll) {
return 'true';
}
if (userId !== null) {
return `(${alias}.ai_moderation_status = 'approved' OR ${ownerColumn} = ${userId})`;
}
return `${alias}.ai_moderation_status = 'approved'`;
}
function visibleLifeCommentSql(alias: string, ownerColumn: string, userId: number | null): string {
return userId !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = ${userId})` : `${alias}.deleted_at IS NULL`;
}
function addModerationLanguageCondition( function addModerationLanguageCondition(
conditions: string[], conditions: string[],
params: unknown[], params: unknown[],
@@ -2966,6 +2992,92 @@ function cleanCommentLimit(value: QueryValue): number {
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit; return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxCommentLimit) : defaultCommentLimit;
} }
function cleanCommentSort(value: QueryValue): CommentSort {
const sort = asString(value);
return sort === 'latest' || sort === 'most-liked' || sort === 'most-replied' ? sort : 'oldest';
}
function decodeCommentCursor(value: QueryValue): CommentCursor | null {
const rawCursor = asString(value);
if (!rawCursor) {
return null;
}
try {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<CommentCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
const count = cursor.count === undefined ? undefined : Number(cursor.count);
if (
!createdAt ||
Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) ||
id <= 0 ||
(count !== undefined && (!Number.isInteger(count) || count < 0))
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id, count };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
}
throw validationError('server.validation.cursorInvalid');
}
}
function encodeCommentCursor(comment: LifeCommentRow | EntityDiscussionCommentRow, sort: CommentSort): string {
const count = sort === 'most-liked' ? comment.likeCount : sort === 'most-replied' ? comment.replyCount : undefined;
return Buffer.from(JSON.stringify({ createdAt: comment.createdAtCursor, id: comment.id, count }), 'utf8').toString('base64url');
}
function commentSortOrder(alias: string, sort: CommentSort): string {
if (sort === 'latest') {
return `${alias}.created_at DESC, ${alias}.id DESC`;
}
if (sort === 'most-liked') {
return `"likeCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
}
if (sort === 'most-replied') {
return `"replyCount" DESC, ${alias}.created_at DESC, ${alias}.id DESC`;
}
return `${alias}.created_at, ${alias}.id`;
}
function addCommentCursorCondition(
conditions: string[],
params: unknown[],
alias: string,
cursor: CommentCursor,
sort: CommentSort
): void {
if (sort === 'latest') {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
return;
}
if (sort === 'most-liked' || sort === 'most-replied') {
params.push(cursor.count ?? 0, cursor.createdAt, cursor.id);
const countExpression = sort === 'most-liked' ? 'like_stats.like_count' : 'reply_stats.reply_count';
conditions.push(`
(
${countExpression} < $${params.length - 2}::integer
OR (
${countExpression} = $${params.length - 2}::integer
AND (${alias}.created_at, ${alias}.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)
)
)
`);
return;
}
params.push(cursor.createdAt, cursor.id);
conditions.push(`(${alias}.created_at, ${alias}.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
}
function decodeLifePostCursor(value: QueryValue): LifePostCursor | null { function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const rawCursor = asString(value); const rawCursor = asString(value);
if (!rawCursor) { if (!rawCursor) {
@@ -3101,7 +3213,17 @@ function hydrateLifePost(
}; };
} }
function lifeCommentProjection(whereClause: string): string { function lifeCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
const myLikedExpression =
userId === null
? 'false'
: `EXISTS (SELECT 1 FROM life_comment_likes my_like WHERE my_like.comment_id = lc.id AND my_like.user_id = ${userId})`;
const replyVisibility = [
'reply.parent_comment_id = lc.id',
visibleLifeCommentSql('reply', 'reply.created_by_user_id', userId),
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
].join(' AND ');
return ` return `
SELECT SELECT
lc.id, lc.id,
@@ -3115,8 +3237,21 @@ function lifeCommentProjection(whereClause: string): string {
lc.created_at AS "createdAt", lc.created_at AS "createdAt",
lc.created_at::text AS "createdAtCursor", lc.created_at::text AS "createdAtCursor",
lc.updated_at AS "updatedAt", lc.updated_at AS "updatedAt",
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author,
like_stats.like_count AS "likeCount",
reply_stats.reply_count AS "replyCount",
${myLikedExpression} AS "myLiked"
FROM life_post_comments lc FROM life_post_comments lc
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS like_count
FROM life_comment_likes lcl
WHERE lcl.comment_id = lc.id
) like_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS reply_count
FROM life_post_comments reply
WHERE ${replyVisibility}
) reply_stats ON true
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
${whereClause} ${whereClause}
`; `;
@@ -3236,7 +3371,7 @@ async function lifeCommentPreviewForPosts(
) ranked ) ranked
WHERE preview_rank <= $${params.length} WHERE preview_rank <= $${params.length}
) )
${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)')} ${lifeCommentProjection('WHERE lc.id IN (SELECT id FROM preview_top)', userId, canViewAll)}
ORDER BY lc.post_id, lc.created_at, lc.id ORDER BY lc.post_id, lc.created_at, lc.id
`, `,
params params
@@ -3256,9 +3391,10 @@ export async function listLifeComments(
canViewAll = false canViewAll = false
): Promise<LifeCommentsPage | null> { ): Promise<LifeCommentsPage | null> {
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid'); const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
const cursor = decodeLifePostCursor(paramsQuery.cursor); const cursor = decodeCommentCursor(paramsQuery.cursor);
const limit = cleanCommentLimit(paramsQuery.limit); const limit = cleanCommentLimit(paramsQuery.limit);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language); const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const sort = cleanCommentSort(paramsQuery.sort);
const postParams: unknown[] = [postId]; const postParams: unknown[] = [postId];
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL']; const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll); addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
@@ -3284,15 +3420,14 @@ export async function listLifeComments(
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode); addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
if (cursor) { if (cursor) {
params.push(cursor.createdAt, cursor.id); addCommentCursorCondition(topLevelConditions, params, 'lc', cursor, sort);
topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
} }
params.push(limit + 1); params.push(limit + 1);
const topLevelRows = await query<LifeCommentRow>( const topLevelRows = await query<LifeCommentRow>(
` `
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)} ${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY lc.created_at, lc.id ORDER BY ${commentSortOrder('lc', sort)}
LIMIT $${params.length} LIMIT $${params.length}
`, `,
params params
@@ -3309,7 +3444,7 @@ export async function listLifeComments(
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode); addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
return query<LifeCommentRow>( return query<LifeCommentRow>(
` `
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)} ${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY lc.created_at, lc.id ORDER BY lc.created_at, lc.id
`, `,
replyParams replyParams
@@ -3334,12 +3469,7 @@ export async function listLifeComments(
items: buildLifeCommentTree([...topLevelComments, ...replyRows]), items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
nextCursor: nextCursor:
hasMore && topLevelComments.length > 0 hasMore && topLevelComments.length > 0
? encodeProfileCursor({ ? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
: null, : null,
hasMore, hasMore,
total: total?.total ?? 0 total: total?.total ?? 0
@@ -3526,10 +3656,10 @@ async function lifeRatingsForPosts(postIds: number[], userId: number | null): Pr
return myRatingsByPost; return myRatingsByPost;
} }
async function getLifeCommentById(id: number): Promise<LifeComment | null> { async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>( const row = await queryOne<LifeCommentRow>(
` `
${lifeCommentProjection('WHERE lc.id = $1')} ${lifeCommentProjection('WHERE lc.id = $1', userId, canViewAll)}
`, `,
[id] [id]
); );
@@ -4347,6 +4477,7 @@ export async function retryLifePostModeration(id: number, userId: number, locale
WHERE id = $1 WHERE id = $1
AND ($3 = true OR created_by_user_id = $2) AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`, `,
[postId, userId, allowAny] [postId, userId, allowAny]
); );
@@ -4490,7 +4621,7 @@ export async function createLifeComment(postId: number, payload: Record<string,
); );
} }
return result ? getLifeCommentById(result.id) : null; return result ? getLifeCommentById(result.id, userId, false) : null;
} }
export async function createLifeCommentReply( export async function createLifeCommentReply(
@@ -4533,7 +4664,7 @@ export async function createLifeCommentReply(
); );
} }
return result ? getLifeCommentById(result.id) : null; return result ? getLifeCommentById(result.id, userId, false) : null;
} }
export async function deleteLifeComment(id: number, userId: number, allowAny = false) { export async function deleteLifeComment(id: number, userId: number, allowAny = false) {
@@ -4572,7 +4703,7 @@ export async function restoreLifeComment(id: number, userId: number) {
[commentId, userId] [commentId, userId]
); );
return result ? getLifeCommentById(result.id) : null; return result ? getLifeCommentById(result.id, userId, false) : null;
} }
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) { export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
@@ -4584,6 +4715,7 @@ export async function retryLifeCommentModeration(id: number, userId: number, all
WHERE id = $1 WHERE id = $1
AND ($3 = true OR created_by_user_id = $2) AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`, `,
[commentId, userId, allowAny] [commentId, userId, allowAny]
); );
@@ -4593,7 +4725,63 @@ export async function retryLifeCommentModeration(id: number, userId: number, all
} }
await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true }); await requestAiModerationReview({ type: 'life-comment', id: commentId }, { incrementRetries: true });
return getLifeCommentById(commentId); return getLifeCommentById(commentId, userId, allowAny);
}
async function approvedLifeCommentExists(commentId: number): Promise<boolean> {
const row = await queryOne<{ id: number }>(
`
SELECT lc.id
FROM life_post_comments lc
JOIN life_posts lp ON lp.id = lc.post_id
WHERE lc.id = $1
AND lc.deleted_at IS NULL
AND lc.ai_moderation_status = 'approved'
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
`,
[commentId]
);
return Boolean(row);
}
export async function setLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedLifeCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
INSERT INTO life_comment_likes (comment_id, user_id)
VALUES ($1, $2)
ON CONFLICT (comment_id, user_id) DO NOTHING
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getLifeCommentById(commentId, userId);
}
export async function deleteLifeCommentLike(id: number, userId: number): Promise<LifeComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedLifeCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
DELETE FROM life_comment_likes
WHERE comment_id = $1
AND user_id = $2
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getLifeCommentById(commentId, userId);
} }
function cleanDiscussionEntityType(value: unknown): DiscussionEntityType { function cleanDiscussionEntityType(value: unknown): DiscussionEntityType {
@@ -4627,7 +4815,21 @@ async function entityDiscussionExists(
return result.rows[0]?.exists === true; return result.rows[0]?.exists === true;
} }
function entityDiscussionCommentProjection(whereClause: string): string { function entityDiscussionCommentProjection(whereClause: string, userId: number | null = null, canViewAll = false): string {
const myLikedExpression =
userId === null
? 'false'
: `EXISTS (
SELECT 1
FROM entity_discussion_comment_likes my_like
WHERE my_like.comment_id = edc.id AND my_like.user_id = ${userId}
)`;
const replyVisibility = [
'reply.parent_comment_id = edc.id',
'reply.deleted_at IS NULL',
moderationVisibilitySql('reply', 'reply.created_by_user_id', userId, canViewAll)
].join(' AND ');
return ` return `
SELECT SELECT
edc.id, edc.id,
@@ -4645,8 +4847,21 @@ function entityDiscussionCommentProjection(whereClause: string): string {
CASE CASE
WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL WHEN edc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
END AS author END AS author,
like_stats.like_count AS "likeCount",
reply_stats.reply_count AS "replyCount",
${myLikedExpression} AS "myLiked"
FROM entity_discussion_comments edc FROM entity_discussion_comments edc
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS like_count
FROM entity_discussion_comment_likes edcl
WHERE edcl.comment_id = edc.id
) like_stats ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS reply_count
FROM entity_discussion_comments reply
WHERE ${replyVisibility}
) reply_stats ON true
LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id LEFT JOIN users comment_user ON comment_user.id = edc.created_by_user_id
${whereClause} ${whereClause}
`; `;
@@ -4678,10 +4893,14 @@ function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): E
return topLevelComments; return topLevelComments;
} }
async function getEntityDiscussionCommentById(id: number): Promise<EntityDiscussionComment | null> { async function getEntityDiscussionCommentById(
id: number,
userId: number | null = null,
canViewAll = false
): Promise<EntityDiscussionComment | null> {
const row = await queryOne<EntityDiscussionCommentRow>( const row = await queryOne<EntityDiscussionCommentRow>(
` `
${entityDiscussionCommentProjection('WHERE edc.id = $1')} ${entityDiscussionCommentProjection('WHERE edc.id = $1', userId, canViewAll)}
`, `,
[id] [id]
); );
@@ -4703,9 +4922,10 @@ export async function listEntityDiscussionComments(
): Promise<EntityDiscussionCommentsPage | null> { ): Promise<EntityDiscussionCommentsPage | null> {
const entityType = cleanDiscussionEntityType(entityTypeValue); const entityType = cleanDiscussionEntityType(entityTypeValue);
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid'); const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
const cursor = decodeLifePostCursor(paramsQuery.cursor); const cursor = decodeCommentCursor(paramsQuery.cursor);
const limit = cleanCommentLimit(paramsQuery.limit); const limit = cleanCommentLimit(paramsQuery.limit);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language); const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const sort = cleanCommentSort(paramsQuery.sort);
if (!(await entityDiscussionExists(pool, entityType, entityId))) { if (!(await entityDiscussionExists(pool, entityType, entityId))) {
return null; return null;
@@ -4717,15 +4937,14 @@ export async function listEntityDiscussionComments(
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode); addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
if (cursor) { if (cursor) {
params.push(cursor.createdAt, cursor.id); addCommentCursorCondition(topLevelConditions, params, 'edc', cursor, sort);
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
} }
params.push(limit + 1); params.push(limit + 1);
const topLevelRows = await query<EntityDiscussionCommentRow>( const topLevelRows = await query<EntityDiscussionCommentRow>(
` `
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)} ${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY edc.created_at, edc.id ORDER BY ${commentSortOrder('edc', sort)}
LIMIT $${params.length} LIMIT $${params.length}
`, `,
params params
@@ -4741,7 +4960,7 @@ export async function listEntityDiscussionComments(
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode); addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
return query<EntityDiscussionCommentRow>( return query<EntityDiscussionCommentRow>(
` `
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)} ${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
ORDER BY edc.created_at, edc.id ORDER BY edc.created_at, edc.id
`, `,
replyParams replyParams
@@ -4765,12 +4984,7 @@ export async function listEntityDiscussionComments(
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]), items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
nextCursor: nextCursor:
hasMore && topLevelComments.length > 0 hasMore && topLevelComments.length > 0
? encodeProfileCursor({ ? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
createdAt:
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
id: topLevelComments[topLevelComments.length - 1].id
})
: null, : null,
hasMore, hasMore,
total: total?.total ?? 0 total: total?.total ?? 0
@@ -4818,7 +5032,7 @@ export async function createEntityDiscussionComment(
); );
} }
return id ? getEntityDiscussionCommentById(id) : null; return id ? getEntityDiscussionCommentById(id, userId, false) : null;
} }
export async function createEntityDiscussionReply( export async function createEntityDiscussionReply(
@@ -4872,7 +5086,7 @@ export async function createEntityDiscussionReply(
); );
} }
return id ? getEntityDiscussionCommentById(id) : null; return id ? getEntityDiscussionCommentById(id, userId, false) : null;
} }
export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> { export async function deleteEntityDiscussionComment(id: number, userId: number, allowAny = false): Promise<boolean> {
@@ -4907,6 +5121,7 @@ export async function retryEntityDiscussionCommentModeration(
WHERE id = $1 WHERE id = $1
AND ($3 = true OR created_by_user_id = $2) AND ($3 = true OR created_by_user_id = $2)
AND deleted_at IS NULL AND deleted_at IS NULL
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
`, `,
[commentId, userId, allowAny] [commentId, userId, allowAny]
); );
@@ -4916,7 +5131,60 @@ export async function retryEntityDiscussionCommentModeration(
} }
await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true }); await requestAiModerationReview({ type: 'discussion-comment', id: commentId }, { incrementRetries: true });
return getEntityDiscussionCommentById(commentId); return getEntityDiscussionCommentById(commentId, userId, allowAny);
}
async function approvedEntityDiscussionCommentExists(commentId: number): Promise<boolean> {
const row = await queryOne<{ id: number }>(
`
SELECT id
FROM entity_discussion_comments
WHERE id = $1
AND deleted_at IS NULL
AND ai_moderation_status = 'approved'
`,
[commentId]
);
return Boolean(row);
}
export async function setEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
INSERT INTO entity_discussion_comment_likes (comment_id, user_id)
VALUES ($1, $2)
ON CONFLICT (comment_id, user_id) DO NOTHING
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getEntityDiscussionCommentById(commentId, userId);
}
export async function deleteEntityDiscussionCommentLike(id: number, userId: number): Promise<EntityDiscussionComment | null> {
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
if (!(await approvedEntityDiscussionCommentExists(commentId))) {
return null;
}
await queryOne<{ commentId: number }>(
`
DELETE FROM entity_discussion_comment_likes
WHERE comment_id = $1
AND user_id = $2
RETURNING comment_id AS "commentId"
`,
[commentId, userId]
);
return getEntityDiscussionCommentById(commentId, userId);
} }
async function deleteEntityDiscussionCommentsForEntity( async function deleteEntityDiscussionCommentsForEntity(
@@ -6838,7 +7106,8 @@ const dataToolColumns = {
'deleted_at', 'deleted_at',
'created_at', 'created_at',
'updated_at' 'updated_at'
] ],
discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
} as const; } as const;
function isDataToolScope(value: unknown): value is DataToolScope { function isDataToolScope(value: unknown): value is DataToolScope {
@@ -7057,6 +7326,17 @@ async function exportGenericScopeData(client: DbClient, entityType: string, incl
ORDER BY parent_comment_id NULLS FIRST, id ORDER BY parent_comment_id NULLS FIRST, id
`, `,
[entityType] [entityType]
),
discussionCommentLikes: await tableRows(
client,
`
SELECT edcl.*
FROM entity_discussion_comment_likes edcl
JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id
WHERE edc.entity_type = $1
ORDER BY edcl.comment_id, edcl.user_id
`,
[entityType]
) )
}; };
@@ -7182,6 +7462,12 @@ async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle)
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs')); await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads')); await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments')); await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
await insertRows(
client,
'entity_discussion_comment_likes',
dataToolColumns.discussionCommentLikes,
dataToolTableRows(data, 'discussionCommentLikes')
);
} }
} }

View File

@@ -55,7 +55,9 @@ import {
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
deleteEntityDiscussionCommentLike,
deleteLifeComment, deleteLifeComment,
deleteLifeCommentLike,
deleteLifePost, deleteLifePost,
deleteLifePostRating, deleteLifePostRating,
deleteLifePostReaction, deleteLifePostReaction,
@@ -108,6 +110,8 @@ import {
restoreLifeComment, restoreLifeComment,
setLifePostRating, setLifePostRating,
setLifePostReaction, setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
updateConfig, updateConfig,
updateAncientArtifact, updateAncientArtifact,
updateDailyChecklistItem, updateDailyChecklistItem,
@@ -1470,6 +1474,26 @@ app.post('/api/life-comments/:id/restore', async (request, reply) => {
return comment ? comment : notFound(reply, request); return comment ? comment : notFound(reply, request);
}); });
app.put('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => { app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits( const user = await requireAnyPermissionWithRateLimits(
request, request,
@@ -1580,6 +1604,28 @@ app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply
return comment ? comment : notFound(reply, request); return comment ? comment : notFound(reply, request);
}); });
app.put('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.get('/api/pokemon', async (request) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );

View File

@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue'; import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons'; import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import { import {
api, api,
getAuthToken, getAuthToken,
@@ -13,6 +13,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type DiscussionEntityType, type DiscussionEntityType,
type EntityDiscussionComment, type EntityDiscussionComment,
type Language, type Language,
@@ -41,7 +42,9 @@ const formError = ref('');
const commentErrors = ref<Record<string, string>>({}); const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null); const commentInput = ref<HTMLTextAreaElement | null>(null);
const activeLanguageCode = ref('all'); const activeLanguageCode = ref('all');
const activeSort = ref<CommentSort>('oldest');
const moderationBusyId = ref<number | null>(null); const moderationBusyId = ref<number | null>(null);
const likeBusyId = ref<number | null>(null);
const commentMaxLength = 1000; const commentMaxLength = 1000;
const discussionPageSize = 20; const discussionPageSize = 20;
const allLanguageValue = 'all'; const allLanguageValue = 'all';
@@ -56,12 +59,19 @@ function can(permissionKey: string) {
} }
const canComment = computed(() => can('discussions.comments.create')); const canComment = computed(() => can('discussions.comments.create'));
const canLikeComments = computed(() => can('discussions.comments.like'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length)); const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value)); const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [ const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') }, { value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name })) ...languages.value.map((language) => ({ value: language.code, label: language.name }))
]); ]);
const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('discussion.sortOldest') },
{ value: 'latest', label: t('discussion.sortLatest') },
{ value: 'most-liked', label: t('discussion.sortMostLiked') },
{ value: 'most-replied', label: t('discussion.sortMostReplied') }
]);
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
@@ -119,7 +129,8 @@ async function loadDiscussion(reset = true) {
const page = await api.entityDiscussion(props.entityType, props.entityId, { const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize, limit: discussionPageSize,
cursor: reset ? null : nextCursor.value, cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value language: selectedLanguageCode.value,
sort: activeSort.value
}); });
if (nextRequestId === requestId) { if (nextRequestId === requestId) {
comments.value = reset ? page.items : mergeComments(comments.value, page.items); comments.value = reset ? page.items : mergeComments(comments.value, page.items);
@@ -151,6 +162,17 @@ function commentKey(commentId: number) {
return `comment-${commentId}`; return `comment-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeSort.value = event.target.value as CommentSort;
void loadDiscussion();
}
}
function replyBody(commentId: number) { function replyBody(commentId: number) {
return replyBodies.value[commentId] ?? ''; return replyBodies.value[commentId] ?? '';
} }
@@ -181,6 +203,14 @@ function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment); return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
} }
function canLikeComment(comment: EntityDiscussionComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function commentLikeLabel(comment: EntityDiscussionComment) {
return comment.myLiked ? t('discussion.unlikeComment') : t('discussion.likeComment');
}
function moderationReasonVisible(comment: EntityDiscussionComment) { function moderationReasonVisible(comment: EntityDiscussionComment) {
return ( return (
!comment.deleted && !comment.deleted &&
@@ -267,6 +297,9 @@ async function submitComment() {
comments.value = [...comments.value, comment]; comments.value = [...comments.value, comment];
commentTotal.value += 1; commentTotal.value += 1;
body.value = ''; body.value = '';
if (activeSort.value !== 'oldest') {
void loadDiscussion();
}
} catch (error) { } catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed'); formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally { } finally {
@@ -291,8 +324,12 @@ async function submitReply(comment: EntityDiscussionComment) {
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
}); });
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
commentTotal.value += 1; commentTotal.value += 1;
cancelReply(comment.id); cancelReply(comment.id);
if (activeSort.value === 'most-replied') {
void loadDiscussion();
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
} finally { } finally {
@@ -317,6 +354,49 @@ async function retryModeration(comment: EntityDiscussionComment) {
} }
} }
function replaceCommentInTree(items: EntityDiscussionComment[], updated: EntityDiscussionComment): boolean {
for (let index = 0; index < items.length; index += 1) {
const comment = items[index];
if (!comment) {
continue;
}
if (comment.id === updated.id) {
items[index] = { ...updated, replies: comment.replies };
return true;
}
if (replaceCommentInTree(comment.replies, updated)) {
return true;
}
}
return false;
}
async function toggleCommentLike(comment: EntityDiscussionComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
likeBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = comment.myLiked
? await api.deleteEntityDiscussionCommentLike(comment.id)
: await api.setEntityDiscussionCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeSort.value === 'most-liked') {
void loadDiscussion();
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.commentLikeFailed'));
} finally {
likeBusyId.value = null;
}
}
function updateDiscussionCommentModeration( function updateDiscussionCommentModeration(
items: EntityDiscussionComment[], items: EntityDiscussionComment[],
commentId: number, commentId: number,
@@ -455,6 +535,14 @@ onUnmounted(() => {
</div> </div>
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" /> <Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
<label class="entity-discussion-sort">
<span>{{ t('discussion.sort') }}</span>
<select :value="activeSort" @change="handleSortChange">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true"> <div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" /> <Skeleton variant="box" height="112px" />
@@ -528,6 +616,18 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted" class="entity-discussion-comment__actions"> <div v-if="!comment.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || likeBusyId === comment.id"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="canComment" v-if="canComment"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -563,6 +663,9 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert"> <p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(comment.id)] }} {{ commentErrors[commentKey(comment.id)] }}
</p> </p>
@@ -624,7 +727,19 @@ onUnmounted(() => {
<strong>{{ t('discussion.moderationReason') }}</strong> <strong>{{ t('discussion.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span> <span>{{ reply.moderationReason }}</span>
</p> </p>
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions"> <div v-if="!reply.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || likeBusyId === reply.id"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canRetryModeration(reply)" v-if="canRetryModeration(reply)"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -639,6 +754,7 @@ onUnmounted(() => {
</span> </span>
</button> </button>
<button <button
v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@@ -648,6 +764,9 @@ onUnmounted(() => {
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button> </button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert"> <p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(reply.id)] }} {{ commentErrors[commentKey(reply.id)] }}
</p> </p>

View File

@@ -421,8 +421,11 @@ export interface CommentPageParams {
cursor?: string | null; cursor?: string | null;
limit?: number; limit?: number;
language?: string; language?: string;
sort?: CommentSort;
} }
export type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
export interface LifeComment { export interface LifeComment {
id: number; id: number;
postId: number; postId: number;
@@ -435,6 +438,9 @@ export interface LifeComment {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: LifeComment[]; replies: LifeComment[];
} }
@@ -831,6 +837,9 @@ export interface EntityDiscussionComment {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: EntityDiscussionComment[]; replies: EntityDiscussionComment[];
} }
@@ -1229,7 +1238,8 @@ export const api = {
`/api/life-posts/${postId}/comments${buildQuery({ `/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
language: params.language language: params.language,
sort: params.sort
})}` })}`
), ),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
@@ -1237,13 +1247,16 @@ export const api = {
retryLifeCommentModeration: (id: string | number) => retryLifeCommentModeration: (id: string | number) =>
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}), sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}), restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}),
setLifeCommentLike: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/like`, 'PUT', {}),
deleteLifeCommentLike: (id: string | number) => deleteAndGetJson<LifeComment>(`/api/life-comments/${id}/like`),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`), deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) => entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>( getJson<EntityDiscussionCommentsPage>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({ `/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
language: params.language language: params.language,
sort: params.sort
})}` })}`
), ),
createEntityDiscussionComment: ( createEntityDiscussionComment: (
@@ -1259,6 +1272,10 @@ export const api = {
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload), ) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
retryEntityDiscussionModeration: (id: string | number) => retryEntityDiscussionModeration: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}), sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
setEntityDiscussionCommentLike: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`, 'PUT', {}),
deleteEntityDiscussionCommentLike: (id: string | number) =>
deleteAndGetJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`), deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: ( uploadImage: (
entityType: ImageUploadEntityType, entityType: ImageUploadEntityType,

View File

@@ -3262,7 +3262,8 @@ button:disabled,
font-weight: 950; font-weight: 950;
} }
.life-comments__header span { .life-comments__header > span,
.life-comments__header > div > span {
min-width: 32px; min-width: 32px;
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -3274,6 +3275,25 @@ button:disabled,
text-align: center; text-align: center;
} }
.life-comments__sort {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.life-comments__sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.life-comment-form { .life-comment-form {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -3386,6 +3406,13 @@ button:disabled,
gap: 8px; gap: 8px;
} }
.life-comment__action-count {
min-width: 1ch;
font-size: 12px;
font-weight: 900;
line-height: 1;
}
.life-comments__empty { .life-comments__empty {
margin: 0; margin: 0;
} }
@@ -4529,6 +4556,26 @@ button:disabled,
font-weight: 800; font-weight: 800;
} }
.entity-discussion-sort {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-skeleton, .entity-discussion-skeleton,
.entity-discussion-form, .entity-discussion-form,
.entity-discussion-list { .entity-discussion-list {

View File

@@ -33,6 +33,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifeReactionType, type LifeReactionType,
@@ -53,6 +54,7 @@ const commentsLoading = ref(false);
const commentsLoadingMore = ref(false); const commentsLoadingMore = ref(false);
const commentsLoaded = ref(false); const commentsLoaded = ref(false);
const commentsError = ref(''); const commentsError = ref('');
const activeCommentSort = ref<CommentSort>('oldest');
const commentBodies = ref<Record<number, string>>({}); const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({}); const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null); const replyTargetId = ref<number | null>(null);
@@ -82,9 +84,16 @@ function can(permissionKey: string) {
} }
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set')); const canRate = computed(() => can('life.ratings.set'));
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved'); const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
function routePostId() { function routePostId() {
const value = route.params.id; const value = route.params.id;
@@ -185,7 +194,7 @@ async function loadComments(reset = false) {
} }
try { try {
const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor }); const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor, sort: activeCommentSort.value });
comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items); comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items);
commentsNextCursor.value = page.nextCursor; commentsNextCursor.value = page.nextCursor;
commentsHasMore.value = page.hasMore; commentsHasMore.value = page.hasMore;
@@ -208,6 +217,17 @@ function replyKey(commentId: number) {
return `reply-${commentId}`; return `reply-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleCommentSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeCommentSort.value = event.target.value as CommentSort;
void loadComments(true);
}
}
function isCommentBusy(key: string) { function isCommentBusy(key: string) {
return commentBusyKey.value === key; return commentBusyKey.value === key;
} }
@@ -232,6 +252,19 @@ function canRestoreComment(comment: LifeComment) {
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete'); return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
} }
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function canSeeCommentModeration(comment: LifeComment) { function canSeeCommentModeration(comment: LifeComment) {
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any')); return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
} }
@@ -271,6 +304,10 @@ function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) {
}); });
} }
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) { function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
reactionUsersModal.value = { postId, reactionType }; reactionUsersModal.value = { postId, reactionType };
} }
@@ -536,6 +573,9 @@ async function submitComment(currentPost: LifePost) {
currentPost.commentCount = commentsTotal.value; currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true; commentsLoaded.value = true;
commentBodies.value[currentPost.id] = ''; commentBodies.value[currentPost.id] = '';
if (activeCommentSort.value !== 'oldest') {
void loadComments(true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally { } finally {
@@ -571,10 +611,14 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
}); });
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
commentsTotal.value += 1; commentsTotal.value += 1;
currentPost.commentCount = commentsTotal.value; currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true; commentsLoaded.value = true;
cancelReply(comment.id); cancelReply(comment.id);
if (activeCommentSort.value === 'most-replied') {
void loadComments(true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally { } finally {
@@ -669,6 +713,45 @@ async function restoreComment(comment: LifeComment) {
} }
} }
async function retryCommentModeration(comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeCommentSort.value === 'most-liked') {
void loadComments(true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} finally {
commentBusyKey.value = '';
}
}
function commentAuthorName(comment: LifeComment) { function commentAuthorName(comment: LifeComment) {
return comment.author?.displayName ?? t('pages.life.byUnknown'); return comment.author?.displayName ?? t('pages.life.byUnknown');
} }
@@ -921,8 +1004,18 @@ onUnmounted(() => {
<section :id="`life-comments-${post.id}`" class="life-comments" :aria-label="t('pages.life.comments')"> <section :id="`life-comments-${post.id}`" class="life-comments" :aria-label="t('pages.life.comments')">
<div class="life-comments__header"> <div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3> <div>
<span>{{ commentsTotal }}</span> <h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentsTotal }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="activeCommentSort" @change="handleCommentSortChange">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div> </div>
<form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)"> <form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -995,6 +1088,19 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions"> <div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="!comment.deleted && canCommentOnPost" v-if="!comment.deleted && canCommentOnPost"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -1026,8 +1132,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert"> <p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }} {{ commentErrors[replyKey(comment.id)] }}
</p> </p>
@@ -1092,7 +1214,20 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong> <strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span> <span>{{ reply.moderationReason }}</span>
</p> </p>
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions"> <div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canManageComment(reply)" v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -1114,7 +1249,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert"> <p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }} {{ commentErrors[replyKey(reply.id)] }}
</p> </p>

View File

@@ -40,6 +40,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type GameVersion, type GameVersion,
type Language, type Language,
type LifeCategory, type LifeCategory,
@@ -93,6 +94,7 @@ const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null); const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({}); const expandedComments = ref<Record<number, boolean>>({});
const commentPages = ref<Record<number, LifeCommentPageState>>({}); const commentPages = ref<Record<number, LifeCommentPageState>>({});
const commentSorts = ref<Record<number, CommentSort>>({});
const commentBusyKey = ref(''); const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({}); const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
@@ -134,6 +136,7 @@ function can(permissionKey: string) {
const canPost = computed(() => can('life.posts.create')); const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set')); const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length)); const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
@@ -183,6 +186,12 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') } { value: 'top-rated', label: t('pages.life.sortTopRated') }
]); ]);
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [ const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') }, { value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') } { value: 'following', label: t('pages.life.followingFeed') }
@@ -505,6 +514,28 @@ function replyKey(commentId: number) {
return `reply-${commentId}`; return `reply-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function commentSort(postId: number): CommentSort {
return commentSorts.value[postId] ?? 'oldest';
}
function setCommentSort(post: LifePost, sort: CommentSort) {
commentSorts.value = {
...commentSorts.value,
[post.id]: sort
};
void loadComments(post, true);
}
function handleCommentSortChange(post: LifePost, event: Event) {
if (event.target instanceof HTMLSelectElement) {
setCommentSort(post, event.target.value as CommentSort);
}
}
function initialCommentPage(post: LifePost): LifeCommentPageState { function initialCommentPage(post: LifePost): LifeCommentPageState {
return { return {
items: post.commentPreview, items: post.commentPreview,
@@ -768,7 +799,12 @@ async function loadComments(post: LifePost, reset = false) {
}); });
try { try {
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value }); const page = await api.lifeComments(post.id, {
limit: lifeCommentPageSize,
cursor,
language: selectedFeedLanguageCode.value,
sort: commentSort(post.id)
});
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items); const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
setCommentPage(post.id, { setCommentPage(post.id, {
items: nextItems, items: nextItems,
@@ -858,6 +894,23 @@ function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function canUseRatings(post: LifePost) { function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true; return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
} }
@@ -1018,6 +1071,9 @@ async function submitComment(post: LifePost) {
})); }));
commentBodies.value[post.id] = ''; commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true); setCommentsExpanded(post.id, true);
if (commentSort(post.id) !== 'oldest') {
void loadComments(post, true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally { } finally {
@@ -1044,12 +1100,16 @@ async function submitReply(post: LifePost, comment: LifeComment) {
const nextTotal = commentCount(post) + 1; const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal; post.commentCount = nextTotal;
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
updateCommentPage(post, (page) => ({ updateCommentPage(post, (page) => ({
...page, ...page,
total: nextTotal total: nextTotal
})); }));
setCommentsExpanded(post.id, true); setCommentsExpanded(post.id, true);
cancelReply(comment.id); cancelReply(comment.id);
if (commentSort(post.id) === 'most-replied') {
void loadComments(post, true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally { } finally {
@@ -1152,6 +1212,51 @@ async function restoreComment(post: LifePost, comment: LifeComment) {
} }
} }
async function retryCommentModeration(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(post: LifePost, comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
if (commentSort(post.id) === 'most-liked') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} finally {
commentBusyKey.value = '';
}
}
function formatPostTime(value: string) { function formatPostTime(value: string) {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
@@ -1635,8 +1740,18 @@ onUnmounted(() => {
:aria-label="t('pages.life.comments')" :aria-label="t('pages.life.comments')"
> >
<div class="life-comments__header"> <div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3> <div>
<span>{{ commentCount(post) }}</span> <h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="commentSort(post.id)" @change="handleCommentSortChange(post, $event)">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div> </div>
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)"> <form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -1709,6 +1824,19 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions"> <div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(post, comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="!comment.deleted && canComment" v-if="!comment.deleted && canComment"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -1740,8 +1868,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(post, comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert"> <p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(comment.id)] }} {{ commentErrors[replyKey(comment.id)] }}
</p> </p>
@@ -1806,7 +1950,20 @@ onUnmounted(() => {
<strong>{{ t('pages.life.moderationReason') }}</strong> <strong>{{ t('pages.life.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span> <span>{{ reply.moderationReason }}</span>
</p> </p>
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions"> <div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(post, reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canManageComment(reply)" v-if="canManageComment(reply)"
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -1828,7 +1985,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(post, reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert"> <p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[replyKey(reply.id)] }} {{ commentErrors[replyKey(reply.id)] }}
</p> </p>

View File

@@ -872,6 +872,8 @@ export const systemWordingMessages = {
sortLatest: 'Latest', sortLatest: 'Latest',
sortOldest: 'Oldest', sortOldest: 'Oldest',
sortTopRated: 'Top rated', sortTopRated: 'Top rated',
sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied',
categoryPlaceholder: 'Select category', categoryPlaceholder: 'Select category',
searchCategories: 'Search categories', searchCategories: 'Search categories',
search: 'Search Life', search: 'Search Life',
@@ -924,6 +926,10 @@ export const systemWordingMessages = {
deleteCommentConfirm: 'Delete this comment?', deleteCommentConfirm: 'Delete this comment?',
commentDeleted: 'Comment deleted', commentDeleted: 'Comment deleted',
restoreComment: 'Undo', restoreComment: 'Undo',
likeComment: 'Like comment',
unlikeComment: 'Unlike comment',
commentLikeCount: '{count} likes',
commentLikeFailed: 'Like failed',
commentRequired: 'Please enter a comment.', commentRequired: 'Please enter a comment.',
commentFailed: 'Comment failed', commentFailed: 'Comment failed',
replyFailed: 'Reply failed', replyFailed: 'Reply failed',
@@ -1174,6 +1180,15 @@ export const systemWordingMessages = {
moderationRetryFailed: 'Review retry failed', moderationRetryFailed: 'Review retry failed',
loading: 'Loading discussion', loading: 'Loading discussion',
loadMore: 'Load more comments', loadMore: 'Load more comments',
sort: 'Sort',
sortOldest: 'Oldest',
sortLatest: 'Latest',
sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied',
likeComment: 'Like comment',
unlikeComment: 'Unlike comment',
commentLikeCount: '{count} likes',
commentLikeFailed: 'Like failed',
empty: 'No discussion yet', empty: 'No discussion yet',
emptyHint: 'Start a new discussion now.', emptyHint: 'Start a new discussion now.',
loginPrompt: 'Log in with a verified email to comment.', loginPrompt: 'Log in with a verified email to comment.',
@@ -2166,6 +2181,8 @@ export const systemWordingMessages = {
sortLatest: '最新', sortLatest: '最新',
sortOldest: '最早', sortOldest: '最早',
sortTopRated: '评分最高', sortTopRated: '评分最高',
sortMostLiked: '点赞最多',
sortMostReplied: '回复最多',
categoryPlaceholder: '选择 Category', categoryPlaceholder: '选择 Category',
searchCategories: '搜索 Category', searchCategories: '搜索 Category',
search: '搜索动态', search: '搜索动态',
@@ -2218,6 +2235,10 @@ export const systemWordingMessages = {
deleteCommentConfirm: '确认删除这条评论?', deleteCommentConfirm: '确认删除这条评论?',
commentDeleted: '评论已删除', commentDeleted: '评论已删除',
restoreComment: '撤销', restoreComment: '撤销',
likeComment: '点赞评论',
unlikeComment: '取消点赞评论',
commentLikeCount: '{count} 个赞',
commentLikeFailed: '点赞失败',
commentRequired: '请输入评论内容。', commentRequired: '请输入评论内容。',
commentFailed: '评论失败', commentFailed: '评论失败',
replyFailed: '回复失败', replyFailed: '回复失败',
@@ -2468,6 +2489,15 @@ export const systemWordingMessages = {
moderationRetryFailed: '重新审核失败', moderationRetryFailed: '重新审核失败',
loading: '正在加载讨论', loading: '正在加载讨论',
loadMore: '加载更多评论', loadMore: '加载更多评论',
sort: '排序',
sortOldest: '最早',
sortLatest: '最新',
sortMostLiked: '点赞最多',
sortMostReplied: '回复最多',
likeComment: '点赞评论',
unlikeComment: '取消点赞评论',
commentLikeCount: '{count} 个赞',
commentLikeFailed: '点赞失败',
empty: '暂无讨论', empty: '暂无讨论',
emptyHint: '现在发起新的讨论。', emptyHint: '现在发起新的讨论。',
loginPrompt: '使用已验证邮箱登录后即可评论。', loginPrompt: '使用已验证邮箱登录后即可评论。',