Files

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>