feat(life): add comments and replies to life posts
Introduce life_post_comments table for nested comment threads Add API endpoints to create, reply to, and delete comments Implement frontend UI with engagement counts and collapsible threads
This commit is contained in:
@@ -229,6 +229,26 @@ const messages = {
|
||||
composerPrompt: 'What would you like to share?',
|
||||
bodyLabel: 'Post',
|
||||
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
||||
comments: 'Comments',
|
||||
commentsCount: '{count} comments',
|
||||
comment: 'Comment',
|
||||
hideComments: 'Hide comments',
|
||||
commentPlaceholder: 'Write a comment...',
|
||||
commentReplyPlaceholder: 'Write a reply...',
|
||||
postComment: 'Post comment',
|
||||
postingComment: 'Posting comment',
|
||||
reply: 'Reply',
|
||||
postReply: 'Post reply',
|
||||
postingReply: 'Posting reply',
|
||||
cancelReply: 'Cancel reply',
|
||||
noComments: 'No comments yet',
|
||||
deleteComment: 'Delete comment',
|
||||
deleteCommentConfirm: 'Delete this comment?',
|
||||
commentDeleted: 'Comment deleted',
|
||||
commentRequired: 'Please enter a comment.',
|
||||
commentFailed: 'Comment failed',
|
||||
replyFailed: 'Reply failed',
|
||||
deleteCommentFailed: 'Delete comment failed',
|
||||
publish: 'Post',
|
||||
publishing: 'Posting',
|
||||
update: 'Update',
|
||||
@@ -542,6 +562,26 @@ const messages = {
|
||||
composerPrompt: '想分享什么?',
|
||||
bodyLabel: '动态内容',
|
||||
bodyPlaceholder: '分享一段想法、心得或发现……',
|
||||
comments: '评论',
|
||||
commentsCount: '{count} 条评论',
|
||||
comment: '评论',
|
||||
hideComments: '收起评论',
|
||||
commentPlaceholder: '写下评论……',
|
||||
commentReplyPlaceholder: '写下回复……',
|
||||
postComment: '发表评论',
|
||||
postingComment: '评论中',
|
||||
reply: '回复',
|
||||
postReply: '发布回复',
|
||||
postingReply: '回复中',
|
||||
cancelReply: '取消回复',
|
||||
noComments: '暂无评论',
|
||||
deleteComment: '删除评论',
|
||||
deleteCommentConfirm: '确认删除这条评论?',
|
||||
commentDeleted: '评论已删除',
|
||||
commentRequired: '请输入评论内容。',
|
||||
commentFailed: '评论失败',
|
||||
replyFailed: '回复失败',
|
||||
deleteCommentFailed: '删除评论失败',
|
||||
publish: '发布',
|
||||
publishing: '发布中',
|
||||
update: '更新',
|
||||
|
||||
@@ -8,6 +8,7 @@ export const iconCheck: AppIcon = 'mdi:check';
|
||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||
export const iconClose: AppIcon = 'mdi:close';
|
||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||
export const iconDragHandle: AppIcon = 'mdi:drag';
|
||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||
@@ -23,6 +24,7 @@ export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||
|
||||
@@ -180,6 +180,19 @@ export interface LifePost {
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
updatedBy: UserSummary | null;
|
||||
comments: LifeComment[];
|
||||
}
|
||||
|
||||
export interface LifeComment {
|
||||
id: number;
|
||||
postId: number;
|
||||
parentCommentId: number | null;
|
||||
body: string;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
replies: LifeComment[];
|
||||
}
|
||||
|
||||
export interface RecipeDetail extends Recipe {
|
||||
@@ -288,6 +301,10 @@ export interface LifePostPayload {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface LifeCommentPayload {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -422,6 +439,11 @@ export const api = {
|
||||
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
||||
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||
|
||||
@@ -1316,6 +1316,212 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-post__engagement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.life-post__engagement-button,
|
||||
.life-post__comment-count {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 9px;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.life-post__engagement-button:hover,
|
||||
.life-post__comment-count:hover,
|
||||
.life-post__engagement-button[aria-expanded="true"] {
|
||||
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface-soft));
|
||||
color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.life-post__engagement-button .ui-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.life-post__comment-count {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.life-comments {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.life-comments__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.life-comments__header h3 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comments__header span {
|
||||
min-width: 32px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.life-comment-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-comment-form textarea {
|
||||
min-height: 78px;
|
||||
}
|
||||
|
||||
.life-comment-form--reply {
|
||||
margin-top: 8px;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid color-mix(in srgb, var(--pokemon-blue) 34%, var(--line));
|
||||
}
|
||||
|
||||
.life-comment-list,
|
||||
.life-comment-replies {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.life-comment {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.life-comment__main,
|
||||
.life-comment--reply {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.life-comment-replies {
|
||||
margin-top: 10px;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.life-comment__avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-soft);
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comment.is-deleted .life-comment__avatar {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.life-comment__content {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.life-comment__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-comment__meta strong {
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comment.is-deleted .life-comment__meta strong {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.life-comment__meta time {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.life-comment__body {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.life-comment.is-deleted .life-comment__body,
|
||||
.life-comments__empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.life-comment__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-comment__link-button {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 0;
|
||||
background: transparent;
|
||||
color: var(--pokemon-blue);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.life-comment__link-button:hover {
|
||||
color: var(--pokemon-blue-deep);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.life-comment__link-button .ui-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.life-comments__empty {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reorderable-row {
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
@@ -2490,6 +2696,15 @@ button:disabled,
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.life-comment-replies {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.life-comment__main,
|
||||
.life-comment--reply {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.appearance-list li {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconCancel, iconDelete, iconEdit, iconLife, iconSave } from '../icons';
|
||||
import { iconCancel, iconComment, iconDelete, iconEdit, iconLife, iconReply, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
type AuthUser,
|
||||
type LifeComment,
|
||||
type LifePost
|
||||
} from '../services/api';
|
||||
|
||||
@@ -25,6 +26,12 @@ const body = ref('');
|
||||
const editingPostId = ref<number | null>(null);
|
||||
const formError = ref('');
|
||||
const loadError = ref('');
|
||||
const commentBodies = ref<Record<number, string>>({});
|
||||
const replyBodies = ref<Record<number, string>>({});
|
||||
const replyTargetId = ref<number | null>(null);
|
||||
const expandedComments = ref<Record<number, boolean>>({});
|
||||
const commentBusyKey = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const skeletonPostCount = 3;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
@@ -117,6 +124,60 @@ function canManage(post: LifePost) {
|
||||
return currentUser.value?.id === post.author?.id;
|
||||
}
|
||||
|
||||
function canManageComment(comment: LifeComment) {
|
||||
return !comment.deleted && currentUser.value?.id === comment.author?.id;
|
||||
}
|
||||
|
||||
function commentKey(postId: number) {
|
||||
return `post-${postId}`;
|
||||
}
|
||||
|
||||
function replyKey(commentId: number) {
|
||||
return `reply-${commentId}`;
|
||||
}
|
||||
|
||||
function commentCount(post: LifePost) {
|
||||
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
|
||||
}
|
||||
|
||||
function areCommentsExpanded(postId: number) {
|
||||
return expandedComments.value[postId] === true;
|
||||
}
|
||||
|
||||
function setCommentsExpanded(postId: number, expanded: boolean) {
|
||||
expandedComments.value = {
|
||||
...expandedComments.value,
|
||||
[postId]: expanded
|
||||
};
|
||||
}
|
||||
|
||||
function toggleComments(postId: number) {
|
||||
setCommentsExpanded(postId, !areCommentsExpanded(postId));
|
||||
}
|
||||
|
||||
function isCommentBusy(key: string) {
|
||||
return commentBusyKey.value === key;
|
||||
}
|
||||
|
||||
function commentAuthorName(comment: LifeComment) {
|
||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||
}
|
||||
|
||||
function commentInitial(comment: LifeComment) {
|
||||
const name = commentAuthorName(comment);
|
||||
return name.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function setCommentError(key: string, message: string) {
|
||||
commentErrors.value = { ...commentErrors.value, [key]: message };
|
||||
}
|
||||
|
||||
function clearCommentError(key: string) {
|
||||
const nextErrors = { ...commentErrors.value };
|
||||
delete nextErrors[key];
|
||||
commentErrors.value = nextErrors;
|
||||
}
|
||||
|
||||
function startEdit(post: LifePost) {
|
||||
editingPostId.value = post.id;
|
||||
body.value = post.body;
|
||||
@@ -142,6 +203,99 @@ async function deletePost(post: LifePost) {
|
||||
}
|
||||
}
|
||||
|
||||
function startReply(comment: LifeComment) {
|
||||
replyTargetId.value = comment.id;
|
||||
clearCommentError(replyKey(comment.id));
|
||||
}
|
||||
|
||||
function cancelReply(commentId: number) {
|
||||
replyTargetId.value = null;
|
||||
replyBodies.value[commentId] = '';
|
||||
clearCommentError(replyKey(commentId));
|
||||
}
|
||||
|
||||
async function submitComment(post: LifePost) {
|
||||
const key = commentKey(post.id);
|
||||
const nextBody = (commentBodies.value[post.id] ?? '').trim();
|
||||
if (!nextBody) {
|
||||
setCommentError(key, t('pages.life.commentRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
commentBusyKey.value = key;
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const comment = await api.createLifeComment(post.id, { body: nextBody });
|
||||
post.comments.push(comment);
|
||||
commentBodies.value[post.id] = '';
|
||||
setCommentsExpanded(post.id, true);
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||
} finally {
|
||||
commentBusyKey.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReply(post: LifePost, comment: LifeComment) {
|
||||
const key = replyKey(comment.id);
|
||||
const nextBody = (replyBodies.value[comment.id] ?? '').trim();
|
||||
if (!nextBody) {
|
||||
setCommentError(key, t('pages.life.commentRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
commentBusyKey.value = key;
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
|
||||
comment.replies.push(reply);
|
||||
setCommentsExpanded(post.id, true);
|
||||
cancelReply(comment.id);
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||
} finally {
|
||||
commentBusyKey.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function markCommentDeleted(comments: LifeComment[], id: number): boolean {
|
||||
for (const comment of comments) {
|
||||
if (comment.id === id) {
|
||||
comment.deleted = true;
|
||||
comment.body = '';
|
||||
comment.author = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (markCommentDeleted(comment.replies, id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function deleteComment(post: LifePost, comment: LifeComment) {
|
||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = replyKey(comment.id);
|
||||
clearCommentError(key);
|
||||
|
||||
try {
|
||||
await api.deleteLifeComment(comment.id);
|
||||
markCommentDeleted(post.comments, comment.id);
|
||||
if (replyTargetId.value === comment.id) {
|
||||
cancelReply(comment.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.deleteCommentFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function formatPostTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -268,6 +422,162 @@ onUnmounted(() => {
|
||||
</header>
|
||||
|
||||
<p class="life-post__body">{{ post.body }}</p>
|
||||
|
||||
<div class="life-post__engagement">
|
||||
<button
|
||||
class="life-post__engagement-button"
|
||||
type="button"
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
@click="toggleComments(post.id)"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
|
||||
</button>
|
||||
<button
|
||||
class="life-post__comment-count"
|
||||
type="button"
|
||||
:aria-controls="`life-comments-${post.id}`"
|
||||
:aria-expanded="areCommentsExpanded(post.id)"
|
||||
@click="toggleComments(post.id)"
|
||||
>
|
||||
{{ t('pages.life.commentsCount', { count: commentCount(post) }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="areCommentsExpanded(post.id)"
|
||||
: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>{{ commentCount(post) }}</span>
|
||||
</div>
|
||||
|
||||
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
<div class="field">
|
||||
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
|
||||
<textarea
|
||||
:id="`life-comment-${post.id}`"
|
||||
v-model="commentBodies[post.id]"
|
||||
maxlength="1000"
|
||||
:placeholder="t('pages.life.commentPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
<p v-if="commentErrors[commentKey(post.id)]" class="life-form__error" role="alert">
|
||||
{{ commentErrors[commentKey(post.id)] }}
|
||||
</p>
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
:disabled="isCommentBusy(commentKey(post.id)) || !(commentBodies[post.id] ?? '').trim()"
|
||||
type="submit"
|
||||
>
|
||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
||||
{{ isCommentBusy(commentKey(post.id)) ? t('pages.life.postingComment') : t('pages.life.postComment') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="post.comments.length" class="life-comment-list">
|
||||
<article
|
||||
v-for="comment in post.comments"
|
||||
:key="comment.id"
|
||||
class="life-comment"
|
||||
:class="{ 'is-deleted': comment.deleted }"
|
||||
>
|
||||
<div class="life-comment__main">
|
||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||
<div class="life-comment__content">
|
||||
<div class="life-comment__meta">
|
||||
<strong>{{ commentAuthorName(comment) }}</strong>
|
||||
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
||||
|
||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
||||
<button v-if="canPost" class="life-comment__link-button" type="button" @click="startReply(comment)">
|
||||
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.reply') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageComment(comment)"
|
||||
class="life-comment__link-button"
|
||||
type="button"
|
||||
@click="deleteComment(post, comment)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.deleteComment') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
||||
{{ commentErrors[replyKey(comment.id)] }}
|
||||
</p>
|
||||
|
||||
<form
|
||||
v-if="canPost && replyTargetId === comment.id"
|
||||
class="life-comment-form life-comment-form--reply"
|
||||
@submit.prevent="submitReply(post, comment)"
|
||||
>
|
||||
<div class="field">
|
||||
<label :for="`life-reply-${comment.id}`">{{ t('pages.life.reply') }}</label>
|
||||
<textarea
|
||||
:id="`life-reply-${comment.id}`"
|
||||
v-model="replyBodies[comment.id]"
|
||||
maxlength="1000"
|
||||
:placeholder="t('pages.life.commentReplyPlaceholder')"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="life-form__actions">
|
||||
<button
|
||||
class="ui-button ui-button--ghost ui-button--small"
|
||||
:disabled="isCommentBusy(replyKey(comment.id)) || !(replyBodies[comment.id] ?? '').trim()"
|
||||
type="submit"
|
||||
>
|
||||
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
|
||||
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.postingReply') : t('pages.life.postReply') }}
|
||||
</button>
|
||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.cancelReply') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="comment.replies.length" class="life-comment-replies">
|
||||
<article
|
||||
v-for="reply in comment.replies"
|
||||
:key="reply.id"
|
||||
class="life-comment life-comment--reply"
|
||||
:class="{ 'is-deleted': reply.deleted }"
|
||||
>
|
||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||
<div class="life-comment__content">
|
||||
<div class="life-comment__meta">
|
||||
<strong>{{ commentAuthorName(reply) }}</strong>
|
||||
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
|
||||
</div>
|
||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
||||
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
||||
<button class="life-comment__link-button" type="button" @click="deleteComment(post, reply)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.life.deleteComment') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
||||
{{ commentErrors[replyKey(reply.id)] }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user