feat: refine content calendar experience
This commit is contained in:
386
frontend/src/features/content/components/ContentCommentFeed.vue
Normal file
386
frontend/src/features/content/components/ContentCommentFeed.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
mdiCheckCircleOutline,
|
||||
mdiDeleteOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEmoticonPlusOutline,
|
||||
mdiPencilOutline,
|
||||
mdiReplyOutline,
|
||||
} from '@mdi/js';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ContentCommentComposer from '@/features/content/components/ContentCommentComposer.vue';
|
||||
|
||||
const props = defineProps({
|
||||
comments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
members: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isPosting: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit-comment']);
|
||||
const activeReplyCommentId = ref(null);
|
||||
const commentThreads = computed(() => {
|
||||
const repliesByParentId = new Map();
|
||||
const roots = [];
|
||||
|
||||
for (const comment of props.comments) {
|
||||
if (comment.parentCommentId) {
|
||||
const existing = repliesByParentId.get(comment.parentCommentId) ?? [];
|
||||
existing.push(comment);
|
||||
repliesByParentId.set(comment.parentCommentId, existing);
|
||||
} else {
|
||||
roots.push(comment);
|
||||
}
|
||||
}
|
||||
|
||||
return roots.map(comment => ({
|
||||
comment,
|
||||
replies: repliesByParentId.get(comment.id) ?? [],
|
||||
}));
|
||||
});
|
||||
|
||||
function formatDateTime(value) {
|
||||
return value ? new Date(value).toLocaleString() : '';
|
||||
}
|
||||
|
||||
function hasImageAttachment(comment) {
|
||||
return Boolean(comment.attachmentBlobUrl && comment.attachmentContentType?.startsWith('image/'));
|
||||
}
|
||||
|
||||
function submitReply(payload) {
|
||||
emit('submit-comment', payload);
|
||||
activeReplyCommentId.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="timeline-list">
|
||||
<article
|
||||
v-for="thread in commentThreads"
|
||||
:key="thread.comment.id"
|
||||
class="comment-row"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="comment-row-header">
|
||||
<div class="comment-author">
|
||||
<AppAvatar
|
||||
:name="thread.comment.authorDisplayName"
|
||||
:email="thread.comment.authorEmail"
|
||||
:src="thread.comment.authorPortraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="comment-author-meta">
|
||||
<strong>{{ thread.comment.authorDisplayName }}</strong>
|
||||
<small>{{ formatDateTime(thread.comment.createdAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-actions">
|
||||
<button
|
||||
class="comment-action-button"
|
||||
type="button"
|
||||
title="Add reaction"
|
||||
>
|
||||
<v-icon :icon="mdiEmoticonPlusOutline" />
|
||||
</button>
|
||||
<button
|
||||
class="comment-action-button"
|
||||
type="button"
|
||||
title="Resolve"
|
||||
>
|
||||
<v-icon :icon="mdiCheckCircleOutline" />
|
||||
</button>
|
||||
<button
|
||||
class="comment-action-button"
|
||||
type="button"
|
||||
title="Reply"
|
||||
@click="activeReplyCommentId = thread.comment.id"
|
||||
>
|
||||
<v-icon :icon="mdiReplyOutline" />
|
||||
</button>
|
||||
<details class="comment-more-menu">
|
||||
<summary
|
||||
class="comment-action-button"
|
||||
title="More comment actions"
|
||||
>
|
||||
<v-icon :icon="mdiDotsVertical" />
|
||||
</summary>
|
||||
<div class="comment-action-menu">
|
||||
<button
|
||||
class="comment-menu-item"
|
||||
type="button"
|
||||
>
|
||||
<v-icon :icon="mdiPencilOutline" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="comment-menu-item danger"
|
||||
type="button"
|
||||
>
|
||||
<v-icon :icon="mdiDeleteOutline" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="thread.comment.body"
|
||||
class="comment-body"
|
||||
>
|
||||
{{ thread.comment.body }}
|
||||
</p>
|
||||
|
||||
<a
|
||||
v-if="hasImageAttachment(thread.comment)"
|
||||
class="comment-attachment"
|
||||
:href="thread.comment.attachmentBlobUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
:src="thread.comment.attachmentBlobUrl"
|
||||
:alt="thread.comment.attachmentFileName || 'Comment attachment'"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="thread.replies.length"
|
||||
class="reply-list"
|
||||
>
|
||||
<article
|
||||
v-for="reply in thread.replies"
|
||||
:key="reply.id"
|
||||
class="reply-row"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="reply.authorDisplayName"
|
||||
:email="reply.authorEmail"
|
||||
:src="reply.authorPortraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<div class="reply-meta">
|
||||
<strong>{{ reply.authorDisplayName }}</strong>
|
||||
<small>{{ formatDateTime(reply.createdAt) }}</small>
|
||||
</div>
|
||||
<p v-if="reply.body">{{ reply.body }}</p>
|
||||
<a
|
||||
v-if="hasImageAttachment(reply)"
|
||||
class="comment-attachment reply-attachment"
|
||||
:href="reply.attachmentBlobUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
:src="reply.attachmentBlobUrl"
|
||||
:alt="reply.attachmentFileName || 'Reply attachment'"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ContentCommentComposer
|
||||
v-if="activeReplyCommentId === thread.comment.id"
|
||||
variant="reply"
|
||||
:members="members"
|
||||
:is-posting="isPosting"
|
||||
:reply-target="thread.comment"
|
||||
@submit-comment="submitReply"
|
||||
@cancel-reply="activeReplyCommentId = null"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<div
|
||||
v-if="!commentThreads.length"
|
||||
class="empty-note"
|
||||
>
|
||||
No comments yet.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline-list {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.empty-note {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.comment-row {
|
||||
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.comment-row:hover,
|
||||
.comment-row:focus-within,
|
||||
.comment-row:focus {
|
||||
background: #fffdf8;
|
||||
border-color: rgba(15, 118, 110, 0.24);
|
||||
box-shadow: 0 16px 34px rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.comment-row-header {
|
||||
@apply flex min-h-9 w-full items-center;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
@apply flex w-full min-w-0 items-center gap-3;
|
||||
}
|
||||
|
||||
.comment-author-meta {
|
||||
@apply flex w-full min-w-0 flex-col;
|
||||
}
|
||||
|
||||
.comment-author strong {
|
||||
@apply truncate text-sm;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.comment-author small {
|
||||
@apply text-xs leading-5;
|
||||
color: #7c8798;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
@apply absolute right-3 top-3 z-20 flex items-center gap-1 rounded-full border px-1 py-1 opacity-0 shadow-lg transition;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.comment-row:hover .comment-actions,
|
||||
.comment-row:focus-within .comment-actions,
|
||||
.comment-row:focus .comment-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.comment-action-button {
|
||||
@apply inline-flex h-8 w-8 items-center justify-center rounded-full transition;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.comment-action-button:hover,
|
||||
.comment-action-button:focus-visible {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.comment-more-menu {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.comment-more-menu summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.comment-more-menu summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment-more-menu[open] .comment-action-button {
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.comment-more-menu[open] .comment-action-menu,
|
||||
.comment-more-menu:hover .comment-action-menu,
|
||||
.comment-more-menu:focus-within .comment-action-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.comment-action-menu {
|
||||
@apply absolute right-0 top-[calc(100%+0.375rem)] z-20 hidden min-w-36 flex-col gap-1 rounded-[0.875rem] border p-1 shadow-xl;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
}
|
||||
|
||||
.comment-menu-item {
|
||||
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.comment-menu-item:hover,
|
||||
.comment-menu-item:focus-visible {
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.comment-menu-item.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.comment-menu-item.danger:hover,
|
||||
.comment-menu-item.danger:focus-visible {
|
||||
background: rgba(185, 28, 28, 0.1);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
@apply whitespace-pre-line text-sm leading-6;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.comment-attachment {
|
||||
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.comment-attachment img {
|
||||
@apply block max-h-72 max-w-full object-contain;
|
||||
}
|
||||
|
||||
.reply-attachment img {
|
||||
@apply max-h-56;
|
||||
}
|
||||
|
||||
.reply-list {
|
||||
@apply ml-2 mt-2 flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.reply-row {
|
||||
@apply flex items-start gap-3;
|
||||
}
|
||||
|
||||
.reply-row > div {
|
||||
@apply flex min-w-0 flex-col gap-1;
|
||||
}
|
||||
|
||||
.reply-meta {
|
||||
@apply flex min-w-0 flex-col;
|
||||
}
|
||||
|
||||
.reply-meta strong {
|
||||
@apply truncate text-sm;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.reply-meta small {
|
||||
@apply text-xs leading-5;
|
||||
color: #7c8798;
|
||||
}
|
||||
|
||||
.reply-row p {
|
||||
@apply whitespace-pre-line text-sm leading-6;
|
||||
color: #172033;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user