feat(life): allow authors to view and restore their deleted comments
Update backend to return soft-deleted comments to their authors Add restore endpoint and frontend Undo button for deleted comments Retain comment body and author information upon deletion
This commit is contained in:
@@ -817,7 +817,7 @@ Life Post 可配置:
|
|||||||
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 不再出现在评论列表、评论预览或评论数量中。
|
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
@@ -841,7 +841,7 @@ Life Post 可配置:
|
|||||||
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
||||||
- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签。
|
- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签。
|
||||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||||
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中。
|
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
|
||||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
@@ -1063,6 +1063,7 @@ API 暴露边界:
|
|||||||
- `POST /api/life-posts/:postId/comments`
|
- `POST /api/life-posts/:postId/comments`
|
||||||
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
||||||
- `DELETE /api/life-comments/:id`
|
- `DELETE /api/life-comments/:id`
|
||||||
|
- `POST /api/life-comments/:id/restore`
|
||||||
- `POST /api/life-comments/:id/moderation/retry`
|
- `POST /api/life-comments/:id/moderation/retry`
|
||||||
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
||||||
- `POST /api/discussions/:entityType/:entityId/comments`
|
- `POST /api/discussions/:entityType/:entityId/comments`
|
||||||
|
|||||||
@@ -3077,7 +3077,7 @@ function lifeCommentProjection(whereClause: string): string {
|
|||||||
lc.id,
|
lc.id,
|
||||||
lc.post_id AS "postId",
|
lc.post_id AS "postId",
|
||||||
lc.parent_comment_id AS "parentCommentId",
|
lc.parent_comment_id AS "parentCommentId",
|
||||||
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
lc.body,
|
||||||
lc.deleted_at IS NOT NULL AS deleted,
|
lc.deleted_at IS NOT NULL AS deleted,
|
||||||
lc.ai_moderation_status AS "moderationStatus",
|
lc.ai_moderation_status AS "moderationStatus",
|
||||||
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
@@ -3085,18 +3085,23 @@ 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
|
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
|
||||||
WHEN lc.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
|
|
||||||
FROM life_post_comments lc
|
FROM life_post_comments lc
|
||||||
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}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addVisibleLifeCommentCondition(conditions: string[]): void {
|
function visibleLifeCommentExpression(alias: string, ownerColumn: string, userParamIndex: number | null): string {
|
||||||
conditions.push('lc.deleted_at IS NULL');
|
return userParamIndex !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = $${userParamIndex})` : `${alias}.deleted_at IS NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVisibleLifeCommentCondition(conditions: string[], params: unknown[], userId: number | null): void {
|
||||||
|
const userParamIndex = params.length + 1;
|
||||||
|
if (userId !== null) {
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
conditions.push(visibleLifeCommentExpression('lc', 'lc.created_by_user_id', userId === null ? null : userParamIndex));
|
||||||
conditions.push(`
|
conditions.push(`
|
||||||
(
|
(
|
||||||
lc.parent_comment_id IS NULL
|
lc.parent_comment_id IS NULL
|
||||||
@@ -3104,7 +3109,7 @@ function addVisibleLifeCommentCondition(conditions: string[]): void {
|
|||||||
SELECT 1
|
SELECT 1
|
||||||
FROM life_post_comments parent_comment
|
FROM life_post_comments parent_comment
|
||||||
WHERE parent_comment.id = lc.parent_comment_id
|
WHERE parent_comment.id = lc.parent_comment_id
|
||||||
AND parent_comment.deleted_at IS NULL
|
AND ${visibleLifeCommentExpression('parent_comment', 'parent_comment.created_by_user_id', userId === null ? null : userParamIndex)}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
@@ -3152,7 +3157,7 @@ async function lifeCommentCountsForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
||||||
addVisibleLifeCommentCondition(conditions);
|
addVisibleLifeCommentCondition(conditions, params, userId);
|
||||||
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
|
|
||||||
const rows = await query<{ postId: number; total: number }>(
|
const rows = await query<{ postId: number; total: number }>(
|
||||||
@@ -3184,7 +3189,7 @@ async function lifeCommentPreviewForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
||||||
addVisibleLifeCommentCondition(previewConditions);
|
addVisibleLifeCommentCondition(previewConditions, params, userId);
|
||||||
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
params.push(lifeCommentPreviewLimit);
|
params.push(lifeCommentPreviewLimit);
|
||||||
|
|
||||||
@@ -3244,7 +3249,7 @@ export async function listLifeComments(
|
|||||||
|
|
||||||
const params: unknown[] = [postId];
|
const params: unknown[] = [postId];
|
||||||
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
||||||
addVisibleLifeCommentCondition(topLevelConditions);
|
addVisibleLifeCommentCondition(topLevelConditions, params, userId);
|
||||||
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
||||||
|
|
||||||
@@ -3269,7 +3274,7 @@ export async function listLifeComments(
|
|||||||
? await (async () => {
|
? await (async () => {
|
||||||
const replyParams: unknown[] = [topLevelIds];
|
const replyParams: unknown[] = [topLevelIds];
|
||||||
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
||||||
addVisibleLifeCommentCondition(replyConditions);
|
addVisibleLifeCommentCondition(replyConditions, replyParams, userId);
|
||||||
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
||||||
return query<LifeCommentRow>(
|
return query<LifeCommentRow>(
|
||||||
@@ -3283,7 +3288,7 @@ export async function listLifeComments(
|
|||||||
: [];
|
: [];
|
||||||
const totalParams: unknown[] = [postId];
|
const totalParams: unknown[] = [postId];
|
||||||
const totalConditions = ['lc.post_id = $1'];
|
const totalConditions = ['lc.post_id = $1'];
|
||||||
addVisibleLifeCommentCondition(totalConditions);
|
addVisibleLifeCommentCondition(totalConditions, totalParams, userId);
|
||||||
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
||||||
const total = await queryOne<{ total: number }>(
|
const total = await queryOne<{ total: number }>(
|
||||||
@@ -4381,6 +4386,29 @@ export async function deleteLifeComment(id: number, userId: number, allowAny = f
|
|||||||
return Boolean(result);
|
return Boolean(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreLifeComment(id: number, userId: number) {
|
||||||
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||||
|
const result = await queryOne<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE life_post_comments
|
||||||
|
SET deleted_at = NULL, deleted_by_user_id = NULL, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND created_by_user_id = $2
|
||||||
|
AND deleted_at IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts lp
|
||||||
|
WHERE lp.id = life_post_comments.post_id
|
||||||
|
AND lp.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[commentId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ? getLifeCommentById(result.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
||||||
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||||
const row = await queryOne<{ id: number }>(
|
const row = await queryOne<{ id: number }>(
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ import {
|
|||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
retryLifePostModeration,
|
retryLifePostModeration,
|
||||||
|
restoreLifeComment,
|
||||||
setLifePostRating,
|
setLifePostRating,
|
||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
@@ -1419,6 +1420,16 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/life-comments/:id/restore', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.delete', 'communityWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const comment = await restoreLifeComment(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,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const iconStar: AppIcon = 'mdi:star';
|
|||||||
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconUndo: AppIcon = 'mdi:undo';
|
||||||
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
||||||
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
||||||
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||||
|
|||||||
@@ -1208,6 +1208,7 @@ export const api = {
|
|||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
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', {}),
|
||||||
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>(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
iconReactionLike,
|
iconReactionLike,
|
||||||
iconReactionThanks,
|
iconReactionThanks,
|
||||||
iconReply,
|
iconReply,
|
||||||
|
iconUndo,
|
||||||
iconVersion,
|
iconVersion,
|
||||||
iconWarning
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
@@ -149,6 +150,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
|||||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceCommentInTree(items: LifeComment[], updated: LifeComment): boolean {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.id === updated.id) {
|
||||||
|
items[index] = { ...updated, replies: item.replies };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceCommentInTree(item.replies, updated)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadComments(reset = false) {
|
async function loadComments(reset = false) {
|
||||||
const currentPost = post.value;
|
const currentPost = post.value;
|
||||||
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
|
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
|
||||||
@@ -208,8 +228,12 @@ function canManageComment(comment: LifeComment) {
|
|||||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRestoreComment(comment: LifeComment) {
|
||||||
|
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
|
||||||
|
}
|
||||||
|
|
||||||
function canSeeCommentModeration(comment: LifeComment) {
|
function canSeeCommentModeration(comment: LifeComment) {
|
||||||
return 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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseReactions() {
|
function canUseReactions() {
|
||||||
@@ -277,6 +301,10 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
return tones[status];
|
return tones[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moderationStatusVisible(status: AiModerationStatus) {
|
||||||
|
return status !== 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
function canRetryModeration(currentPost: LifePost) {
|
function canRetryModeration(currentPost: LifePost) {
|
||||||
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
||||||
}
|
}
|
||||||
@@ -554,16 +582,39 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markCommentDeleted(items: LifeComment[], id: number): boolean {
|
function countCommentBranch(comment: LifeComment): number {
|
||||||
|
return 1 + comment.replies.reduce((total, reply) => total + countCommentBranch(reply), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCommentFromTree(items: LifeComment[], id: number): number {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.id === id) {
|
||||||
|
const removedCount = countCommentBranch(item);
|
||||||
|
items.splice(index, 1);
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedCount = removeCommentFromTree(item.replies, id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.id === id) {
|
if (item.id === id) {
|
||||||
item.deleted = true;
|
item.deleted = true;
|
||||||
item.body = '';
|
|
||||||
item.author = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markCommentDeleted(item.replies, id)) {
|
if (markOwnCommentDeleted(item.replies, id)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +632,19 @@ async function deleteComment(comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
await api.deleteLifeComment(comment.id);
|
||||||
markCommentDeleted(comments.value, comment.id);
|
if (currentUser.value?.id === comment.author?.id) {
|
||||||
|
markOwnCommentDeleted(comments.value, comment.id);
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} else {
|
||||||
|
const removedCount = removeCommentFromTree(comments.value, comment.id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
commentsTotal.value = Math.max(0, commentsTotal.value - removedCount);
|
||||||
|
if (post.value) {
|
||||||
|
post.value.commentCount = commentsTotal.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (replyTargetId.value === comment.id) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -590,8 +653,24 @@ async function deleteComment(comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreComment(comment: LifeComment) {
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restored = await api.restoreLifeComment(comment.id);
|
||||||
|
replaceCommentInTree(comments.value, restored);
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
|
||||||
|
} finally {
|
||||||
|
commentBusyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function commentAuthorName(comment: LifeComment) {
|
function commentAuthorName(comment: LifeComment) {
|
||||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
return comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentInitial(comment: LifeComment) {
|
function commentInitial(comment: LifeComment) {
|
||||||
@@ -781,7 +860,12 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="life-post__review-actions">
|
<div class="life-post__review-actions">
|
||||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
<StatusBadge
|
||||||
|
v-if="moderationStatusVisible(post.moderationStatus)"
|
||||||
|
:label="moderationLabel(post.moderationStatus)"
|
||||||
|
:tone="moderationTone(post.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="canRetryModeration(post)"
|
v-if="canRetryModeration(post)"
|
||||||
class="life-icon-button life-review-button"
|
class="life-icon-button life-review-button"
|
||||||
@@ -889,7 +973,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
{{ comment.author.displayName }}
|
{{ comment.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||||
@@ -901,7 +985,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
<p class="life-comment__body">{{ comment.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -910,9 +994,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ comment.moderationReason }}</span>
|
<span>{{ comment.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
v-if="canCommentOnPost"
|
v-if="!comment.deleted && canCommentOnPost"
|
||||||
class="life-icon-button life-icon-button--flat"
|
class="life-icon-button life-icon-button--flat"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.reply')"
|
:aria-label="t('pages.life.reply')"
|
||||||
@@ -931,6 +1015,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(comment.id))"
|
||||||
|
@click="restoreComment(comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -977,7 +1072,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
{{ reply.author.displayName }}
|
{{ reply.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||||
@@ -989,7 +1084,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
<p class="life-comment__body">{{ reply.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -997,8 +1092,9 @@ 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)" class="life-comment__actions">
|
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
|
||||||
<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('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@@ -1007,6 +1103,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(reply.id))"
|
||||||
|
@click="restoreComment(reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<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)] }}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
iconReply,
|
iconReply,
|
||||||
iconSave,
|
iconSave,
|
||||||
iconSearch,
|
iconSearch,
|
||||||
|
iconUndo,
|
||||||
iconVersion,
|
iconVersion,
|
||||||
iconWarning
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
@@ -478,6 +479,10 @@ function canManageComment(comment: LifeComment) {
|
|||||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRestoreComment(comment: LifeComment) {
|
||||||
|
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
|
||||||
|
}
|
||||||
|
|
||||||
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'));
|
||||||
}
|
}
|
||||||
@@ -718,6 +723,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
|||||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceCommentInTree(comments: LifeComment[], updated: LifeComment): boolean {
|
||||||
|
for (let index = 0; index < comments.length; index += 1) {
|
||||||
|
const comment = comments[index];
|
||||||
|
if (!comment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (comment.id === updated.id) {
|
||||||
|
comments[index] = { ...updated, replies: comment.replies };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceCommentInTree(comment.replies, updated)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadComments(post: LifePost, reset = false) {
|
async function loadComments(post: LifePost, reset = false) {
|
||||||
const existing = commentPage(post);
|
const existing = commentPage(post);
|
||||||
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
||||||
@@ -778,7 +802,7 @@ function isRatingBusy(postId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commentAuthorName(comment: LifeComment) {
|
function commentAuthorName(comment: LifeComment) {
|
||||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
return comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentInitial(comment: LifeComment) {
|
function commentInitial(comment: LifeComment) {
|
||||||
@@ -1048,6 +1072,21 @@ function removeCommentFromTree(comments: LifeComment[], id: number): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
|
||||||
|
for (const comment of comments) {
|
||||||
|
if (comment.id === id) {
|
||||||
|
comment.deleted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markOwnCommentDeleted(comment.replies, id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteComment(post: LifePost, comment: LifeComment) {
|
async function deleteComment(post: LifePost, comment: LifeComment) {
|
||||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
||||||
return;
|
return;
|
||||||
@@ -1058,6 +1097,13 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
await api.deleteLifeComment(comment.id);
|
||||||
|
if (currentUser.value?.id === comment.author?.id) {
|
||||||
|
markOwnCommentDeleted(commentsForPost(post), comment.id);
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: [...page.items]
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
const removedCount = removeCommentFromTree(commentsForPost(post), comment.id);
|
const removedCount = removeCommentFromTree(commentsForPost(post), comment.id);
|
||||||
if (removedCount > 0) {
|
if (removedCount > 0) {
|
||||||
const nextTotal = Math.max(0, commentCount(post) - removedCount);
|
const nextTotal = Math.max(0, commentCount(post) - removedCount);
|
||||||
@@ -1068,6 +1114,7 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
total: nextTotal
|
total: nextTotal
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (replyTargetId.value === comment.id) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1123,25 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreComment(post: LifePost, comment: LifeComment) {
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restored = await api.restoreLifeComment(comment.id);
|
||||||
|
replaceCommentInTree(commentsForPost(post), restored);
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: [...page.items]
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
|
||||||
|
} 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())) {
|
||||||
@@ -1599,7 +1665,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
{{ comment.author.displayName }}
|
{{ comment.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||||
@@ -1611,7 +1677,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
<p class="life-comment__body">{{ comment.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -1620,9 +1686,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ comment.moderationReason }}</span>
|
<span>{{ comment.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
v-if="canComment"
|
v-if="!comment.deleted && canComment"
|
||||||
class="life-icon-button life-icon-button--flat"
|
class="life-icon-button life-icon-button--flat"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.reply')"
|
:aria-label="t('pages.life.reply')"
|
||||||
@@ -1641,6 +1707,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(comment.id))"
|
||||||
|
@click="restoreComment(post, comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
@@ -1687,7 +1764,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
{{ reply.author.displayName }}
|
{{ reply.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||||
@@ -1699,7 +1776,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
<p class="life-comment__body">{{ reply.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -1707,8 +1784,9 @@ 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)" class="life-comment__actions">
|
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
|
||||||
<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('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@@ -1717,6 +1795,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(reply.id))"
|
||||||
|
@click="restoreComment(post, reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<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)] }}
|
||||||
|
|||||||
@@ -909,10 +909,12 @@ export const systemWordingMessages = {
|
|||||||
deleteComment: 'Delete comment',
|
deleteComment: 'Delete comment',
|
||||||
deleteCommentConfirm: 'Delete this comment?',
|
deleteCommentConfirm: 'Delete this comment?',
|
||||||
commentDeleted: 'Comment deleted',
|
commentDeleted: 'Comment deleted',
|
||||||
|
restoreComment: 'Undo',
|
||||||
commentRequired: 'Please enter a comment.',
|
commentRequired: 'Please enter a comment.',
|
||||||
commentFailed: 'Comment failed',
|
commentFailed: 'Comment failed',
|
||||||
replyFailed: 'Reply failed',
|
replyFailed: 'Reply failed',
|
||||||
deleteCommentFailed: 'Delete comment failed',
|
deleteCommentFailed: 'Delete comment failed',
|
||||||
|
restoreCommentFailed: 'Undo failed',
|
||||||
publish: 'Post',
|
publish: 'Post',
|
||||||
publishing: 'Posting',
|
publishing: 'Posting',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
@@ -2186,10 +2188,12 @@ export const systemWordingMessages = {
|
|||||||
deleteComment: '删除评论',
|
deleteComment: '删除评论',
|
||||||
deleteCommentConfirm: '确认删除这条评论?',
|
deleteCommentConfirm: '确认删除这条评论?',
|
||||||
commentDeleted: '评论已删除',
|
commentDeleted: '评论已删除',
|
||||||
|
restoreComment: '撤销',
|
||||||
commentRequired: '请输入评论内容。',
|
commentRequired: '请输入评论内容。',
|
||||||
commentFailed: '评论失败',
|
commentFailed: '评论失败',
|
||||||
replyFailed: '回复失败',
|
replyFailed: '回复失败',
|
||||||
deleteCommentFailed: '删除评论失败',
|
deleteCommentFailed: '删除评论失败',
|
||||||
|
restoreCommentFailed: '撤销失败',
|
||||||
publish: '发布',
|
publish: '发布',
|
||||||
publishing: '发布中',
|
publishing: '发布中',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
|
|||||||
Reference in New Issue
Block a user