388 lines
12 KiB
Vue
388 lines
12 KiB
Vue
<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">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="comment-action-button"
|
|
type="button"
|
|
title="Add reaction"
|
|
>
|
|
<v-icon :icon="mdiEmoticonPlusOutline" />
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="comment-action-button"
|
|
type="button"
|
|
title="Resolve"
|
|
>
|
|
<v-icon :icon="mdiCheckCircleOutline" />
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="comment-action-button"
|
|
type="button"
|
|
title="Reply"
|
|
@click="activeReplyCommentId = thread.comment.id"
|
|
>
|
|
<v-icon :icon="mdiReplyOutline" />
|
|
</v-btn>
|
|
<details class="comment-more-menu">
|
|
<summary
|
|
class="comment-action-button"
|
|
title="More comment actions"
|
|
>
|
|
<v-icon :icon="mdiDotsVertical" />
|
|
</summary>
|
|
<div class="comment-action-menu">
|
|
<v-btn variant="text" :ripple="false"
|
|
class="comment-menu-item"
|
|
type="button"
|
|
>
|
|
<v-icon :icon="mdiPencilOutline" />
|
|
Edit
|
|
</v-btn>
|
|
<v-btn variant="text" :ripple="false"
|
|
class="comment-menu-item danger"
|
|
type="button"
|
|
>
|
|
<v-icon :icon="mdiDeleteOutline" />
|
|
Delete
|
|
</v-btn>
|
|
</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>
|
|
@reference "@/assets/main.css";
|
|
.timeline-list {
|
|
@apply flex flex-col gap-4;
|
|
}
|
|
|
|
.empty-note {
|
|
@apply text-sm leading-6;
|
|
color: var(--app-text-muted);
|
|
}
|
|
|
|
.comment-row {
|
|
@apply relative flex flex-col gap-3 rounded-[1rem] border p-4 transition;
|
|
background: #f8fafc;
|
|
border-color: var(--app-border-subtle);
|
|
outline: none;
|
|
}
|
|
|
|
.comment-row:hover,
|
|
.comment-row:focus-within,
|
|
.comment-row:focus {
|
|
background: var(--app-color-surface);
|
|
border-color: rgba(15, 118, 110, 0.24);
|
|
box-shadow: 0 16px 34px var(--app-border-subtle);
|
|
}
|
|
|
|
.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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.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: var(--app-border-subtle);
|
|
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: var(--app-text-muted);
|
|
}
|
|
|
|
.comment-action-button:hover,
|
|
.comment-action-button:focus-visible {
|
|
background: rgba(15, 118, 110, 0.12);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.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: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.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: var(--app-control-active);
|
|
}
|
|
|
|
.comment-menu-item {
|
|
@apply flex items-center gap-2 rounded-[0.625rem] px-3 py-2 text-sm font-semibold transition;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.comment-menu-item:hover,
|
|
.comment-menu-item:focus-visible {
|
|
background: rgba(15, 118, 110, 0.1);
|
|
color: var(--app-color-on-tertiary);
|
|
}
|
|
|
|
.comment-menu-item.danger {
|
|
color: var(--app-danger-muted);
|
|
}
|
|
|
|
.comment-menu-item.danger:hover,
|
|
.comment-menu-item.danger:focus-visible {
|
|
background: rgba(185, 28, 28, 0.1);
|
|
color: var(--app-danger-muted);
|
|
}
|
|
|
|
.comment-body {
|
|
@apply whitespace-pre-line text-sm leading-6;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
|
|
.comment-attachment {
|
|
@apply block w-fit max-w-full overflow-hidden rounded-[0.875rem] border;
|
|
border-color: var(--app-control-active);
|
|
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: var(--app-color-on-surface);
|
|
}
|
|
|
|
.reply-meta small {
|
|
@apply text-xs leading-5;
|
|
color: #7c8798;
|
|
}
|
|
|
|
.reply-row p {
|
|
@apply whitespace-pre-line text-sm leading-6;
|
|
color: var(--app-color-on-surface);
|
|
}
|
|
</style>
|