357 lines
11 KiB
Vue
357 lines
11 KiB
Vue
<template>
|
|
<div class="mt-4 lg:mt-6">
|
|
<UCard variant="subtle" :ui="{ body: 'p-4 sm:p-5', header: 'p-4 sm:p-5', footer: 'p-4 sm:p-5' }">
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-highlighted">
|
|
{{ $t('comments.title') }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
{{ $t('comments.count', { count: commentCount }) }}
|
|
</p>
|
|
</div>
|
|
<UButton color="neutral" variant="ghost" size="sm" icon="i-lucide-x" @click="$emit('close')" />
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="comments && comments.length > 0" class="relative">
|
|
<UProgress
|
|
v-if="isLoadingMore"
|
|
indeterminate
|
|
size="xs"
|
|
class="absolute top-0 inset-x-0 z-10"
|
|
:ui="{ base: 'bg-default' }"
|
|
/>
|
|
|
|
<UScrollArea ref="scrollAreaRef" class="max-h-96" :ui="{ viewport: 'space-y-4 pe-2' }">
|
|
<div v-for="comment in comments" :key="comment.id" class="space-y-2">
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<UAvatar icon="i-lucide-user" size="2xs" />
|
|
<div class="min-w-0">
|
|
<p class="text-sm text-highlighted truncate">
|
|
{{ comment.createdBy.name }}
|
|
</p>
|
|
<p class="text-xs text-muted">
|
|
{{ comment.createdAt ? formatDate(comment.createdAt) : '-' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<UDropdownMenu
|
|
v-if="isCommentByUser(comment)"
|
|
:items="[createCommentActions(comment)]"
|
|
:content="{ align: 'end' }"
|
|
>
|
|
<UButton icon="i-lucide-ellipsis" color="neutral" variant="ghost" size="xs" square />
|
|
</UDropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
|
|
<!-- Edit comment form -->
|
|
<template v-if="editingCommentId === comment.id">
|
|
<UEditor
|
|
v-slot="{ editor }"
|
|
v-model="editingCommentEditorContent"
|
|
content-type="json"
|
|
:editable="true"
|
|
:ui="{
|
|
root: 'bg-transparent',
|
|
content: 'bg-transparent',
|
|
base: 'min-h-[120px] p-3 bg-transparent'
|
|
}"
|
|
>
|
|
<UEditorToolbar
|
|
:editor="editor"
|
|
:items="toolbarItems"
|
|
class="border-b border-default px-3 py-2 bg-default/50 overflow-x-auto"
|
|
/>
|
|
</UEditor>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end p-3 border-t border-default">
|
|
<UButton color="neutral" variant="outline" @click="cancelEditComment">
|
|
{{ $t('common.cancel') }}
|
|
</UButton>
|
|
<UButton @click="saveEditedComment(comment)">
|
|
{{ $t('comments.saveChanges') }}
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Display comment content when not editing -->
|
|
<UEditor
|
|
v-else
|
|
:key="`${comment.id}:${comment.modifiedAt?.toISOString?.() ?? ''}`"
|
|
:model-value="getCommentEditorModelValue(comment)"
|
|
content-type="json"
|
|
:editable="false"
|
|
:ui="{
|
|
root: 'bg-transparent',
|
|
content: 'bg-transparent',
|
|
base: 'p-3 bg-transparent'
|
|
}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</UScrollArea>
|
|
</div>
|
|
|
|
<UEmpty v-else :title="$t('comments.empty')" icon="i-lucide-message-square" class="py-6" />
|
|
|
|
<template #footer>
|
|
<div class="space-y-3">
|
|
<div class="rounded-md ring-1 ring-inset ring-default bg-default">
|
|
<UEditor
|
|
v-slot="{ editor }"
|
|
v-model="newCommentEditorContent"
|
|
content-type="json"
|
|
:editable="true"
|
|
:placeholder="$t('comments.placeholder')"
|
|
:ui="{
|
|
root: 'bg-transparent',
|
|
content: 'bg-transparent',
|
|
base: 'min-h-[120px] p-3 bg-transparent'
|
|
}"
|
|
>
|
|
<UEditorToolbar
|
|
:editor="editor"
|
|
:items="toolbarItems"
|
|
class="border-b border-default px-3 py-2 bg-transparent overflow-x-auto"
|
|
/>
|
|
</UEditor>
|
|
</div>
|
|
|
|
<div class="flex flex-col sm:flex-row gap-2 sm:justify-end">
|
|
<UButton @click="submitComment(formElementId)">
|
|
{{ $t('comments.submit') }}
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { CommentDto } from '~~/.api-client'
|
|
import type { JSONContent } from '@tiptap/vue-3'
|
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
import { useInfiniteScroll } from '@vueuse/core'
|
|
import { useCommentStore } from '~~/stores/useCommentStore'
|
|
import { useUserStore } from '~~/stores/useUserStore'
|
|
|
|
const props = defineProps<{
|
|
formElementId: string
|
|
applicationFormId: string
|
|
comments?: CommentDto[]
|
|
totalCount?: number
|
|
}>()
|
|
|
|
defineEmits<{
|
|
(e: 'close'): void
|
|
}>()
|
|
|
|
const commentStore = useCommentStore()
|
|
const { loadMore } = commentStore
|
|
const userStore = useUserStore()
|
|
const { user } = storeToRefs(userStore)
|
|
const toast = useToast()
|
|
const { t: $t } = useI18n()
|
|
|
|
const scrollAreaRef = useTemplateRef('scrollAreaRef')
|
|
const scrollContainerEl = ref<HTMLElement | null>(null)
|
|
|
|
const commentCount = computed(() => props.totalCount ?? props.comments?.length ?? 0)
|
|
|
|
const commentCursorSate = computed(
|
|
() => commentStore.nextCursorByApplicationFormId[props.applicationFormId]?.[props.formElementId]
|
|
)
|
|
const canLoadMore = computed(() => commentCursorSate.value?.hasMore === true)
|
|
const isLoadingMore = computed(() => commentCursorSate.value?.isLoading === true)
|
|
const newCommentValue = ref<string>('')
|
|
|
|
const newCommentEditorContent = computed<JSONContent>({
|
|
get: () => toEditorJson(newCommentValue.value),
|
|
set: (newValue) => {
|
|
newCommentValue.value = JSON.stringify(newValue)
|
|
}
|
|
})
|
|
const editingCommentId = ref<string | null>(null)
|
|
|
|
const editingCommentValue = ref<string>('')
|
|
const editingCommentEditorContent = computed<JSONContent>({
|
|
get: () => toEditorJson(editingCommentValue.value),
|
|
set: (newValue) => {
|
|
editingCommentValue.value = JSON.stringify(newValue)
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => scrollAreaRef.value,
|
|
async (scrollAreaComponent) => {
|
|
if (!scrollAreaComponent) {
|
|
scrollContainerEl.value = null
|
|
return
|
|
}
|
|
|
|
await nextTick()
|
|
|
|
const rootEl = scrollAreaComponent.$el as HTMLElement | undefined
|
|
if (!rootEl) return
|
|
|
|
scrollContainerEl.value = rootEl
|
|
|
|
// Wait another tick for content to be measured, then scroll to bottom
|
|
await nextTick()
|
|
// Use requestAnimationFrame to ensure layout is complete
|
|
requestAnimationFrame(() => {
|
|
rootEl.scrollTop = rootEl.scrollHeight
|
|
})
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
useInfiniteScroll(
|
|
scrollContainerEl,
|
|
async () => {
|
|
const scrollEl = scrollContainerEl.value
|
|
if (!scrollEl) return
|
|
|
|
const previousScrollHeight = scrollEl.scrollHeight
|
|
await loadMore(props.applicationFormId, props.formElementId)
|
|
// Maintain scroll position after prepending older comments
|
|
await nextTick()
|
|
const newScrollHeight = scrollEl.scrollHeight
|
|
scrollEl.scrollTop = newScrollHeight - previousScrollHeight + scrollEl.scrollTop
|
|
},
|
|
{
|
|
direction: 'top',
|
|
distance: 100,
|
|
canLoadMore: () => canLoadMore.value && !isLoadingMore.value
|
|
}
|
|
)
|
|
|
|
async function submitComment(formElementId: string) {
|
|
if (!newCommentValue.value.trim()) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await commentStore.createComment(props.applicationFormId, formElementId, { message: newCommentValue.value })
|
|
newCommentValue.value = ''
|
|
toast.add({ title: $t('comments.created'), color: 'success' })
|
|
} catch {
|
|
toast.add({ title: $t('comments.createError'), color: 'error' })
|
|
}
|
|
}
|
|
|
|
function startEditComment(comment: CommentDto) {
|
|
editingCommentId.value = comment.id
|
|
editingCommentValue.value = comment.message || ''
|
|
}
|
|
|
|
function cancelEditComment() {
|
|
editingCommentId.value = null
|
|
editingCommentValue.value = ''
|
|
}
|
|
|
|
async function saveEditedComment(comment: CommentDto) {
|
|
try {
|
|
const updatedComment: CommentDto = {
|
|
...comment,
|
|
message: editingCommentValue.value,
|
|
modifiedAt: new Date()
|
|
}
|
|
|
|
await commentStore.updateComment(comment.id, updatedComment)
|
|
cancelEditComment()
|
|
toast.add({ title: $t('comments.updated'), color: 'success' })
|
|
} catch {
|
|
toast.add({ title: $t('comments.updateError'), color: 'error' })
|
|
}
|
|
}
|
|
|
|
function isCommentByUser(comment: CommentDto) {
|
|
return comment.createdBy.keycloakId === user.value?.keycloakId
|
|
}
|
|
|
|
function createCommentActions(comment: CommentDto): DropdownMenuItem[] {
|
|
return [
|
|
{
|
|
label: $t('comments.editAction'),
|
|
icon: 'i-lucide-pencil',
|
|
onClick: () => startEditComment(comment)
|
|
}
|
|
]
|
|
}
|
|
|
|
function getCommentEditorModelValue(comment: CommentDto): JSONContent {
|
|
return toEditorJson(comment.message)
|
|
}
|
|
|
|
function toEditorJson(rawValue: string | undefined): JSONContent {
|
|
const raw = (rawValue ?? '').trim()
|
|
if (raw) {
|
|
try {
|
|
if (raw.startsWith('{')) {
|
|
return JSON.parse(raw) as JSONContent
|
|
}
|
|
} catch {
|
|
// fall through to plain text
|
|
}
|
|
return wrapPlainTextAsDoc(raw)
|
|
}
|
|
|
|
return wrapPlainTextAsDoc('')
|
|
}
|
|
|
|
function wrapPlainTextAsDoc(text: string): JSONContent {
|
|
return {
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'paragraph',
|
|
content: text
|
|
? [
|
|
{
|
|
type: 'text',
|
|
text
|
|
}
|
|
]
|
|
: []
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
const toolbarItems = [
|
|
[
|
|
{ kind: 'undo', icon: 'i-lucide-undo' },
|
|
{ kind: 'redo', icon: 'i-lucide-redo' }
|
|
],
|
|
[
|
|
{ kind: 'heading', level: 1, icon: 'i-lucide-heading-1', label: 'H1' },
|
|
{ kind: 'heading', level: 2, icon: 'i-lucide-heading-2', label: 'H2' },
|
|
{ kind: 'heading', level: 3, icon: 'i-lucide-heading-3', label: 'H3' }
|
|
],
|
|
[
|
|
{ kind: 'mark', mark: 'bold', icon: 'i-lucide-bold' },
|
|
{ kind: 'mark', mark: 'italic', icon: 'i-lucide-italic' },
|
|
{ kind: 'mark', mark: 'underline', icon: 'i-lucide-underline' },
|
|
{ kind: 'mark', mark: 'strike', icon: 'i-lucide-strikethrough' }
|
|
],
|
|
[
|
|
{ kind: 'bulletList', icon: 'i-lucide-list' },
|
|
{ kind: 'orderedList', icon: 'i-lucide-list-ordered' }
|
|
],
|
|
[
|
|
{ kind: 'blockquote', icon: 'i-lucide-quote' },
|
|
{ kind: 'codeBlock', icon: 'i-lucide-code' }
|
|
],
|
|
[{ kind: 'link', icon: 'i-lucide-link' }]
|
|
]
|
|
</script>
|