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:
23
DESIGN.md
23
DESIGN.md
@@ -377,8 +377,9 @@
|
||||
- 讨论回复只支持一层回复,不做无限嵌套。
|
||||
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
|
||||
- 被删除实体的讨论会随实体删除一并清理。
|
||||
- 讨论按创建时间正序展示。
|
||||
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items`、`nextCursor`、`hasMore`、`total`。
|
||||
- 讨论列表支持 `sort`:`oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
|
||||
- 已注册并完成邮箱验证且拥有 `discussions.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的实体讨论评论;每个用户对每条评论最多 1 个 Like。
|
||||
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
|
||||
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
|
||||
- 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
|
||||
@@ -390,6 +391,7 @@
|
||||
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
|
||||
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
||||
- API 对外返回讨论评论的 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
||||
- API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情;不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at`、`deleted_by_user_id` 等内部字段。
|
||||
|
||||
## AI 审核
|
||||
@@ -828,7 +830,9 @@ Life Post 可配置:
|
||||
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||
- 每条 Life 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 Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
|
||||
@@ -850,7 +854,7 @@ Life Post 可配置:
|
||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
|
||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核,API 也必须拒绝对 `reviewing` 或 `approved` 评论重新审核。
|
||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
|
||||
API 暴露边界:
|
||||
@@ -861,11 +865,12 @@ API 暴露边界:
|
||||
- Life Post Rating 只返回 `ratingAverage`、`ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
|
||||
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`,不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
|
||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||
- Life Comment 只返回 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
||||
- Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction,不内嵌其他用户明细。
|
||||
- Life Reaction 用户列表 API 只返回公开用户摘要 `id`、`displayName`、`reactionType` 和 `reactedAt`;不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。
|
||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount` 和 `commentPreview`,不内嵌完整评论列表。
|
||||
- Life Post 详情 API 返回单条 Life Post,字段边界与列表项一致;评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。
|
||||
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论。
|
||||
- Life Comment 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`、`total`;`cursor` 是不透明分页令牌;普通访客只读取审核通过评论;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`。
|
||||
- Life Comment 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情。
|
||||
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈或不必要的审计 payload。
|
||||
- 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/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||
- `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/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||
- `PUT /api/users/:id/follow`:需要 `users.follow`;Follow 指定用户并返回更新后的公开 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:
|
||||
|
||||
@@ -1075,11 +1080,17 @@ API 暴露边界:
|
||||
- `DELETE /api/life-comments/:id`
|
||||
- `POST /api/life-comments/:id/restore`
|
||||
- `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` 权限。
|
||||
- `POST /api/discussions/:entityType/:entityId/comments`
|
||||
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
|
||||
- `DELETE /api/discussions/comments/:id`
|
||||
- `POST /api/discussions/comments/:id/moderation/retry`
|
||||
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
||||
- `PUT /api/discussions/comments/:id/like`
|
||||
- `DELETE /api/discussions/comments/:id/like`
|
||||
- Life Reaction 的设置、替换和取消。
|
||||
- `PUT /api/life-posts/:id/reaction`
|
||||
- `DELETE /api/life-posts/:id/reaction`
|
||||
|
||||
@@ -299,12 +299,14 @@ VALUES
|
||||
('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-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.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),
|
||||
('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-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;
|
||||
|
||||
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.delete',
|
||||
'life.comments.delete-any',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.delete-any'
|
||||
'discussions.comments.delete-any',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'admin'
|
||||
AND NOT EXISTS (
|
||||
@@ -460,11 +464,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.posts.delete',
|
||||
'life.comments.create',
|
||||
'life.comments.delete',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'editor'
|
||||
AND NOT EXISTS (
|
||||
@@ -508,11 +514,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.posts.delete',
|
||||
'life.comments.create',
|
||||
'life.comments.delete',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'member'
|
||||
AND NOT EXISTS (
|
||||
@@ -529,6 +537,20 @@ JOIN permissions p ON p.key = 'life.ratings.set'
|
||||
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 = '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)
|
||||
SELECT r.id, p.id
|
||||
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
|
||||
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 (
|
||||
post_id integer NOT NULL REFERENCES life_posts(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
|
||||
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
|
||||
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
||||
|
||||
|
||||
@@ -271,6 +271,9 @@ type EntityDiscussionCommentRow = {
|
||||
createdAtCursor?: string;
|
||||
updatedAt: Date;
|
||||
author: { id: number; displayName: string } | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
};
|
||||
type EntityDiscussionComment = Omit<EntityDiscussionCommentRow, 'createdAtCursor'> & {
|
||||
replies: EntityDiscussionComment[];
|
||||
@@ -313,6 +316,9 @@ type LifeCommentRow = {
|
||||
createdAtCursor?: string;
|
||||
updatedAt: Date;
|
||||
author: { id: number; displayName: string } | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
};
|
||||
|
||||
type LifeComment = Omit<LifeCommentRow, 'createdAtCursor'> & {
|
||||
@@ -351,6 +357,12 @@ type LifePostCursor = {
|
||||
};
|
||||
|
||||
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
|
||||
type CommentCursor = {
|
||||
createdAt: string;
|
||||
id: number;
|
||||
count?: number;
|
||||
};
|
||||
|
||||
type LifePostFilters = {
|
||||
authorId?: number;
|
||||
@@ -2866,6 +2878,20 @@ function addModerationVisibilityCondition(
|
||||
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(
|
||||
conditions: string[],
|
||||
params: unknown[],
|
||||
@@ -2966,6 +2992,92 @@ function cleanCommentLimit(value: QueryValue): number {
|
||||
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 {
|
||||
const rawCursor = asString(value);
|
||||
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 `
|
||||
SELECT
|
||||
lc.id,
|
||||
@@ -3115,8 +3237,21 @@ function lifeCommentProjection(whereClause: string): string {
|
||||
lc.created_at AS "createdAt",
|
||||
lc.created_at::text AS "createdAtCursor",
|
||||
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
|
||||
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
|
||||
${whereClause}
|
||||
`;
|
||||
@@ -3236,7 +3371,7 @@ async function lifeCommentPreviewForPosts(
|
||||
) ranked
|
||||
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
|
||||
`,
|
||||
params
|
||||
@@ -3256,9 +3391,10 @@ export async function listLifeComments(
|
||||
canViewAll = false
|
||||
): Promise<LifeCommentsPage | null> {
|
||||
const postId = requirePositiveInteger(postIdValue, 'server.validation.recordInvalid');
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const cursor = decodeCommentCursor(paramsQuery.cursor);
|
||||
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||
const sort = cleanCommentSort(paramsQuery.sort);
|
||||
const postParams: unknown[] = [postId];
|
||||
const postConditions = ['lp.id = $1', 'lp.deleted_at IS NULL'];
|
||||
addModerationVisibilityCondition(postConditions, postParams, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
||||
@@ -3284,15 +3420,14 @@ export async function listLifeComments(
|
||||
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
topLevelConditions.push(`(lc.created_at, lc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||
addCommentCursorCondition(topLevelConditions, params, 'lc', cursor, sort);
|
||||
}
|
||||
|
||||
params.push(limit + 1);
|
||||
const topLevelRows = await query<LifeCommentRow>(
|
||||
`
|
||||
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
|
||||
ORDER BY lc.created_at, lc.id
|
||||
${lifeCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
|
||||
ORDER BY ${commentSortOrder('lc', sort)}
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params
|
||||
@@ -3309,7 +3444,7 @@ export async function listLifeComments(
|
||||
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
||||
return query<LifeCommentRow>(
|
||||
`
|
||||
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
|
||||
${lifeCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
|
||||
ORDER BY lc.created_at, lc.id
|
||||
`,
|
||||
replyParams
|
||||
@@ -3334,12 +3469,7 @@ export async function listLifeComments(
|
||||
items: buildLifeCommentTree([...topLevelComments, ...replyRows]),
|
||||
nextCursor:
|
||||
hasMore && topLevelComments.length > 0
|
||||
? encodeProfileCursor({
|
||||
createdAt:
|
||||
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
|
||||
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
|
||||
id: topLevelComments[topLevelComments.length - 1].id
|
||||
})
|
||||
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
|
||||
: null,
|
||||
hasMore,
|
||||
total: total?.total ?? 0
|
||||
@@ -3526,10 +3656,10 @@ async function lifeRatingsForPosts(postIds: number[], userId: number | null): Pr
|
||||
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>(
|
||||
`
|
||||
${lifeCommentProjection('WHERE lc.id = $1')}
|
||||
${lifeCommentProjection('WHERE lc.id = $1', userId, canViewAll)}
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
@@ -4347,6 +4477,7 @@ export async function retryLifePostModeration(id: number, userId: number, locale
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
||||
`,
|
||||
[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(
|
||||
@@ -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) {
|
||||
@@ -4572,7 +4703,7 @@ export async function restoreLifeComment(id: number, userId: number) {
|
||||
[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) {
|
||||
@@ -4584,6 +4715,7 @@ export async function retryLifeCommentModeration(id: number, userId: number, all
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
||||
`,
|
||||
[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 });
|
||||
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 {
|
||||
@@ -4627,7 +4815,21 @@ async function entityDiscussionExists(
|
||||
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 `
|
||||
SELECT
|
||||
edc.id,
|
||||
@@ -4645,8 +4847,21 @@ function entityDiscussionCommentProjection(whereClause: string): string {
|
||||
CASE
|
||||
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)
|
||||
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
|
||||
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
|
||||
${whereClause}
|
||||
`;
|
||||
@@ -4678,10 +4893,14 @@ function buildEntityDiscussionCommentTree(rows: EntityDiscussionCommentRow[]): E
|
||||
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>(
|
||||
`
|
||||
${entityDiscussionCommentProjection('WHERE edc.id = $1')}
|
||||
${entityDiscussionCommentProjection('WHERE edc.id = $1', userId, canViewAll)}
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
@@ -4703,9 +4922,10 @@ export async function listEntityDiscussionComments(
|
||||
): Promise<EntityDiscussionCommentsPage | null> {
|
||||
const entityType = cleanDiscussionEntityType(entityTypeValue);
|
||||
const entityId = requirePositiveInteger(entityIdValue, 'server.validation.recordInvalid');
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const cursor = decodeCommentCursor(paramsQuery.cursor);
|
||||
const limit = cleanCommentLimit(paramsQuery.limit);
|
||||
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
|
||||
const sort = cleanCommentSort(paramsQuery.sort);
|
||||
|
||||
if (!(await entityDiscussionExists(pool, entityType, entityId))) {
|
||||
return null;
|
||||
@@ -4717,15 +4937,14 @@ export async function listEntityDiscussionComments(
|
||||
addModerationLanguageCondition(topLevelConditions, params, 'edc', languageCode);
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
topLevelConditions.push(`(edc.created_at, edc.id) > ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||
addCommentCursorCondition(topLevelConditions, params, 'edc', cursor, sort);
|
||||
}
|
||||
|
||||
params.push(limit + 1);
|
||||
const topLevelRows = await query<EntityDiscussionCommentRow>(
|
||||
`
|
||||
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`)}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
${entityDiscussionCommentProjection(`WHERE ${topLevelConditions.join(' AND ')}`, userId, canViewAll)}
|
||||
ORDER BY ${commentSortOrder('edc', sort)}
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params
|
||||
@@ -4741,7 +4960,7 @@ export async function listEntityDiscussionComments(
|
||||
addModerationLanguageCondition(replyConditions, replyParams, 'edc', languageCode);
|
||||
return query<EntityDiscussionCommentRow>(
|
||||
`
|
||||
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`)}
|
||||
${entityDiscussionCommentProjection(`WHERE ${replyConditions.join(' AND ')}`, userId, canViewAll)}
|
||||
ORDER BY edc.created_at, edc.id
|
||||
`,
|
||||
replyParams
|
||||
@@ -4765,12 +4984,7 @@ export async function listEntityDiscussionComments(
|
||||
items: buildEntityDiscussionCommentTree([...topLevelComments, ...replyRows]),
|
||||
nextCursor:
|
||||
hasMore && topLevelComments.length > 0
|
||||
? encodeProfileCursor({
|
||||
createdAt:
|
||||
topLevelComments[topLevelComments.length - 1].createdAtCursor ??
|
||||
topLevelComments[topLevelComments.length - 1].createdAt.toISOString(),
|
||||
id: topLevelComments[topLevelComments.length - 1].id
|
||||
})
|
||||
? encodeCommentCursor(topLevelComments[topLevelComments.length - 1], sort)
|
||||
: null,
|
||||
hasMore,
|
||||
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(
|
||||
@@ -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> {
|
||||
@@ -4907,6 +5121,7 @@ export async function retryEntityDiscussionCommentModeration(
|
||||
WHERE id = $1
|
||||
AND ($3 = true OR created_by_user_id = $2)
|
||||
AND deleted_at IS NULL
|
||||
AND ai_moderation_status IN ('unreviewed', 'rejected', 'failed')
|
||||
`,
|
||||
[commentId, userId, allowAny]
|
||||
);
|
||||
@@ -4916,7 +5131,60 @@ export async function retryEntityDiscussionCommentModeration(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -6838,7 +7106,8 @@ const dataToolColumns = {
|
||||
'deleted_at',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
],
|
||||
discussionCommentLikes: ['comment_id', 'user_id', 'created_at']
|
||||
} as const;
|
||||
|
||||
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
|
||||
`,
|
||||
[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, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
|
||||
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
|
||||
await insertRows(
|
||||
client,
|
||||
'entity_discussion_comment_likes',
|
||||
dataToolColumns.discussionCommentLikes,
|
||||
dataToolTableRows(data, 'discussionCommentLikes')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ import {
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
deleteEntityDiscussionCommentLike,
|
||||
deleteLifeComment,
|
||||
deleteLifeCommentLike,
|
||||
deleteLifePost,
|
||||
deleteLifePostRating,
|
||||
deleteLifePostReaction,
|
||||
@@ -108,6 +110,8 @@ import {
|
||||
restoreLifeComment,
|
||||
setLifePostRating,
|
||||
setLifePostReaction,
|
||||
setEntityDiscussionCommentLike,
|
||||
setLifeCommentLike,
|
||||
updateConfig,
|
||||
updateAncientArtifact,
|
||||
updateDailyChecklistItem,
|
||||
@@ -1470,6 +1474,26 @@ app.post('/api/life-comments/:id/restore', async (request, reply) => {
|
||||
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) => {
|
||||
const user = await requireAnyPermissionWithRateLimits(
|
||||
request,
|
||||
@@ -1580,6 +1604,28 @@ app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply
|
||||
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) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatusBadge from './StatusBadge.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 {
|
||||
api,
|
||||
getAuthToken,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type DiscussionEntityType,
|
||||
type EntityDiscussionComment,
|
||||
type Language,
|
||||
@@ -41,7 +42,9 @@ const formError = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const activeLanguageCode = ref('all');
|
||||
const activeSort = ref<CommentSort>('oldest');
|
||||
const moderationBusyId = ref<number | null>(null);
|
||||
const likeBusyId = ref<number | null>(null);
|
||||
const commentMaxLength = 1000;
|
||||
const discussionPageSize = 20;
|
||||
const allLanguageValue = 'all';
|
||||
@@ -56,12 +59,19 @@ function can(permissionKey: string) {
|
||||
}
|
||||
|
||||
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 selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
|
||||
const languageTabs = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('discussion.allLanguages') },
|
||||
...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() {
|
||||
authReady.value = false;
|
||||
@@ -119,7 +129,8 @@ async function loadDiscussion(reset = true) {
|
||||
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||
limit: discussionPageSize,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
language: selectedLanguageCode.value
|
||||
language: selectedLanguageCode.value,
|
||||
sort: activeSort.value
|
||||
});
|
||||
if (nextRequestId === requestId) {
|
||||
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||
@@ -151,6 +162,17 @@ function commentKey(commentId: number) {
|
||||
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) {
|
||||
return replyBodies.value[commentId] ?? '';
|
||||
}
|
||||
@@ -181,6 +203,14 @@ function canRetryModeration(comment: EntityDiscussionComment) {
|
||||
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) {
|
||||
return (
|
||||
!comment.deleted &&
|
||||
@@ -267,6 +297,9 @@ async function submitComment() {
|
||||
comments.value = [...comments.value, comment];
|
||||
commentTotal.value += 1;
|
||||
body.value = '';
|
||||
if (activeSort.value !== 'oldest') {
|
||||
void loadDiscussion();
|
||||
}
|
||||
} catch (error) {
|
||||
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
|
||||
} finally {
|
||||
@@ -291,8 +324,12 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
commentTotal.value += 1;
|
||||
cancelReply(comment.id);
|
||||
if (activeSort.value === 'most-replied') {
|
||||
void loadDiscussion();
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
|
||||
} 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(
|
||||
items: EntityDiscussionComment[],
|
||||
commentId: number,
|
||||
@@ -455,6 +535,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Skeleton variant="box" height="112px" />
|
||||
@@ -528,6 +616,18 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="canComment"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -563,6 +663,9 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</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">
|
||||
{{ commentErrors[commentKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -624,7 +727,19 @@ onUnmounted(() => {
|
||||
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canRetryModeration(reply)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -639,6 +754,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageComment(reply)"
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('discussion.deleteComment')"
|
||||
@@ -648,6 +764,9 @@ onUnmounted(() => {
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||
</button>
|
||||
</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">
|
||||
{{ commentErrors[commentKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -421,8 +421,11 @@ export interface CommentPageParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
language?: string;
|
||||
sort?: CommentSort;
|
||||
}
|
||||
|
||||
export type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
|
||||
|
||||
export interface LifeComment {
|
||||
id: number;
|
||||
postId: number;
|
||||
@@ -435,6 +438,9 @@ export interface LifeComment {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
replies: LifeComment[];
|
||||
}
|
||||
|
||||
@@ -831,6 +837,9 @@ export interface EntityDiscussionComment {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
replies: EntityDiscussionComment[];
|
||||
}
|
||||
|
||||
@@ -1229,7 +1238,8 @@ export const api = {
|
||||
`/api/life-posts/${postId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
language: params.language,
|
||||
sort: params.sort
|
||||
})}`
|
||||
),
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
@@ -1237,13 +1247,16 @@ export const api = {
|
||||
retryLifeCommentModeration: (id: string | number) =>
|
||||
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, '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}`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<EntityDiscussionCommentsPage>(
|
||||
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
language: params.language,
|
||||
sort: params.sort
|
||||
})}`
|
||||
),
|
||||
createEntityDiscussionComment: (
|
||||
@@ -1259,6 +1272,10 @@ export const api = {
|
||||
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryEntityDiscussionModeration: (id: string | number) =>
|
||||
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}`),
|
||||
uploadImage: (
|
||||
entityType: ImageUploadEntityType,
|
||||
|
||||
@@ -3262,7 +3262,8 @@ button:disabled,
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comments__header span {
|
||||
.life-comments__header > span,
|
||||
.life-comments__header > div > span {
|
||||
min-width: 32px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -3274,6 +3275,25 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -3386,6 +3406,13 @@ button:disabled,
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-comment__action-count {
|
||||
min-width: 1ch;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.life-comments__empty {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -4529,6 +4556,26 @@ button:disabled,
|
||||
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-form,
|
||||
.entity-discussion-list {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifeReactionType,
|
||||
@@ -53,6 +54,7 @@ const commentsLoading = ref(false);
|
||||
const commentsLoadingMore = ref(false);
|
||||
const commentsLoaded = ref(false);
|
||||
const commentsError = ref('');
|
||||
const activeCommentSort = ref<CommentSort>('oldest');
|
||||
const commentBodies = ref<Record<number, string>>({});
|
||||
const replyBodies = ref<Record<number, string>>({});
|
||||
const replyTargetId = ref<number | null>(null);
|
||||
@@ -82,9 +84,16 @@ function can(permissionKey: string) {
|
||||
}
|
||||
|
||||
const canComment = computed(() => can('life.comments.create'));
|
||||
const canLikeComments = computed(() => can('life.comments.like'));
|
||||
const canReact = computed(() => can('life.reactions.set'));
|
||||
const canRate = computed(() => can('life.ratings.set'));
|
||||
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() {
|
||||
const value = route.params.id;
|
||||
@@ -185,7 +194,7 @@ async function loadComments(reset = false) {
|
||||
}
|
||||
|
||||
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);
|
||||
commentsNextCursor.value = page.nextCursor;
|
||||
commentsHasMore.value = page.hasMore;
|
||||
@@ -208,6 +217,17 @@ function replyKey(commentId: number) {
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
reactionUsersModal.value = { postId, reactionType };
|
||||
}
|
||||
@@ -536,6 +573,9 @@ async function submitComment(currentPost: LifePost) {
|
||||
currentPost.commentCount = commentsTotal.value;
|
||||
commentsLoaded.value = true;
|
||||
commentBodies.value[currentPost.id] = '';
|
||||
if (activeCommentSort.value !== 'oldest') {
|
||||
void loadComments(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||
} finally {
|
||||
@@ -571,10 +611,14 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
|
||||
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
commentsTotal.value += 1;
|
||||
currentPost.commentCount = commentsTotal.value;
|
||||
commentsLoaded.value = true;
|
||||
cancelReply(comment.id);
|
||||
if (activeCommentSort.value === 'most-replied') {
|
||||
void loadComments(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||
} 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) {
|
||||
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')">
|
||||
<div class="life-comments__header">
|
||||
<h3>{{ t('pages.life.comments') }}</h3>
|
||||
<span>{{ commentsTotal }}</span>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
@@ -995,6 +1088,19 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="!comment.deleted && canCommentOnPost"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -1026,8 +1132,24 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{{ commentErrors[replyKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -1092,7 +1214,20 @@ onUnmounted(() => {
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canManageComment(reply)"
|
||||
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" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
<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">
|
||||
{{ commentErrors[replyKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type GameVersion,
|
||||
type Language,
|
||||
type LifeCategory,
|
||||
@@ -93,6 +94,7 @@ const replyBodies = ref<Record<number, string>>({});
|
||||
const replyTargetId = ref<number | null>(null);
|
||||
const expandedComments = ref<Record<number, boolean>>({});
|
||||
const commentPages = ref<Record<number, LifeCommentPageState>>({});
|
||||
const commentSorts = ref<Record<number, CommentSort>>({});
|
||||
const commentBusyKey = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const reactionPickerPostId = ref<number | null>(null);
|
||||
@@ -134,6 +136,7 @@ function can(permissionKey: string) {
|
||||
|
||||
const canPost = computed(() => can('life.posts.create'));
|
||||
const canComment = computed(() => can('life.comments.create'));
|
||||
const canLikeComments = computed(() => can('life.comments.like'));
|
||||
const canReact = computed(() => can('life.reactions.set'));
|
||||
const canRate = computed(() => can('life.ratings.set'));
|
||||
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: '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[]>(() => [
|
||||
{ value: 'all', label: t('pages.life.allFeed') },
|
||||
{ value: 'following', label: t('pages.life.followingFeed') }
|
||||
@@ -505,6 +514,28 @@ function replyKey(commentId: number) {
|
||||
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 {
|
||||
return {
|
||||
items: post.commentPreview,
|
||||
@@ -768,7 +799,12 @@ async function loadComments(post: LifePost, reset = false) {
|
||||
});
|
||||
|
||||
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);
|
||||
setCommentPage(post.id, {
|
||||
items: nextItems,
|
||||
@@ -858,6 +894,23 @@ function canUseReactions() {
|
||||
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) {
|
||||
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] = '';
|
||||
setCommentsExpanded(post.id, true);
|
||||
if (commentSort(post.id) !== 'oldest') {
|
||||
void loadComments(post, true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||
} finally {
|
||||
@@ -1044,12 +1100,16 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
updateCommentPage(post, (page) => ({
|
||||
...page,
|
||||
total: nextTotal
|
||||
}));
|
||||
setCommentsExpanded(post.id, true);
|
||||
cancelReply(comment.id);
|
||||
if (commentSort(post.id) === 'most-replied') {
|
||||
void loadComments(post, true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||
} 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) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -1635,8 +1740,18 @@ onUnmounted(() => {
|
||||
:aria-label="t('pages.life.comments')"
|
||||
>
|
||||
<div class="life-comments__header">
|
||||
<h3>{{ t('pages.life.comments') }}</h3>
|
||||
<span>{{ commentCount(post) }}</span>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
@@ -1709,6 +1824,19 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="!comment.deleted && canComment"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -1740,8 +1868,24 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{{ commentErrors[replyKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -1806,7 +1950,20 @@ onUnmounted(() => {
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canManageComment(reply)"
|
||||
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" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
<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">
|
||||
{{ commentErrors[replyKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -872,6 +872,8 @@ export const systemWordingMessages = {
|
||||
sortLatest: 'Latest',
|
||||
sortOldest: 'Oldest',
|
||||
sortTopRated: 'Top rated',
|
||||
sortMostLiked: 'Most liked',
|
||||
sortMostReplied: 'Most replied',
|
||||
categoryPlaceholder: 'Select category',
|
||||
searchCategories: 'Search categories',
|
||||
search: 'Search Life',
|
||||
@@ -924,6 +926,10 @@ export const systemWordingMessages = {
|
||||
deleteCommentConfirm: 'Delete this comment?',
|
||||
commentDeleted: 'Comment deleted',
|
||||
restoreComment: 'Undo',
|
||||
likeComment: 'Like comment',
|
||||
unlikeComment: 'Unlike comment',
|
||||
commentLikeCount: '{count} likes',
|
||||
commentLikeFailed: 'Like failed',
|
||||
commentRequired: 'Please enter a comment.',
|
||||
commentFailed: 'Comment failed',
|
||||
replyFailed: 'Reply failed',
|
||||
@@ -1174,6 +1180,15 @@ export const systemWordingMessages = {
|
||||
moderationRetryFailed: 'Review retry failed',
|
||||
loading: 'Loading discussion',
|
||||
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',
|
||||
emptyHint: 'Start a new discussion now.',
|
||||
loginPrompt: 'Log in with a verified email to comment.',
|
||||
@@ -2166,6 +2181,8 @@ export const systemWordingMessages = {
|
||||
sortLatest: '最新',
|
||||
sortOldest: '最早',
|
||||
sortTopRated: '评分最高',
|
||||
sortMostLiked: '点赞最多',
|
||||
sortMostReplied: '回复最多',
|
||||
categoryPlaceholder: '选择 Category',
|
||||
searchCategories: '搜索 Category',
|
||||
search: '搜索动态',
|
||||
@@ -2218,6 +2235,10 @@ export const systemWordingMessages = {
|
||||
deleteCommentConfirm: '确认删除这条评论?',
|
||||
commentDeleted: '评论已删除',
|
||||
restoreComment: '撤销',
|
||||
likeComment: '点赞评论',
|
||||
unlikeComment: '取消点赞评论',
|
||||
commentLikeCount: '{count} 个赞',
|
||||
commentLikeFailed: '点赞失败',
|
||||
commentRequired: '请输入评论内容。',
|
||||
commentFailed: '评论失败',
|
||||
replyFailed: '回复失败',
|
||||
@@ -2468,6 +2489,15 @@ export const systemWordingMessages = {
|
||||
moderationRetryFailed: '重新审核失败',
|
||||
loading: '正在加载讨论',
|
||||
loadMore: '加载更多评论',
|
||||
sort: '排序',
|
||||
sortOldest: '最早',
|
||||
sortLatest: '最新',
|
||||
sortMostLiked: '点赞最多',
|
||||
sortMostReplied: '回复最多',
|
||||
likeComment: '点赞评论',
|
||||
unlikeComment: '取消点赞评论',
|
||||
commentLikeCount: '{count} 个赞',
|
||||
commentLikeFailed: '点赞失败',
|
||||
empty: '暂无讨论',
|
||||
emptyHint: '现在发起新的讨论。',
|
||||
loginPrompt: '使用已验证邮箱登录后即可评论。',
|
||||
|
||||
Reference in New Issue
Block a user