mirror of https://github.com/nocodb/nocodb
Anbarasu
4 months ago
committed by
GitHub
50 changed files with 2745 additions and 1475 deletions
After Width: | Height: | Size: 1.0 KiB |
After Width: | Height: | Size: 8.0 KiB |
@ -1,156 +1,379 @@ |
|||||||
<script lang="ts" setup> |
<script lang="ts" setup> |
||||||
import { onKeyDown } from '@vueuse/core' |
import type { CarouselApi } from '../../nc/Carousel/interface' |
||||||
import { useAttachmentCell } from './utils' |
import { useAttachmentCell } from './utils' |
||||||
|
import { isOffice } from '~/utils/fileUtils' |
||||||
|
|
||||||
const { selectedImage, visibleItems, downloadAttachment } = useAttachmentCell()! |
const { selectedFile, visibleItems, downloadAttachment, removeFile, renameFile, isPublic, isReadonly, isRenameModalOpen } = |
||||||
|
useAttachmentCell()! |
||||||
|
|
||||||
const carouselRef = ref() |
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false)) |
||||||
|
|
||||||
const container = ref() |
const { isSharedForm } = useSmartsheetStoreOrThrow() |
||||||
|
|
||||||
const imageItems = computed(() => visibleItems.value.filter((item) => isImage(item.title, item.mimetype))) |
/* |
||||||
|
const openComments = ref(false) |
||||||
|
*/ |
||||||
|
|
||||||
|
const { isUIAllowed } = useRoles() |
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null) |
||||||
|
|
||||||
|
const emblaMainApi: CarouselApi = ref() |
||||||
|
const emblaThumbnailApi: CarouselApi = ref() |
||||||
|
const selectedIndex = ref() |
||||||
|
|
||||||
|
const filetoDelete = reactive({ |
||||||
|
title: '', |
||||||
|
i: 0, |
||||||
|
}) |
||||||
|
const isModalOpen = ref(false) |
||||||
|
|
||||||
|
function onRemoveFileClick(title: any, i: number) { |
||||||
|
isModalOpen.value = true |
||||||
|
filetoDelete.title = title |
||||||
|
filetoDelete.i = i |
||||||
|
} |
||||||
|
|
||||||
|
const handleFileDelete = (i: number) => { |
||||||
|
removeFile(i) |
||||||
|
isModalOpen.value = false |
||||||
|
filetoDelete.i = 0 |
||||||
|
filetoDelete.title = '' |
||||||
|
} |
||||||
|
|
||||||
const { getPossibleAttachmentSrc } = useAttachment() |
const { getPossibleAttachmentSrc } = useAttachment() |
||||||
|
|
||||||
/** navigate to previous image on button click */ |
useEventListener(container, 'click', (e) => { |
||||||
onKeyDown( |
const target = e.target as HTMLElement |
||||||
(e) => ['Left', 'ArrowLeft', 'A'].includes(e.key), |
if (!target.closest('.keep-open') && !target.closest('.nc-button') && !target.closest('img') && !target.closest('video')) { |
||||||
() => { |
selectedFile.value = false |
||||||
if (carouselRef.value) carouselRef.value.prev() |
} |
||||||
}, |
}) |
||||||
) |
|
||||||
|
const onThumbClick = (index: number) => { |
||||||
/** navigate to next image on button click */ |
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||||
onKeyDown( |
|
||||||
(e) => ['Right', 'ArrowRight', 'D'].includes(e.key), |
emblaMainApi.value.scrollTo(index) |
||||||
() => { |
emblaThumbnailApi.value.scrollTo(index) |
||||||
if (carouselRef.value) carouselRef.value.next() |
} |
||||||
}, |
|
||||||
) |
const onSelect = () => { |
||||||
|
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||||
/** set our selected image when slide changes */ |
|
||||||
function onSlideChange(index: number) { |
const newSnap = emblaMainApi.value.selectedScrollSnap() |
||||||
selectedImage.value = imageItems.value[index] |
|
||||||
|
selectedIndex.value = newSnap |
||||||
|
selectedFile.value = visibleItems.value[newSnap] |
||||||
|
emblaThumbnailApi.value.scrollTo(newSnap) |
||||||
} |
} |
||||||
|
|
||||||
/** set our carousel ref and move to initial slide */ |
const goPrev = () => { |
||||||
const setCarouselRef = (el: Element) => { |
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||||
carouselRef.value = el |
|
||||||
|
|
||||||
carouselRef.value?.goTo( |
emblaMainApi.value.scrollPrev() |
||||||
imageItems.value.findIndex((item) => item === selectedImage.value), |
emblaThumbnailApi.value.scrollPrev() |
||||||
true, |
|
||||||
) |
|
||||||
} |
} |
||||||
|
|
||||||
/** close overlay view when clicking outside of image */ |
const goNext = () => { |
||||||
useEventListener(container, 'click', (e) => { |
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||||
if (!(e.target as HTMLElement)?.closest('.keep-open') && !(e.target as HTMLElement)?.closest('img')) { |
|
||||||
selectedImage.value = false |
emblaMainApi.value.scrollNext() |
||||||
|
emblaThumbnailApi.value.scrollNext() |
||||||
|
} |
||||||
|
|
||||||
|
// When the carousel is initialized, we set the selected index to the index of the selected file |
||||||
|
// and scroll to that index. We only need to do this once, so we use watchOnce. |
||||||
|
watchOnce(emblaMainApi, async (emblaMainApi) => { |
||||||
|
if (!emblaMainApi) return |
||||||
|
|
||||||
|
// The focus is set to the container so that the keyboard navigation works |
||||||
|
container.value?.focus() |
||||||
|
|
||||||
|
emblaThumbnailApi.value?.on('reInit', onSelect) |
||||||
|
|
||||||
|
emblaMainApi.on('select', onSelect) |
||||||
|
|
||||||
|
await nextTick(() => { |
||||||
|
if (!selectedIndex.value) { |
||||||
|
const newIndex = visibleItems.value.findIndex((item) => { |
||||||
|
if (selectedFile.value?.path) return item?.path === selectedFile.value.path |
||||||
|
if (selectedFile.value?.url) return item?.url === selectedFile.value.url |
||||||
|
return selectedFile.value?.title === item?.title |
||||||
|
}) |
||||||
|
|
||||||
|
selectedIndex.value = newIndex |
||||||
|
emblaMainApi.scrollTo(newIndex) |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
document.addEventListener('keydown', onKeyDown) |
||||||
|
}) |
||||||
|
|
||||||
|
onUnmounted(() => { |
||||||
|
document.removeEventListener('keydown', onKeyDown) |
||||||
|
}) |
||||||
|
|
||||||
|
function onKeyDown(event: KeyboardEvent) { |
||||||
|
if (isRenameModalOpen.value) return |
||||||
|
const prevKey = ['ArrowLeft', 'Left', 'a', 'A'] |
||||||
|
const nextKey = ['ArrowRight', 'Right', 'd', 'D'] |
||||||
|
|
||||||
|
if (prevKey.includes(event.key)) { |
||||||
|
event.preventDefault() |
||||||
|
emblaMainApi.value?.scrollPrev() |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if (nextKey.includes(event.key)) { |
||||||
|
event.preventDefault() |
||||||
|
emblaMainApi.value?.scrollNext() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* const toggleComment = () => { |
||||||
|
openComments.value = !openComments.value |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
if (!isPublic.value && !isExpandedFormOpen.value && isUIAllowed('commentList')) { |
||||||
|
const { loadComments } = useRowCommentsOrThrow() |
||||||
|
loadComments() |
||||||
} |
} |
||||||
}) |
}) |
||||||
|
*/ |
||||||
|
|
||||||
|
const initEmblaApi = (val: any) => { |
||||||
|
emblaMainApi.value = val |
||||||
|
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<template> |
<template> |
||||||
<GeneralOverlay v-model="selectedImage" :z-index="1001" class="bg-gray-500 bg-opacity-50"> |
<GeneralOverlay v-model="selectedFile" transition :z-index="isExpandedFormOpen ? 1000 : 50" class="bg-black bg-opacity-90"> |
||||||
<template v-if="selectedImage"> |
<div class="flex w-full h-full"> |
||||||
<div ref="container" class="overflow-hidden p-12 text-center relative xs:h-screen"> |
<div |
||||||
<div class="text-white group absolute top-5 right-5"> |
v-if="selectedFile" |
||||||
<component |
ref="container" |
||||||
:is="iconMap.closeCircle" |
class="flex w-full overflow-hidden justify-center text-center relative h-screen items-center" |
||||||
class="group-hover:text-red-500 cursor-pointer text-4xl" |
> |
||||||
@click.stop="selectedImage = false" |
<NcButton |
||||||
/> |
class="top-5 !absolute cursor-pointer !z-30 !hover:bg-transparent left-5" |
||||||
</div> |
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click.stop="selectedFile = false" |
||||||
|
> |
||||||
|
<component :is="iconMap.close" class="text-white" /> |
||||||
|
</NcButton> |
||||||
|
|
||||||
<div |
<div |
||||||
class="keep-open select-none group hover:(ring-1 ring-accent) ring-opacity-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow" |
class="keep-open select-none absolute top-5 pointer-events-none inset-x-0 mx-auto group flex items-center justify-center leading-8 inline-block text-center rounded shadow" |
||||||
@click.stop="downloadAttachment(selectedImage)" |
|
||||||
> |
> |
||||||
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3> |
<h3 |
||||||
|
style="width: max-content" |
||||||
|
class="hover:underline pointer-events-auto font-semibold cursor-pointer text-white" |
||||||
|
@click.stop="downloadAttachment(selectedFile)" |
||||||
|
> |
||||||
|
{{ selectedFile && selectedFile.title }} |
||||||
|
</h3> |
||||||
</div> |
</div> |
||||||
|
|
||||||
<a-carousel |
<NcCarousel class="!absolute inset-y-16 inset-x-24 keep-open flex justify-center items-center" @init-api="initEmblaApi"> |
||||||
v-if="!!selectedImage" |
<NcCarouselContent> |
||||||
:ref="setCarouselRef" |
<NcCarouselItem v-for="(item, index) in visibleItems" :key="index"> |
||||||
dots-class="slick-dots slick-thumb" |
<div v-if="selectedIndex === index" class="justify-center w-full h-full flex items-center"> |
||||||
:after-change="onSlideChange" |
<LazyCellAttachmentPreviewImage |
||||||
arrows |
v-if="isImage(item.title, item.mimeType)" |
||||||
|
class="nc-attachment-img-wrapper" |
||||||
|
object-fit="contain" |
||||||
|
:alt="item.title" |
||||||
|
:srcs="getPossibleAttachmentSrc(item)" |
||||||
|
/> |
||||||
|
|
||||||
|
<LazyCellAttachmentPreviewVideo |
||||||
|
v-else-if="isVideo(item.title, item.mimeType)" |
||||||
|
class="flex items-center w-full" |
||||||
|
:mime-type="item.mimeType" |
||||||
|
:title="item.title" |
||||||
|
:src="getPossibleAttachmentSrc(item)" |
||||||
|
/> |
||||||
|
<LazyCellAttachmentPreviewPdf |
||||||
|
v-else-if="isPdf(item.title, item.mimeType)" |
||||||
|
class="keep-open" |
||||||
|
:src="getPossibleAttachmentSrc(item)" |
||||||
|
/> |
||||||
|
<LazyCellAttachmentPreviewMiscOffice |
||||||
|
v-else-if="isOffice(item.title, item.mimeType)" |
||||||
|
class="keep-open" |
||||||
|
:src="getPossibleAttachmentSrc(item)" |
||||||
|
/> |
||||||
|
<div v-else class="bg-white h-full flex flex-col justify-center rounded-md gap-1 items-center w-full"> |
||||||
|
<component :is="iconMap.file" class="text-gray-600 w-20 h-20" /> |
||||||
|
<div class="text-gray-800 text-sm">{{ item.title }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</NcCarouselItem> |
||||||
|
</NcCarouselContent> |
||||||
|
</NcCarousel> |
||||||
|
|
||||||
|
<div |
||||||
|
v-if="emblaMainApi?.canScrollPrev()" |
||||||
|
:key="selectedIndex" |
||||||
|
class="left-2 carousel-navigation keep-open" |
||||||
|
@click="goPrev" |
||||||
> |
> |
||||||
<template #prevArrow> |
<component :is="iconMap.arrowLeft" class="text-7xl" /> |
||||||
<div class="custom-slick-arrow left-2 z-1 keep-open"> |
</div> |
||||||
<MaterialSymbolsArrowCircleLeftRounded class="rounded-full" /> |
<div |
||||||
</div> |
v-if="emblaMainApi?.canScrollNext()" |
||||||
</template> |
:key="selectedIndex" |
||||||
|
class="right-2 carousel-navigation keep-open" |
||||||
|
@click="goNext" |
||||||
|
> |
||||||
|
<component :is="iconMap.arrowRight" class="text-7xl" /> |
||||||
|
</div> |
||||||
|
|
||||||
<template #nextArrow> |
<!-- <div v-if="isUIAllowed('commentList') && !isExpandedFormOpen" class="absolute top-2 right-2"> |
||||||
<div class="custom-slick-arrow !right-2 z-1 keep-open"> |
<NcButton class="!hover:bg-transparent" type="text" size="small" @click="toggleComment"> |
||||||
<MaterialSymbolsArrowCircleRightRounded class="rounded-full" /> |
<div class="flex gap-1 text-white justify-center items-center"> |
||||||
|
Comments |
||||||
|
<GeneralIcon icon="messageCircle" /> |
||||||
</div> |
</div> |
||||||
</template> |
</NcButton> |
||||||
|
</div> --> |
||||||
|
|
||||||
<template #customPaging="props"> |
<div class="text-white absolute right-2 top-2 cursor-pointer"></div> |
||||||
<div class="cursor-pointer h-full nc-attachment-img-wrapper"> |
|
||||||
<LazyCellAttachmentImage |
|
||||||
class="!block m-auto h-full w-full" |
|
||||||
:alt="imageItems[props.i].title || `#${props.i}`" |
|
||||||
:srcs="getPossibleAttachmentSrc(imageItems[props.i])" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
<div v-for="(item, idx) of imageItems" :key="idx"> |
|
||||||
<LazyCellAttachmentImage :srcs="getPossibleAttachmentSrc(item)" class="max-w-70vw max-h-70vh" /> |
|
||||||
</div> |
|
||||||
</a-carousel> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</GeneralOverlay> |
|
||||||
</template> |
|
||||||
|
|
||||||
<style scoped> |
<div class="absolute w-full !bottom-2 max-h-18 z-30 flex items-center justify-center"> |
||||||
.ant-carousel :deep(.custom-slick-arrow .nc-icon):hover { |
<NcCarousel class="absolute max-w-sm" @init-api="(val) => (emblaThumbnailApi = val)"> |
||||||
@apply !bg-white; |
<NcCarouselContent class="!flex !gap-2"> |
||||||
} |
<NcCarouselItem |
||||||
.ant-carousel :deep(.slick-dots) { |
v-for="(item, index) in visibleItems" |
||||||
@apply relative mt-4; |
:key="index" |
||||||
} |
:class="{ |
||||||
|
'!opacity-100': index === selectedIndex, |
||||||
|
'!basis-1/4': visibleItems.length >= 4, |
||||||
|
'!basis-1/3': visibleItems.length === 3, |
||||||
|
'!basis-1/2': visibleItems.length === 2, |
||||||
|
}" |
||||||
|
class="px-2 keep-open opacity-50 cursor-pointer" |
||||||
|
@click="onThumbClick(index)" |
||||||
|
> |
||||||
|
<div class="flex items-center justify-center"> |
||||||
|
<LazyCellAttachmentPreviewImage |
||||||
|
v-if="isImage(item.title, item.mimeType)" |
||||||
|
class="nc-attachment-img-wrapper h-12" |
||||||
|
object-fit="contain" |
||||||
|
:alt="item.title" |
||||||
|
:srcs="getPossibleAttachmentSrc(item)" |
||||||
|
/> |
||||||
|
<div |
||||||
|
v-else-if="isVideo(item.title, item.mimeType)" |
||||||
|
class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200" |
||||||
|
> |
||||||
|
<GeneralIcon class="text-white" icon="play" /> |
||||||
|
</div> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-slide) { |
<div |
||||||
@apply w-full; |
v-else-if="isPdf(item.title, item.mimeType)" |
||||||
} |
class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200" |
||||||
|
> |
||||||
|
<GeneralIcon class="text-white" icon="pdfFile" /> |
||||||
|
</div> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-slide img) { |
<div v-else class="h-full flex items-center h-6 justify-center rounded-md px-2 py-1 border-1 border-gray-200"> |
||||||
@apply border-1 m-auto; |
<GeneralIcon class="text-white" icon="file" /> |
||||||
} |
</div> |
||||||
|
</div> |
||||||
|
</NcCarouselItem> |
||||||
|
</NcCarouselContent> |
||||||
|
</NcCarousel> |
||||||
|
</div> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-thumb) { |
<div class="absolute keep-open right-2 z-30 bottom-3 transition-all gap-3 transition-ease-in-out !h-6 flex items-center"> |
||||||
@apply bottom-2; |
<NcTooltip |
||||||
} |
v-if="!isSharedForm || (!isReadonly && isUIAllowed('dataEdit') && !isPublic)" |
||||||
|
color="light" |
||||||
|
placement="bottom" |
||||||
|
> |
||||||
|
<template #title> {{ $t('title.renameFile') }} </template> |
||||||
|
<NcButton |
||||||
|
size="xsmall" |
||||||
|
class="nc-attachment-rename !hover:text-gray-400 !hover:bg-transparent !text-white" |
||||||
|
type="text" |
||||||
|
@click="renameFile(selectedFile, selectedIndex, true)" |
||||||
|
> |
||||||
|
<component :is="iconMap.rename" class="!hover:text-gray-400" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-thumb li) { |
<NcTooltip v-if="!isReadonly" color="light" placement="bottom"> |
||||||
@apply w-[60px] h-[45px]; |
<template #title> {{ $t('title.downloadFile') }} </template> |
||||||
} |
<NcButton |
||||||
|
class="!hover:bg-transparent !text-white" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="downloadAttachment(selectedFile)" |
||||||
|
> |
||||||
|
<component :is="iconMap.download" class="!hover:text-gray-400" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-thumb li img) { |
<NcTooltip v-if="!isReadonly" color="light" placement="bottom"> |
||||||
@apply w-full h-full block; |
<template #title> {{ $t('title.removeFile') }} </template> |
||||||
filter: grayscale(100%); |
<NcButton |
||||||
} |
class="!hover:bg-transparent !text-white" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="onRemoveFileClick(selectedFile.title, selectedIndex)" |
||||||
|
> |
||||||
|
<component |
||||||
|
:is="iconMap.delete" |
||||||
|
v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)" |
||||||
|
class="!hover:text-gray-400" |
||||||
|
/> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
</div> |
||||||
|
<GeneralDeleteModal v-model:visible="isModalOpen" entity-name="File" :on-delete="() => handleFileDelete(filetoDelete.i)"> |
||||||
|
<template #entity-preview> |
||||||
|
<span> |
||||||
|
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4"> |
||||||
|
<GeneralIcon icon="file" class="nc-view-icon"></GeneralIcon> |
||||||
|
<div |
||||||
|
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75" |
||||||
|
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }" |
||||||
|
> |
||||||
|
{{ filetoDelete.title }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</span> |
||||||
|
</template> |
||||||
|
</GeneralDeleteModal> |
||||||
|
</div> |
||||||
|
<!-- <div v-if="openComments && isUIAllowed('commentList') && !isExpandedFormOpen" class="bg-white w-88 min-w-88 max-w-88"> |
||||||
|
<LazySmartsheetExpandedFormSidebarComments /> |
||||||
|
</div> --> |
||||||
|
</div> |
||||||
|
</GeneralOverlay> |
||||||
|
</template> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-thumb li.slick-active img) { |
<style scoped lang="scss"> |
||||||
filter: grayscale(0%); |
.carousel-navigation { |
||||||
|
@apply absolute text-gray-400 hover:text-white cursor-pointer text-white h-full flex items-center inset-y-0 my-0; |
||||||
} |
} |
||||||
|
</style> |
||||||
|
|
||||||
.ant-carousel :deep(.slick-arrow.custom-slick-arrow) { |
<style lang="scss"> |
||||||
@apply text-4xl text-white hover:text-primary active:text-accent opacity-100 cursor-pointer z-1; |
.nc-attachment-carousel { |
||||||
} |
@apply w-max; |
||||||
.ant-carousel :deep(.custom-slick-arrow:before) { |
|
||||||
display: none; |
|
||||||
} |
|
||||||
.ant-carousel :deep(.custom-slick-arrow:hover) { |
|
||||||
opacity: 0.5; |
|
||||||
} |
} |
||||||
|
|
||||||
.nc-attachment-img-wrapper { |
.carousel-container { |
||||||
width: fit-content !important; |
@apply !w-full flex items-center h-full; |
||||||
|
|
||||||
|
.embla__container { |
||||||
|
@apply items-center h-full w-full; |
||||||
|
} |
||||||
} |
} |
||||||
</style> |
</style> |
||||||
|
@ -0,0 +1,46 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
interface Props { |
||||||
|
src: string[] |
||||||
|
class?: string |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const currentIndex = ref(0) |
||||||
|
|
||||||
|
const handleError = () => { |
||||||
|
if (currentIndex.value < props.src.length - 1) { |
||||||
|
currentIndex.value = currentIndex.value + 1 |
||||||
|
} else { |
||||||
|
currentIndex.value = -1 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const openMethod = ref<'google' | undefined>() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div v-if="!openMethod" :class="props.class" class="flex flex-col text-white gap-2 items-center justify-center"> |
||||||
|
<GeneralIcon class="w-28 h-28" icon="pdfFile" /> |
||||||
|
|
||||||
|
<NcButton type="secondary" @click="openMethod = 'google'"> |
||||||
|
<div class="flex items-center gap-1"> |
||||||
|
<GeneralIcon class="w-4 h-4" icon="googleDocs" /> |
||||||
|
|
||||||
|
Open with Google Docs |
||||||
|
</div> |
||||||
|
</NcButton> |
||||||
|
</div> |
||||||
|
|
||||||
|
<iframe |
||||||
|
v-else-if="openMethod === 'google'" |
||||||
|
:class="props.class" |
||||||
|
:src="`https://docs.google.com/viewer?url=${encodeURIComponent(src[currentIndex])}&embedded=true`" |
||||||
|
width="100%" |
||||||
|
height="100%" |
||||||
|
frameborder="0" |
||||||
|
@error="handleError" |
||||||
|
></iframe> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"></style> |
@ -0,0 +1,63 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
interface Props { |
||||||
|
src: string[] |
||||||
|
class?: string |
||||||
|
} |
||||||
|
|
||||||
|
const props = defineProps<Props>() |
||||||
|
|
||||||
|
const currentIndex = ref(0) |
||||||
|
|
||||||
|
const handleError = () => { |
||||||
|
if (currentIndex.value < props.src.length - 1) { |
||||||
|
currentIndex.value = currentIndex.value + 1 |
||||||
|
} else { |
||||||
|
currentIndex.value = -1 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const openMethod = ref<'browser' | 'google' | undefined>() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div v-if="!openMethod" :class="props.class" class="flex flex-col text-white gap-2 items-center justify-center"> |
||||||
|
<GeneralIcon class="w-28 h-28" icon="pdfFile" /> |
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-2"> |
||||||
|
<NcButton class="!w-52" type="secondary" @click="openMethod = 'browser'"> |
||||||
|
<div class="flex items-center gap-1"> |
||||||
|
<GeneralIcon icon="globe" /> |
||||||
|
Open in browser |
||||||
|
</div> |
||||||
|
</NcButton> |
||||||
|
<NcButton type="secondary" class="!w-52" @click="openMethod = 'google'"> |
||||||
|
<div class="flex items-center gap-1"> |
||||||
|
<GeneralIcon class="w-4 h-4" icon="googleDocs" /> |
||||||
|
|
||||||
|
Open with Google Docs |
||||||
|
</div> |
||||||
|
</NcButton> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<pdf-object |
||||||
|
v-if="openMethod === 'browser'" |
||||||
|
:class="props.class" |
||||||
|
:url="src[currentIndex]" |
||||||
|
class="w-full h-full" |
||||||
|
@error="handleError" |
||||||
|
/> |
||||||
|
|
||||||
|
<iframe |
||||||
|
v-else-if="openMethod === 'google'" |
||||||
|
:class="props.class" |
||||||
|
type="application/pdf" |
||||||
|
:src="`https://docs.google.com/viewer?url=${encodeURIComponent(src[currentIndex])}&embedded=true`" |
||||||
|
width="100%" |
||||||
|
height="100%" |
||||||
|
frameborder="0" |
||||||
|
@error="handleError" |
||||||
|
></iframe> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"></style> |
@ -0,0 +1,55 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import Plyr from 'plyr' |
||||||
|
import 'plyr/dist/plyr.css' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
src?: string[] |
||||||
|
mimeType?: string |
||||||
|
class?: string |
||||||
|
title?: string |
||||||
|
} |
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { |
||||||
|
class: '', |
||||||
|
}) |
||||||
|
|
||||||
|
const emit = defineEmits<Emits>() |
||||||
|
|
||||||
|
interface Emits { |
||||||
|
(event: 'init', player: any): void |
||||||
|
} |
||||||
|
|
||||||
|
const videoPlayer = ref<HTMLElement>() |
||||||
|
|
||||||
|
const player = ref() |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
if (!videoPlayer.value) return |
||||||
|
player.value = new Plyr(videoPlayer.value, { |
||||||
|
previewThumbnails: {}, |
||||||
|
}) |
||||||
|
emit('init', player.value) |
||||||
|
}) |
||||||
|
|
||||||
|
onBeforeUnmount(() => { |
||||||
|
if (player.value) { |
||||||
|
player.value.destroy() |
||||||
|
} |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<video |
||||||
|
ref="videoPlayer" |
||||||
|
controls |
||||||
|
playsinline |
||||||
|
:class="{ |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="videoplayer !min-w-128 !min-h-72 w-full" |
||||||
|
> |
||||||
|
<source v-for="(source, id) in props.src" :key="id" :src="source" :type="mimeType" /> |
||||||
|
</video> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"></style> |
@ -0,0 +1,27 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useCarousel } from './useCarousel' |
||||||
|
import type { WithClassAsProps } from './interface' |
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>() |
||||||
|
|
||||||
|
const { carouselRef, orientation } = useCarousel() |
||||||
|
|
||||||
|
defineOptions({ |
||||||
|
inheritAttrs: false, |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div ref="carouselRef" class="overflow-hidden carousel-container"> |
||||||
|
<div |
||||||
|
:class="{ |
||||||
|
'flex-col': orientation === 'vertical', |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="flex embla__container" |
||||||
|
v-bind="$attrs" |
||||||
|
> |
||||||
|
<slot /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,18 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import type { WithClassAsProps } from './interface' |
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
role="group" |
||||||
|
aria-roledescription="slide" |
||||||
|
:class="{ |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="min-w-0 shrink-0 flex justify-center items-center embla__slide h-full grow-0 basis-full" |
||||||
|
> |
||||||
|
<slot /> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,26 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useCarousel } from './useCarousel' |
||||||
|
import type { WithClassAsProps } from './interface' |
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>() |
||||||
|
|
||||||
|
const { orientation, canScrollNext, scrollNext } = useCarousel() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NcButton |
||||||
|
:disabled="!canScrollNext" |
||||||
|
type="secondary" |
||||||
|
size="xsmall" |
||||||
|
:class="{ |
||||||
|
'rotate-90': orientation === 'vertical', |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="absolute" |
||||||
|
@click="scrollNext" |
||||||
|
> |
||||||
|
<slot> |
||||||
|
<GeneralIcon icon="arrowRight" class="h-4 w-4 text-current" /> |
||||||
|
</slot> |
||||||
|
</NcButton> |
||||||
|
</template> |
@ -0,0 +1,26 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useCarousel } from './useCarousel' |
||||||
|
import type { WithClassAsProps } from './interface' |
||||||
|
|
||||||
|
const props = defineProps<WithClassAsProps>() |
||||||
|
|
||||||
|
const { orientation, canScrollPrev, scrollPrev } = useCarousel() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<NcButton |
||||||
|
:disabled="!canScrollPrev" |
||||||
|
type="secondary" |
||||||
|
size="xsmall" |
||||||
|
:class="{ |
||||||
|
'rotate-90': orientation === 'vertical', |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="!absolute" |
||||||
|
@click="scrollPrev" |
||||||
|
> |
||||||
|
<slot> |
||||||
|
<GeneralIcon icon="arrowLeft" class="h-4 w-4 text-current" /> |
||||||
|
</slot> |
||||||
|
</NcButton> |
||||||
|
</template> |
@ -0,0 +1,41 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useProvideCarousel } from './useCarousel' |
||||||
|
import type { CarouselEmits, CarouselProps, WithClassAsProps } from './interface' |
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), { |
||||||
|
orientation: 'horizontal', |
||||||
|
}) |
||||||
|
|
||||||
|
const emits = defineEmits<CarouselEmits>() |
||||||
|
|
||||||
|
const carouselArgs = useProvideCarousel(props, emits) |
||||||
|
|
||||||
|
defineExpose(carouselArgs) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
:class="{ |
||||||
|
[props.class]: props.class, |
||||||
|
}" |
||||||
|
class="relative embla !focus-visible:outline-none" |
||||||
|
role="region" |
||||||
|
aria-roledescription="carousel" |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
<slot v-bind="carouselArgs" /> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.embla { |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
.embla__container { |
||||||
|
display: flex; |
||||||
|
} |
||||||
|
.embla__slide { |
||||||
|
position: relative; |
||||||
|
flex: 0 0 100%; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,24 @@ |
|||||||
|
import type { HTMLAttributes, UnwrapRef } from 'vue' |
||||||
|
import type useEmblaCarousel from 'embla-carousel-vue' |
||||||
|
import type { EmblaCarouselVueType } from 'embla-carousel-vue' |
||||||
|
|
||||||
|
export type CarouselApi = EmblaCarouselVueType[1] |
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> |
||||||
|
type CarouselOptions = UseCarouselParameters[0] |
||||||
|
type CarouselPlugin = UseCarouselParameters[1] |
||||||
|
|
||||||
|
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi> |
||||||
|
|
||||||
|
export interface CarouselProps { |
||||||
|
opts?: CarouselOptions |
||||||
|
plugins?: CarouselPlugin |
||||||
|
orientation?: 'horizontal' | 'vertical' |
||||||
|
} |
||||||
|
|
||||||
|
export interface CarouselEmits { |
||||||
|
(e: 'init-api', payload: UnwrapRefCarouselApi): void |
||||||
|
} |
||||||
|
|
||||||
|
export interface WithClassAsProps { |
||||||
|
class?: HTMLAttributes['class'] |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
import emblaCarouselVue from 'embla-carousel-vue' |
||||||
|
import type { UnwrapRefCarouselApi as CarouselApi, CarouselEmits, CarouselProps } from './interface' |
||||||
|
|
||||||
|
const [useProvideCarousel, useInjectCarousel] = createInjectionState( |
||||||
|
({ opts, orientation, plugins }: CarouselProps, emits: CarouselEmits) => { |
||||||
|
const [emblaNode, emblaApi] = emblaCarouselVue( |
||||||
|
{ |
||||||
|
...opts, |
||||||
|
axis: orientation === 'horizontal' ? 'x' : 'y', |
||||||
|
}, |
||||||
|
plugins, |
||||||
|
) |
||||||
|
|
||||||
|
function scrollPrev() { |
||||||
|
emblaApi.value?.scrollPrev() |
||||||
|
} |
||||||
|
function scrollNext() { |
||||||
|
emblaApi.value?.scrollNext() |
||||||
|
} |
||||||
|
|
||||||
|
const canScrollNext = ref(false) |
||||||
|
const canScrollPrev = ref(false) |
||||||
|
|
||||||
|
function onSelect(api: CarouselApi) { |
||||||
|
canScrollNext.value = api?.canScrollNext() || false |
||||||
|
canScrollPrev.value = api?.canScrollPrev() || false |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
if (!emblaApi.value) return |
||||||
|
|
||||||
|
emblaApi.value?.on('init', onSelect) |
||||||
|
emblaApi.value?.on('reInit', onSelect) |
||||||
|
emblaApi.value?.on('select', onSelect) |
||||||
|
|
||||||
|
emits('init-api', emblaApi.value) |
||||||
|
}) |
||||||
|
|
||||||
|
return { carouselRef: emblaNode, carouselApi: emblaApi, canScrollPrev, canScrollNext, scrollPrev, scrollNext, orientation } |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
function useCarousel() { |
||||||
|
const carouselState = useInjectCarousel() |
||||||
|
|
||||||
|
if (!carouselState) throw new Error('useCarousel must be used within a <Carousel />') |
||||||
|
|
||||||
|
return carouselState |
||||||
|
} |
||||||
|
|
||||||
|
export { useCarousel, useProvideCarousel } |
@ -1,689 +0,0 @@ |
|||||||
<script setup lang="ts"> |
|
||||||
import { type AuditType, type CommentType, ProjectRoles } from 'nocodb-sdk' |
|
||||||
|
|
||||||
const props = defineProps<{ |
|
||||||
loading: boolean |
|
||||||
primaryKey: string | null |
|
||||||
}>() |
|
||||||
|
|
||||||
const { |
|
||||||
loadComments, |
|
||||||
deleteComment, |
|
||||||
comments, |
|
||||||
resolveComment, |
|
||||||
audits, |
|
||||||
isAuditLoading, |
|
||||||
saveComment: _saveComment, |
|
||||||
updateComment, |
|
||||||
} = useExpandedFormStoreOrThrow() |
|
||||||
|
|
||||||
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore()) |
|
||||||
|
|
||||||
const commentsWrapperEl = ref<HTMLDivElement>() |
|
||||||
|
|
||||||
const commentInputRef = ref<any>() |
|
||||||
|
|
||||||
const comment = ref('') |
|
||||||
|
|
||||||
const { copy } = useClipboard() |
|
||||||
|
|
||||||
const route = useRoute() |
|
||||||
|
|
||||||
const { dashboardUrl } = useDashboard() |
|
||||||
|
|
||||||
const { user, appInfo } = useGlobal() |
|
||||||
|
|
||||||
const basesStore = useBases() |
|
||||||
|
|
||||||
const { basesUser } = storeToRefs(basesStore) |
|
||||||
|
|
||||||
const meta = inject(MetaInj, ref()) |
|
||||||
|
|
||||||
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : [])) |
|
||||||
|
|
||||||
const isExpandedFormLoading = computed(() => props.loading) |
|
||||||
|
|
||||||
const tab = ref<'comments' | 'audits'>('comments') |
|
||||||
|
|
||||||
const { isUIAllowed } = useRoles() |
|
||||||
|
|
||||||
const router = useRouter() |
|
||||||
|
|
||||||
const hasEditPermission = computed(() => isUIAllowed('commentEdit')) |
|
||||||
|
|
||||||
const editCommentValue = ref<CommentType>() |
|
||||||
|
|
||||||
const isEditing = ref<boolean>(false) |
|
||||||
|
|
||||||
const isCommentMode = ref(false) |
|
||||||
|
|
||||||
const hoveredCommentId = ref<null | string>(null) |
|
||||||
|
|
||||||
async function onEditComment() { |
|
||||||
if (!isEditing.value || !editCommentValue.value?.comment) return |
|
||||||
|
|
||||||
while (editCommentValue.value.comment.endsWith('<br />') || editCommentValue.value.comment.endsWith('\n')) { |
|
||||||
if (editCommentValue.value.comment.endsWith('<br />')) { |
|
||||||
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -6) |
|
||||||
} else { |
|
||||||
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -2) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
isCommentMode.value = true |
|
||||||
|
|
||||||
const tempCom = { |
|
||||||
...editCommentValue.value, |
|
||||||
} |
|
||||||
|
|
||||||
isEditing.value = false |
|
||||||
editCommentValue.value = undefined |
|
||||||
await updateComment(tempCom.id!, { |
|
||||||
comment: tempCom.comment, |
|
||||||
}) |
|
||||||
loadComments() |
|
||||||
} |
|
||||||
|
|
||||||
function onCancel(e: KeyboardEvent) { |
|
||||||
if (!isEditing.value) return |
|
||||||
e.preventDefault() |
|
||||||
e.stopPropagation() |
|
||||||
editCommentValue.value = undefined |
|
||||||
loadComments() |
|
||||||
isEditing.value = false |
|
||||||
editCommentValue.value = undefined |
|
||||||
} |
|
||||||
|
|
||||||
function editComment(comment: CommentType) { |
|
||||||
editCommentValue.value = { |
|
||||||
...comment, |
|
||||||
} |
|
||||||
isEditing.value = true |
|
||||||
nextTick(() => { |
|
||||||
scrollToComment(comment.id) |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const value = computed({ |
|
||||||
get() { |
|
||||||
return editCommentValue.value?.comment || '' |
|
||||||
}, |
|
||||||
set(val) { |
|
||||||
if (!editCommentValue.value) return |
|
||||||
editCommentValue.value.comment = val |
|
||||||
}, |
|
||||||
}) |
|
||||||
|
|
||||||
function scrollComments() { |
|
||||||
if (commentsWrapperEl.value) { |
|
||||||
commentsWrapperEl.value.scrollTo({ |
|
||||||
top: commentsWrapperEl.value.scrollHeight, |
|
||||||
behavior: 'smooth', |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const saveComment = async () => { |
|
||||||
if (!comment.value.trim()) return |
|
||||||
|
|
||||||
while (comment.value.endsWith('<br />') || comment.value.endsWith('\n')) { |
|
||||||
if (comment.value.endsWith('<br />')) { |
|
||||||
comment.value = comment.value.slice(0, -6) |
|
||||||
} else { |
|
||||||
comment.value = comment.value.slice(0, -2) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
isCommentMode.value = true |
|
||||||
|
|
||||||
// Optimistic Insert |
|
||||||
comments.value = [ |
|
||||||
...comments.value, |
|
||||||
{ |
|
||||||
id: `temp-${new Date().getTime()}`, |
|
||||||
comment: comment.value, |
|
||||||
created_at: new Date().toISOString(), |
|
||||||
created_by: user.value?.id, |
|
||||||
created_by_email: user.value?.email, |
|
||||||
created_display_name: user.value?.display_name ?? '', |
|
||||||
}, |
|
||||||
] |
|
||||||
|
|
||||||
const tempCom = comment.value |
|
||||||
comment.value = '' |
|
||||||
|
|
||||||
commentInputRef?.value?.setEditorContent('', true) |
|
||||||
await nextTick(() => { |
|
||||||
scrollComments() |
|
||||||
}) |
|
||||||
|
|
||||||
try { |
|
||||||
await _saveComment(tempCom) |
|
||||||
await nextTick(() => { |
|
||||||
isExpandedFormCommentMode.value = true |
|
||||||
}) |
|
||||||
scrollComments() |
|
||||||
} catch (e) { |
|
||||||
console.error(e) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const copyComment = async (comment: CommentType) => { |
|
||||||
await copy( |
|
||||||
encodeURI( |
|
||||||
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}?rowId=${props.primaryKey}&commentId=${comment.id}`, |
|
||||||
), |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function scrollToComment(commentId: string) { |
|
||||||
const commentEl = document.querySelector(`.${commentId}`) |
|
||||||
if (commentEl) { |
|
||||||
commentEl.scrollIntoView({ |
|
||||||
behavior: 'smooth', |
|
||||||
block: 'center', |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
function scrollToAudit(auditId?: string) { |
|
||||||
if (!auditId) return |
|
||||||
|
|
||||||
const auditEl = commentsWrapperEl.value?.querySelector(`.nc-audit-item.${auditId}`) |
|
||||||
if (auditEl) { |
|
||||||
auditEl.scrollIntoView({ |
|
||||||
behavior: 'smooth', |
|
||||||
block: 'center', |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
watch(commentsWrapperEl, () => { |
|
||||||
setTimeout(() => { |
|
||||||
nextTick(() => { |
|
||||||
const query = router.currentRoute.value.query |
|
||||||
const commentId = query.commentId |
|
||||||
if (commentId) { |
|
||||||
router.push({ |
|
||||||
query: { |
|
||||||
rowId: query.rowId, |
|
||||||
}, |
|
||||||
}) |
|
||||||
scrollToComment(commentId as string) |
|
||||||
|
|
||||||
hoveredCommentId.value = commentId as string |
|
||||||
|
|
||||||
onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect) |
|
||||||
} else { |
|
||||||
scrollComments() |
|
||||||
} |
|
||||||
}) |
|
||||||
}, 100) |
|
||||||
}) |
|
||||||
|
|
||||||
const createdBy = ( |
|
||||||
comment: CommentType & { |
|
||||||
created_display_name?: string |
|
||||||
}, |
|
||||||
) => { |
|
||||||
if (comment.created_by === user.value?.id) { |
|
||||||
return 'You' |
|
||||||
} else if (comment.created_display_name?.trim()) { |
|
||||||
return comment.created_display_name || 'Shared source' |
|
||||||
} else if (comment.created_by_email) { |
|
||||||
return comment.created_by_email |
|
||||||
} else { |
|
||||||
return 'Shared source' |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const createdByAudit = ( |
|
||||||
comment: AuditType & { |
|
||||||
created_display_name?: string |
|
||||||
}, |
|
||||||
) => { |
|
||||||
if (comment.user === user.value?.email) { |
|
||||||
return 'You' |
|
||||||
} else if (comment.created_display_name?.trim()) { |
|
||||||
return comment.created_display_name || 'Shared source' |
|
||||||
} else if (comment.user) { |
|
||||||
return comment.user |
|
||||||
} else { |
|
||||||
return 'Shared source' |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const getUserRole = (email: string) => { |
|
||||||
const user = baseUsers.value.find((user) => user.email === email) |
|
||||||
if (!user) return ProjectRoles.NO_ACCESS |
|
||||||
|
|
||||||
return user.roles || ProjectRoles.NO_ACCESS |
|
||||||
} |
|
||||||
|
|
||||||
const editedAt = (comment: CommentType) => { |
|
||||||
if (comment.updated_at !== comment.created_at && comment.updated_at) { |
|
||||||
const str = timeAgo(comment.updated_at).replace(' ', '_') |
|
||||||
return `[(edited)](a~~~###~~~Edited_${str}) ` |
|
||||||
} |
|
||||||
return '' |
|
||||||
} |
|
||||||
|
|
||||||
function handleResetHoverEffect() { |
|
||||||
if (!hoveredCommentId.value) return |
|
||||||
|
|
||||||
hoveredCommentId.value = null |
|
||||||
} |
|
||||||
|
|
||||||
watch( |
|
||||||
() => audits.value.length, |
|
||||||
(auditCount) => { |
|
||||||
nextTick(() => { |
|
||||||
setTimeout(() => { |
|
||||||
scrollToAudit(audits.value[auditCount - 1]?.id) |
|
||||||
}, 100) |
|
||||||
}) |
|
||||||
}, |
|
||||||
) |
|
||||||
</script> |
|
||||||
|
|
||||||
<template> |
|
||||||
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl overflow-hidden"> |
|
||||||
<NcTabs v-model:activeKey="tab" class="h-full"> |
|
||||||
<a-tab-pane key="comments" class="w-full h-full"> |
|
||||||
<template #tab> |
|
||||||
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2"> |
|
||||||
<GeneralIcon icon="messageCircle" class="w-4 h-4" /> |
|
||||||
<span class="<lg:hidden"> Comments </span> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
<div |
|
||||||
class="h-full" |
|
||||||
:class="{ |
|
||||||
'pb-1': !hasEditPermission, |
|
||||||
}" |
|
||||||
> |
|
||||||
<div v-if="isExpandedFormLoading" class="flex flex-col items-center justify-center w-full h-full"> |
|
||||||
<GeneralLoader size="xlarge" /> |
|
||||||
</div> |
|
||||||
<div v-else class="flex flex-col h-full"> |
|
||||||
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full nc-scrollbar-thin"> |
|
||||||
<div class="text-center text-3xl text-gray-700"> |
|
||||||
<GeneralIcon icon="commentHere" /> |
|
||||||
</div> |
|
||||||
<div class="font-medium text-center my-6 text-gray-500"> |
|
||||||
{{ hasEditPermission ? $t('activity.startCommenting') : $t('activity.noCommentsYet') }} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin"> |
|
||||||
<div |
|
||||||
v-for="commentItem of comments" |
|
||||||
:key="commentItem.id" |
|
||||||
:class="`${commentItem.id}`" |
|
||||||
class="nc-comment-item" |
|
||||||
@mouseover="handleResetHoverEffect" |
|
||||||
> |
|
||||||
<div |
|
||||||
:class="{ |
|
||||||
'hover:bg-gray-100': editCommentValue?.id !== commentItem!.id, |
|
||||||
'nc-hovered-comment bg-gray-100': hoveredCommentId === commentItem!.id |
|
||||||
}" |
|
||||||
class="group gap-3 overflow-hidden px-3 py-2 transition-colors" |
|
||||||
> |
|
||||||
<div class="flex items-start justify-between"> |
|
||||||
<div |
|
||||||
class="flex items-start gap-3 flex-1" |
|
||||||
:class="{ |
|
||||||
'w-[calc(100%)] group-hover:w-[calc(100%_-_50px)]': !appInfo.ee, |
|
||||||
'w-[calc(100%_-_44px)] group-hover:w-[calc(100%_-_72px)]': appInfo.ee && commentItem.resolved_by, |
|
||||||
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_72px)]': |
|
||||||
appInfo.ee && !commentItem.resolved_by && hasEditPermission, |
|
||||||
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_44px)]': |
|
||||||
appInfo.ee && !commentItem.resolved_by && !hasEditPermission, |
|
||||||
}" |
|
||||||
> |
|
||||||
<GeneralUserIcon |
|
||||||
:email="commentItem.created_by_email" |
|
||||||
:name="commentItem.created_display_name" |
|
||||||
class="mt-0.5" |
|
||||||
size="medium" |
|
||||||
/> |
|
||||||
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]"> |
|
||||||
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]"> |
|
||||||
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden"> |
|
||||||
{{ createdBy(commentItem) }} |
|
||||||
</div> |
|
||||||
|
|
||||||
<template #overlay> |
|
||||||
<div class="bg-white rounded-lg"> |
|
||||||
<div class="flex items-center gap-4 py-3 px-2"> |
|
||||||
<GeneralUserIcon |
|
||||||
class="!w-8 !h-8 border-1 border-gray-200 rounded-full" |
|
||||||
:name="commentItem.created_display_name" |
|
||||||
:email="commentItem.created_by_email" |
|
||||||
/> |
|
||||||
<div class="flex flex-col"> |
|
||||||
<div class="font-semibold text-gray-800"> |
|
||||||
{{ createdBy(commentItem) }} |
|
||||||
</div> |
|
||||||
<div class="text-xs text-gray-600"> |
|
||||||
{{ commentItem.created_by_email }} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div |
|
||||||
v-if="isUIAllowed('dataEdit')" |
|
||||||
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5" |
|
||||||
> |
|
||||||
Has <RolesBadge size="sm" :border="false" :role="getUserRole(commentItem.created_by_email!)" /> |
|
||||||
role in base |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</NcDropdown> |
|
||||||
<div class="text-xs text-gray-500"> |
|
||||||
{{ timeAgo(commentItem.created_at!) }} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div class="flex items-center"> |
|
||||||
<NcDropdown |
|
||||||
v-if="!editCommentValue" |
|
||||||
class="nc-comment-more-actions !hidden !group-hover:block" |
|
||||||
overlay-class-name="!min-w-[160px]" |
|
||||||
placement="bottomRight" |
|
||||||
> |
|
||||||
<NcButton |
|
||||||
class="nc-expand-form-more-actions !hover:bg-gray-200 !w-7 !h-7 !bg-transparent" |
|
||||||
size="xsmall" |
|
||||||
type="text" |
|
||||||
> |
|
||||||
<GeneralIcon class="text-md" icon="threeDotVertical" /> |
|
||||||
</NcButton> |
|
||||||
<template #overlay> |
|
||||||
<NcMenu> |
|
||||||
<NcMenuItem |
|
||||||
v-if="user && commentItem.created_by_email === user.email && hasEditPermission" |
|
||||||
v-e="['c:comment-expand:comment:edit']" |
|
||||||
class="text-gray-700" |
|
||||||
@click="editComment(commentItem)" |
|
||||||
> |
|
||||||
<div class="flex gap-2 items-center"> |
|
||||||
<component :is="iconMap.rename" class="cursor-pointer" /> |
|
||||||
{{ $t('general.edit') }} |
|
||||||
</div> |
|
||||||
</NcMenuItem> |
|
||||||
<NcMenuItem |
|
||||||
v-e="['c:comment-expand:comment:copy']" |
|
||||||
class="text-gray-700" |
|
||||||
@click="copyComment(commentItem)" |
|
||||||
> |
|
||||||
<div class="flex gap-2 items-center"> |
|
||||||
<component :is="iconMap.copy" class="cursor-pointer" /> |
|
||||||
{{ $t('general.copy') }} URL |
|
||||||
</div> |
|
||||||
</NcMenuItem> |
|
||||||
<template v-if="user && commentItem.created_by_email === user.email && hasEditPermission"> |
|
||||||
<NcDivider /> |
|
||||||
<NcMenuItem |
|
||||||
v-e="['c:row-expand:comment:delete']" |
|
||||||
class="!text-red-500 !hover:bg-red-50" |
|
||||||
@click="deleteComment(commentItem.id!)" |
|
||||||
> |
|
||||||
<div class="flex gap-2 items-center"> |
|
||||||
<component :is="iconMap.delete" class="cursor-pointer" /> |
|
||||||
{{ $t('general.delete') }} |
|
||||||
</div> |
|
||||||
</NcMenuItem> |
|
||||||
</template> |
|
||||||
</NcMenu> |
|
||||||
</template> |
|
||||||
</NcDropdown> |
|
||||||
<div v-if="appInfo.ee"> |
|
||||||
<NcTooltip v-if="!commentItem.resolved_by && hasEditPermission"> |
|
||||||
<NcButton |
|
||||||
class="nc-resolve-comment-btn !w-7 !h-7 !bg-transparent !hover:bg-gray-200 !hidden !group-hover:block" |
|
||||||
size="xsmall" |
|
||||||
type="text" |
|
||||||
@click="resolveComment(commentItem.id!)" |
|
||||||
> |
|
||||||
<GeneralIcon class="text-md" icon="checkCircle" /> |
|
||||||
</NcButton> |
|
||||||
|
|
||||||
<template #title>Click to resolve </template> |
|
||||||
</NcTooltip> |
|
||||||
|
|
||||||
<NcTooltip v-else-if="commentItem.resolved_by"> |
|
||||||
<template #title>{{ `Resolved by ${commentItem.resolved_display_name}` }}</template> |
|
||||||
<NcButton |
|
||||||
class="!h-7 !w-7 !bg-transparent !hover:bg-gray-200 text-semibold" |
|
||||||
size="xsmall" |
|
||||||
type="text" |
|
||||||
@click="resolveComment(commentItem.id!)" |
|
||||||
> |
|
||||||
<GeneralIcon class="text-md rounded-full bg-[#17803D] text-white" icon="checkFill" /> |
|
||||||
</NcButton> |
|
||||||
</NcTooltip> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div |
|
||||||
:class="{ |
|
||||||
'mt-3': commentItem.id === editCommentValue?.id, |
|
||||||
}" |
|
||||||
class="flex-1 flex flex-col gap-1 max-w-[calc(100%)]" |
|
||||||
> |
|
||||||
<SmartsheetExpandedFormRichComment |
|
||||||
v-if="commentItem.id === editCommentValue?.id && hasEditPermission" |
|
||||||
v-model:value="value" |
|
||||||
autofocus |
|
||||||
:hide-options="false" |
|
||||||
class="expanded-form-comment-edit-input cursor-text expanded-form-comment-input !py-2 !px-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[240px]" |
|
||||||
data-testid="expanded-form-comment-input" |
|
||||||
sync-value-change |
|
||||||
@save="onEditComment" |
|
||||||
@keydown.esc="onCancel" |
|
||||||
@blur=" |
|
||||||
() => { |
|
||||||
editCommentValue = undefined |
|
||||||
isEditing = false |
|
||||||
} |
|
||||||
" |
|
||||||
@keydown.enter.exact.prevent="onEditComment" |
|
||||||
/> |
|
||||||
|
|
||||||
<div v-else class="space-y-1 pl-9"> |
|
||||||
<SmartsheetExpandedFormRichComment |
|
||||||
:value="`${commentItem.comment} ${editedAt(commentItem)}`" |
|
||||||
class="!text-small !leading-18px !text-gray-800 -ml-1" |
|
||||||
read-only |
|
||||||
sync-value-change |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div v-if="hasEditPermission" class="px-3 pb-3 nc-comment-input !rounded-br-2xl gap-2 flex"> |
|
||||||
<SmartsheetExpandedFormRichComment |
|
||||||
ref="commentInputRef" |
|
||||||
v-model:value="comment" |
|
||||||
:hide-options="false" |
|
||||||
placeholder="Comment..." |
|
||||||
class="expanded-form-comment-input !py-2 !px-2 cursor-text border-1 rounded-lg w-full bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[240px]" |
|
||||||
:autofocus="isExpandedFormCommentMode" |
|
||||||
data-testid="expanded-form-comment-input" |
|
||||||
@focus="isExpandedFormCommentMode = false" |
|
||||||
@keydown.stop |
|
||||||
@save="saveComment" |
|
||||||
@keydown.enter.exact.prevent="saveComment" |
|
||||||
/> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</a-tab-pane> |
|
||||||
|
|
||||||
<a-tab-pane key="audits" class="w-full" :disabled="appInfo.ee"> |
|
||||||
<template #tab> |
|
||||||
<NcTooltip v-if="appInfo.ee" class="tab flex-1"> |
|
||||||
<template #title>{{ $t('title.comingSoon') }}</template> |
|
||||||
|
|
||||||
<div v-e="['c:row-expand:audit']" class="flex items-center gap-2 text-gray-400"> |
|
||||||
<GeneralIcon icon="audit" class="w-4 h-4" /> |
|
||||||
<span class="<lg:hidden"> Audits </span> |
|
||||||
</div> |
|
||||||
</NcTooltip> |
|
||||||
|
|
||||||
<div v-else v-e="['c:row-expand:audit']" class="flex items-center gap-2"> |
|
||||||
<GeneralIcon icon="audit" class="w-4 h-4" /> |
|
||||||
<span class="<lg:hidden"> Audits </span> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
<div class="h-full pb-1"> |
|
||||||
<div v-if="isExpandedFormLoading || isAuditLoading" class="flex flex-col items-center justify-center w-full h-full"> |
|
||||||
<GeneralLoader size="xlarge" /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin"> |
|
||||||
<template v-if="audits.length === 0"> |
|
||||||
<div class="flex flex-col text-center justify-center h-full"> |
|
||||||
<div class="text-center text-3xl text-gray-600"> |
|
||||||
<MdiHistory /> |
|
||||||
</div> |
|
||||||
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
|
|
||||||
<div v-for="audit of audits" :key="audit.id" :class="`${audit.id}`" class="nc-audit-item"> |
|
||||||
<div class="group gap-3 overflow-hidden px-3 py-2 hover:bg-gray-100"> |
|
||||||
<div class="flex items-start justify-between"> |
|
||||||
<div class="flex items-start gap-3 flex-1 w-full"> |
|
||||||
<GeneralUserIcon |
|
||||||
:email="audit.created_by_email" |
|
||||||
:name="audit.created_display_name" |
|
||||||
class="mt-0.5" |
|
||||||
size="medium" |
|
||||||
/> |
|
||||||
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]"> |
|
||||||
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]"> |
|
||||||
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden"> |
|
||||||
{{ createdByAudit(audit) }} |
|
||||||
</div> |
|
||||||
|
|
||||||
<template #overlay> |
|
||||||
<div class="bg-white rounded-lg"> |
|
||||||
<div class="flex items-center gap-4 py-3 px-2"> |
|
||||||
<GeneralUserIcon |
|
||||||
class="!w-8 !h-8 border-1 border-gray-200 rounded-full" |
|
||||||
:name="audit.created_display_name" |
|
||||||
:email="audit.created_by_email" |
|
||||||
/> |
|
||||||
<div class="flex flex-col"> |
|
||||||
<div class="font-semibold text-gray-800"> |
|
||||||
{{ createdByAudit(audit) }} |
|
||||||
</div> |
|
||||||
<div class="text-xs text-gray-600"> |
|
||||||
{{ audit.created_by_email }} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div |
|
||||||
v-if="isUIAllowed('dataEdit')" |
|
||||||
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5" |
|
||||||
> |
|
||||||
Has <RolesBadge size="sm" :border="false" :role="getUserRole(audit.created_by_email!)" /> |
|
||||||
role in base |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</NcDropdown> |
|
||||||
<div class="text-xs text-gray-500"> |
|
||||||
{{ timeAgo(audit.created_at!) }} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div v-dompurify-html="audit.details" class="!text-[13px] text-gray-500 !leading-5 !pl-9"></div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</a-tab-pane> |
|
||||||
</NcTabs> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
|
|
||||||
<style lang="scss" scoped> |
|
||||||
.tab { |
|
||||||
@apply max-w-1/2; |
|
||||||
} |
|
||||||
|
|
||||||
.nc-audit-item { |
|
||||||
@apply gap-3; |
|
||||||
} |
|
||||||
|
|
||||||
.tab .tab-title { |
|
||||||
@apply min-w-0 flex justify-center gap-2 font-semibold items-center; |
|
||||||
word-break: 'keep-all'; |
|
||||||
white-space: 'nowrap'; |
|
||||||
display: 'inline'; |
|
||||||
} |
|
||||||
|
|
||||||
.text-decoration-line-through { |
|
||||||
text-decoration: line-through; |
|
||||||
} |
|
||||||
|
|
||||||
:deep(.red.lighten-4) { |
|
||||||
@apply bg-red-100 rounded-md line-through; |
|
||||||
} |
|
||||||
|
|
||||||
:deep(.green.lighten-4) { |
|
||||||
@apply bg-green-100 rounded-md !mr-3; |
|
||||||
} |
|
||||||
|
|
||||||
:deep(.ant-tabs) { |
|
||||||
@apply !overflow-visible; |
|
||||||
.ant-tabs-nav { |
|
||||||
@apply px-3 bg-white; |
|
||||||
.ant-tabs-nav-list { |
|
||||||
@apply w-[99%] mx-auto gap-6; |
|
||||||
|
|
||||||
.ant-tabs-tab { |
|
||||||
@apply flex-1 flex items-center justify-center pt-3 pb-2.5; |
|
||||||
|
|
||||||
& + .ant-tabs-tab { |
|
||||||
@apply !ml-0; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
.ant-tabs-content-holder { |
|
||||||
.ant-tabs-content { |
|
||||||
@apply h-full; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
:deep(.expanded-form-comment-input) { |
|
||||||
@apply transition-all duration-150 min-h-8; |
|
||||||
box-shadow: none; |
|
||||||
&:focus, |
|
||||||
&:focus-within { |
|
||||||
@apply min-h-16 !bg-white border-brand-500; |
|
||||||
box-shadow: 0px 0px 0px 2px rgba(51, 102, 255, 0.24); |
|
||||||
} |
|
||||||
&::placeholder { |
|
||||||
@apply !text-gray-400; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) { |
|
||||||
@apply bg-white; |
|
||||||
} |
|
||||||
|
|
||||||
.nc-hovered-comment { |
|
||||||
.nc-expand-form-more-actions, |
|
||||||
.nc-resolve-comment-btn { |
|
||||||
@apply !block; |
|
||||||
} |
|
||||||
} |
|
||||||
</style> |
|
@ -0,0 +1,145 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { type AuditType, ProjectRoles } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const { user } = useGlobal() |
||||||
|
|
||||||
|
const { isUIAllowed } = useRoles() |
||||||
|
|
||||||
|
const basesStore = useBases() |
||||||
|
|
||||||
|
const { basesUser } = storeToRefs(basesStore) |
||||||
|
|
||||||
|
const meta = inject(MetaInj, ref()) |
||||||
|
|
||||||
|
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : [])) |
||||||
|
|
||||||
|
const { audits, isAuditLoading } = useExpandedFormStoreOrThrow() |
||||||
|
|
||||||
|
const auditsWrapperEl = ref<HTMLElement | null>(null) |
||||||
|
|
||||||
|
function scrollToAudit(auditId?: string) { |
||||||
|
if (!auditId) return |
||||||
|
|
||||||
|
const auditEl = auditsWrapperEl.value?.querySelector(`.nc-audit-item.${auditId}`) |
||||||
|
if (auditEl) { |
||||||
|
auditEl.scrollIntoView({ |
||||||
|
behavior: 'smooth', |
||||||
|
block: 'center', |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const createdByAudit = ( |
||||||
|
comment: AuditType & { |
||||||
|
created_display_name?: string |
||||||
|
}, |
||||||
|
) => { |
||||||
|
if (comment.user === user.value?.email) { |
||||||
|
return 'You' |
||||||
|
} else if (comment.created_display_name?.trim()) { |
||||||
|
return comment.created_display_name || 'Shared source' |
||||||
|
} else if (comment.user) { |
||||||
|
return comment.user |
||||||
|
} else { |
||||||
|
return 'Shared source' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const getUserRole = (email: string) => { |
||||||
|
const user = baseUsers.value.find((user) => user.email === email) |
||||||
|
if (!user) return ProjectRoles.NO_ACCESS |
||||||
|
|
||||||
|
return user.roles || ProjectRoles.NO_ACCESS |
||||||
|
} |
||||||
|
watch( |
||||||
|
() => audits.value.length, |
||||||
|
(auditCount) => { |
||||||
|
nextTick(() => { |
||||||
|
setTimeout(() => { |
||||||
|
scrollToAudit(audits.value[auditCount - 1]?.id) |
||||||
|
}, 100) |
||||||
|
}) |
||||||
|
}, |
||||||
|
) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="h-full pb-1"> |
||||||
|
<div v-if="isAuditLoading" class="flex flex-col items-center justify-center w-full h-full"> |
||||||
|
<GeneralLoader size="xlarge" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-else ref="auditsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin"> |
||||||
|
<template v-if="audits.length === 0"> |
||||||
|
<div class="flex flex-col text-center justify-center h-full"> |
||||||
|
<div class="text-center text-3xl text-gray-600"> |
||||||
|
<MdiHistory /> |
||||||
|
</div> |
||||||
|
<div class="font-bold text-center my-1 text-gray-600">See changes to this record</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div v-for="audit of audits" :key="audit.id" :class="`${audit.id}`" class="nc-audit-item"> |
||||||
|
<div class="group gap-3 overflow-hidden px-3 py-2 hover:bg-gray-100"> |
||||||
|
<div class="flex items-start justify-between"> |
||||||
|
<div class="flex items-start gap-3 flex-1 w-full"> |
||||||
|
<GeneralUserIcon :email="audit.created_by_email" :name="audit.created_display_name" class="mt-0.5" size="medium" /> |
||||||
|
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]"> |
||||||
|
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]"> |
||||||
|
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden"> |
||||||
|
{{ createdByAudit(audit) }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<template #overlay> |
||||||
|
<div class="bg-white rounded-lg"> |
||||||
|
<div class="flex items-center gap-4 py-3 px-2"> |
||||||
|
<GeneralUserIcon |
||||||
|
class="!w-8 !h-8 border-1 border-gray-200 rounded-full" |
||||||
|
:name="audit.created_display_name" |
||||||
|
:email="audit.created_by_email" |
||||||
|
/> |
||||||
|
<div class="flex flex-col"> |
||||||
|
<div class="font-semibold text-gray-800"> |
||||||
|
{{ createdByAudit(audit) }} |
||||||
|
</div> |
||||||
|
<div class="text-xs text-gray-600"> |
||||||
|
{{ audit.created_by_email }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
v-if="isUIAllowed('dataEdit')" |
||||||
|
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5" |
||||||
|
> |
||||||
|
Has <RolesBadge size="sm" :border="false" :role="getUserRole(audit.created_by_email!)" /> |
||||||
|
role in base |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</NcDropdown> |
||||||
|
<div class="text-xs text-gray-500"> |
||||||
|
{{ timeAgo(audit.created_at!) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-dompurify-html="audit.details" class="!text-[13px] text-gray-500 !leading-5 !pl-9"></div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
:deep(.red.lighten-4) { |
||||||
|
@apply bg-red-100 rounded-md line-through; |
||||||
|
} |
||||||
|
|
||||||
|
.nc-audit-item { |
||||||
|
@apply gap-3; |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.green.lighten-4) { |
||||||
|
@apply bg-green-100 rounded-md !mr-3; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,488 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { type CommentType, ProjectRoles } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const { user, appInfo } = useGlobal() |
||||||
|
|
||||||
|
const { dashboardUrl } = useDashboard() |
||||||
|
|
||||||
|
const { isUIAllowed } = useRoles() |
||||||
|
|
||||||
|
const { copy } = useClipboard() |
||||||
|
|
||||||
|
const route = useRoute() |
||||||
|
|
||||||
|
const hasEditPermission = computed(() => isUIAllowed('commentEdit')) |
||||||
|
|
||||||
|
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore()) |
||||||
|
|
||||||
|
const basesStore = useBases() |
||||||
|
|
||||||
|
const { basesUser } = storeToRefs(basesStore) |
||||||
|
|
||||||
|
const meta = inject(MetaInj, ref()) |
||||||
|
|
||||||
|
const { |
||||||
|
deleteComment, |
||||||
|
resolveComment, |
||||||
|
isCommentsLoading, |
||||||
|
comments, |
||||||
|
loadComments, |
||||||
|
updateComment, |
||||||
|
saveComment: _saveComment, |
||||||
|
primaryKey, |
||||||
|
} = useRowCommentsOrThrow() |
||||||
|
|
||||||
|
const editCommentValue = ref<CommentType>() |
||||||
|
|
||||||
|
const commentsWrapperEl = ref<HTMLDivElement>() |
||||||
|
|
||||||
|
const isEditing = ref<boolean>(false) |
||||||
|
|
||||||
|
const isCommentMode = ref(false) |
||||||
|
|
||||||
|
const hoveredCommentId = ref<null | string>(null) |
||||||
|
|
||||||
|
const commentInputRef = ref<any>() |
||||||
|
|
||||||
|
const comment = ref('') |
||||||
|
|
||||||
|
const router = useRouter() |
||||||
|
|
||||||
|
const baseUsers = computed(() => (meta.value?.base_id ? basesUser.value.get(meta.value?.base_id) || [] : [])) |
||||||
|
|
||||||
|
function scrollComments() { |
||||||
|
if (commentsWrapperEl.value) { |
||||||
|
commentsWrapperEl.value.scrollTo({ |
||||||
|
top: commentsWrapperEl.value.scrollHeight, |
||||||
|
behavior: 'smooth', |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const saveComment = async () => { |
||||||
|
if (!comment.value.trim()) return |
||||||
|
|
||||||
|
while (comment.value.endsWith('<br />') || comment.value.endsWith('\n')) { |
||||||
|
if (comment.value.endsWith('<br />')) { |
||||||
|
comment.value = comment.value.slice(0, -6) |
||||||
|
} else { |
||||||
|
comment.value = comment.value.slice(0, -2) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
isCommentMode.value = true |
||||||
|
|
||||||
|
// Optimistic Insert |
||||||
|
comments.value = [ |
||||||
|
...comments.value, |
||||||
|
{ |
||||||
|
id: `temp-${new Date().getTime()}`, |
||||||
|
comment: comment.value, |
||||||
|
created_at: new Date().toISOString(), |
||||||
|
created_by: user.value?.id, |
||||||
|
created_by_email: user.value?.email, |
||||||
|
created_display_name: user.value?.display_name ?? '', |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
const tempCom = comment.value |
||||||
|
comment.value = '' |
||||||
|
|
||||||
|
commentInputRef?.value?.setEditorContent('', true) |
||||||
|
await nextTick(() => { |
||||||
|
scrollComments() |
||||||
|
}) |
||||||
|
|
||||||
|
try { |
||||||
|
await _saveComment(tempCom) |
||||||
|
await nextTick(() => { |
||||||
|
isExpandedFormCommentMode.value = true |
||||||
|
}) |
||||||
|
scrollComments() |
||||||
|
} catch (e) { |
||||||
|
console.error(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const copyComment = async (comment: CommentType) => { |
||||||
|
await copy( |
||||||
|
encodeURI( |
||||||
|
`${dashboardUrl?.value}#/${route.params.typeOrId}/${route.params.baseId}/${meta.value?.id}?rowId=${primaryKey.value}&commentId=${comment.id}`, |
||||||
|
), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function scrollToComment(commentId: string) { |
||||||
|
const commentEl = document.querySelector(`.${commentId}`) |
||||||
|
if (commentEl) { |
||||||
|
commentEl.scrollIntoView({ |
||||||
|
behavior: 'smooth', |
||||||
|
block: 'center', |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function onCancel(e: KeyboardEvent) { |
||||||
|
if (!isEditing.value) return |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
editCommentValue.value = undefined |
||||||
|
loadComments() |
||||||
|
isEditing.value = false |
||||||
|
editCommentValue.value = undefined |
||||||
|
} |
||||||
|
|
||||||
|
function editComment(comment: CommentType) { |
||||||
|
editCommentValue.value = { |
||||||
|
...comment, |
||||||
|
} |
||||||
|
isEditing.value = true |
||||||
|
nextTick(() => { |
||||||
|
scrollToComment(comment.id!) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const value = computed({ |
||||||
|
get() { |
||||||
|
return editCommentValue.value?.comment || '' |
||||||
|
}, |
||||||
|
set(val) { |
||||||
|
if (!editCommentValue.value) return |
||||||
|
editCommentValue.value.comment = val |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
async function onEditComment() { |
||||||
|
if (!isEditing.value || !editCommentValue.value?.comment) return |
||||||
|
|
||||||
|
while (editCommentValue.value.comment.endsWith('<br />') || editCommentValue.value.comment.endsWith('\n')) { |
||||||
|
if (editCommentValue.value.comment.endsWith('<br />')) { |
||||||
|
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -6) |
||||||
|
} else { |
||||||
|
editCommentValue.value.comment = editCommentValue.value.comment.slice(0, -2) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
isCommentMode.value = true |
||||||
|
|
||||||
|
const tempCom = { |
||||||
|
...editCommentValue.value, |
||||||
|
} |
||||||
|
|
||||||
|
isEditing.value = false |
||||||
|
editCommentValue.value = undefined |
||||||
|
await updateComment(tempCom.id!, { |
||||||
|
comment: tempCom.comment, |
||||||
|
}) |
||||||
|
loadComments() |
||||||
|
} |
||||||
|
|
||||||
|
const createdBy = ( |
||||||
|
comment: CommentType & { |
||||||
|
created_display_name?: string |
||||||
|
}, |
||||||
|
) => { |
||||||
|
if (comment.created_by === user.value?.id) { |
||||||
|
return 'You' |
||||||
|
} else if (comment.created_display_name?.trim()) { |
||||||
|
return comment.created_display_name || 'Shared source' |
||||||
|
} else if (comment.created_by_email) { |
||||||
|
return comment.created_by_email |
||||||
|
} else { |
||||||
|
return 'Shared source' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const editedAt = (comment: CommentType) => { |
||||||
|
if (comment.updated_at !== comment.created_at && comment.updated_at) { |
||||||
|
const str = timeAgo(comment.updated_at).replace(' ', '_') |
||||||
|
return `[(edited)](a~~~###~~~Edited_${str}) ` |
||||||
|
} |
||||||
|
return '' |
||||||
|
} |
||||||
|
|
||||||
|
function handleResetHoverEffect() { |
||||||
|
if (!hoveredCommentId.value) return |
||||||
|
|
||||||
|
hoveredCommentId.value = null |
||||||
|
} |
||||||
|
|
||||||
|
watch(commentsWrapperEl, () => { |
||||||
|
setTimeout(() => { |
||||||
|
nextTick(() => { |
||||||
|
const query = router.currentRoute.value.query |
||||||
|
const commentId = query.commentId |
||||||
|
if (commentId) { |
||||||
|
router.push({ |
||||||
|
query: { |
||||||
|
rowId: query.rowId, |
||||||
|
}, |
||||||
|
}) |
||||||
|
scrollToComment(commentId as string) |
||||||
|
|
||||||
|
hoveredCommentId.value = commentId as string |
||||||
|
|
||||||
|
onClickOutside(document.querySelector(`.${hoveredCommentId.value}`)! as HTMLDivElement, handleResetHoverEffect) |
||||||
|
} else { |
||||||
|
scrollComments() |
||||||
|
} |
||||||
|
}) |
||||||
|
}, 100) |
||||||
|
}) |
||||||
|
|
||||||
|
const getUserRole = (email: string) => { |
||||||
|
const user = baseUsers.value.find((user) => user.email === email) |
||||||
|
if (!user) return ProjectRoles.NO_ACCESS |
||||||
|
|
||||||
|
return user.roles || ProjectRoles.NO_ACCESS |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div |
||||||
|
class="h-full" |
||||||
|
:class="{ |
||||||
|
'pb-1': !hasEditPermission, |
||||||
|
}" |
||||||
|
> |
||||||
|
<div v-if="isCommentsLoading" class="flex flex-col items-center justify-center w-full h-full"> |
||||||
|
<GeneralLoader size="xlarge" /> |
||||||
|
</div> |
||||||
|
<div v-else class="flex flex-col h-full"> |
||||||
|
<div v-if="comments.length === 0" class="flex flex-col my-1 text-center justify-center h-full nc-scrollbar-thin"> |
||||||
|
<div class="text-center text-3xl text-gray-700"> |
||||||
|
<GeneralIcon icon="commentHere" /> |
||||||
|
</div> |
||||||
|
<div class="font-medium text-center my-6 text-gray-500"> |
||||||
|
{{ hasEditPermission ? $t('activity.startCommenting') : $t('activity.noCommentsYet') }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-else ref="commentsWrapperEl" class="flex flex-col h-full py-1 nc-scrollbar-thin"> |
||||||
|
<div |
||||||
|
v-for="commentItem of comments" |
||||||
|
:key="commentItem.id" |
||||||
|
:class="`${commentItem.id}`" |
||||||
|
class="nc-comment-item" |
||||||
|
@mouseover="handleResetHoverEffect" |
||||||
|
> |
||||||
|
<div |
||||||
|
:class="{ |
||||||
|
'hover:bg-gray-100': editCommentValue?.id !== commentItem!.id, |
||||||
|
'nc-hovered-comment bg-gray-100': hoveredCommentId === commentItem!.id |
||||||
|
}" |
||||||
|
class="group gap-3 overflow-hidden px-3 py-2 transition-colors" |
||||||
|
> |
||||||
|
<div class="flex items-start justify-between"> |
||||||
|
<div |
||||||
|
class="flex items-start gap-3 flex-1" |
||||||
|
:class="{ |
||||||
|
'w-[calc(100%)] group-hover:w-[calc(100%_-_50px)]': !appInfo.ee, |
||||||
|
'w-[calc(100%_-_44px)] group-hover:w-[calc(100%_-_72px)]': appInfo.ee && commentItem.resolved_by, |
||||||
|
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_72px)]': |
||||||
|
appInfo.ee && !commentItem.resolved_by && hasEditPermission, |
||||||
|
'w-[calc(100%_-_16px)] group-hover:w-[calc(100%_-_44px)]': |
||||||
|
appInfo.ee && !commentItem.resolved_by && !hasEditPermission, |
||||||
|
}" |
||||||
|
> |
||||||
|
<GeneralUserIcon |
||||||
|
:email="commentItem.created_by_email" |
||||||
|
:name="commentItem.created_display_name" |
||||||
|
class="mt-0.5" |
||||||
|
size="medium" |
||||||
|
/> |
||||||
|
<div class="flex h-[28px] items-center gap-3 w-[calc(100%_-_40px)]"> |
||||||
|
<NcDropdown placement="topLeft" :trigger="['hover']" class="flex-none max-w-[calc(100%_-_72px)]"> |
||||||
|
<div class="truncate text-gray-800 font-medium !text-small !leading-[18px] overflow-hidden"> |
||||||
|
{{ createdBy(commentItem) }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<template #overlay> |
||||||
|
<div class="bg-white rounded-lg"> |
||||||
|
<div class="flex items-center gap-4 py-3 px-2"> |
||||||
|
<GeneralUserIcon |
||||||
|
class="!w-8 !h-8 border-1 border-gray-200 rounded-full" |
||||||
|
:name="commentItem.created_display_name" |
||||||
|
:email="commentItem.created_by_email" |
||||||
|
/> |
||||||
|
<div class="flex flex-col"> |
||||||
|
<div class="font-semibold text-gray-800"> |
||||||
|
{{ createdBy(commentItem) }} |
||||||
|
</div> |
||||||
|
<div class="text-xs text-gray-600"> |
||||||
|
{{ commentItem.created_by_email }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
v-if="isUIAllowed('dataEdit')" |
||||||
|
class="px-3 rounded-b-lg !text-[13px] items-center text-gray-600 flex gap-1 bg-gray-100 py-1.5" |
||||||
|
> |
||||||
|
Has <RolesBadge size="sm" :border="false" :role="getUserRole(commentItem.created_by_email!)" /> |
||||||
|
role in base |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</NcDropdown> |
||||||
|
<div class="text-xs text-gray-500"> |
||||||
|
{{ timeAgo(commentItem.created_at!) }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div class="flex items-center"> |
||||||
|
<NcDropdown |
||||||
|
v-if="!editCommentValue" |
||||||
|
class="nc-comment-more-actions !hidden !group-hover:block" |
||||||
|
overlay-class-name="!min-w-[160px]" |
||||||
|
placement="bottomRight" |
||||||
|
> |
||||||
|
<NcButton |
||||||
|
class="nc-expand-form-more-actions !hover:bg-gray-200 !w-7 !h-7 !bg-transparent" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
> |
||||||
|
<GeneralIcon class="text-md" icon="threeDotVertical" /> |
||||||
|
</NcButton> |
||||||
|
<template #overlay> |
||||||
|
<NcMenu> |
||||||
|
<NcMenuItem |
||||||
|
v-if="user && commentItem.created_by_email === user.email && hasEditPermission" |
||||||
|
v-e="['c:comment-expand:comment:edit']" |
||||||
|
class="text-gray-700" |
||||||
|
@click="editComment(commentItem)" |
||||||
|
> |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<component :is="iconMap.rename" class="cursor-pointer" /> |
||||||
|
{{ $t('general.edit') }} |
||||||
|
</div> |
||||||
|
</NcMenuItem> |
||||||
|
<NcMenuItem v-e="['c:comment-expand:comment:copy']" class="text-gray-700" @click="copyComment(commentItem)"> |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<component :is="iconMap.copy" class="cursor-pointer" /> |
||||||
|
{{ $t('general.copy') }} URL |
||||||
|
</div> |
||||||
|
</NcMenuItem> |
||||||
|
<template v-if="user && commentItem.created_by_email === user.email && hasEditPermission"> |
||||||
|
<NcDivider /> |
||||||
|
<NcMenuItem |
||||||
|
v-e="['c:row-expand:comment:delete']" |
||||||
|
class="!text-red-500 !hover:bg-red-50" |
||||||
|
@click="deleteComment(commentItem.id!)" |
||||||
|
> |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<component :is="iconMap.delete" class="cursor-pointer" /> |
||||||
|
{{ $t('general.delete') }} |
||||||
|
</div> |
||||||
|
</NcMenuItem> |
||||||
|
</template> |
||||||
|
</NcMenu> |
||||||
|
</template> |
||||||
|
</NcDropdown> |
||||||
|
<div v-if="appInfo.ee"> |
||||||
|
<NcTooltip v-if="!commentItem.resolved_by && hasEditPermission"> |
||||||
|
<NcButton |
||||||
|
class="nc-resolve-comment-btn !w-7 !h-7 !bg-transparent !hover:bg-gray-200 !hidden !group-hover:block" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="resolveComment(commentItem.id!)" |
||||||
|
> |
||||||
|
<GeneralIcon class="text-md" icon="checkCircle" /> |
||||||
|
</NcButton> |
||||||
|
|
||||||
|
<template #title>Click to resolve </template> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<NcTooltip v-else-if="commentItem.resolved_by"> |
||||||
|
<template #title>{{ `Resolved by ${commentItem.resolved_display_name}` }}</template> |
||||||
|
<NcButton |
||||||
|
class="!h-7 !w-7 !bg-transparent !hover:bg-gray-200 text-semibold" |
||||||
|
size="xsmall" |
||||||
|
type="text" |
||||||
|
@click="resolveComment(commentItem.id!)" |
||||||
|
> |
||||||
|
<GeneralIcon class="text-md rounded-full bg-[#17803D] text-white" icon="checkFill" /> |
||||||
|
</NcButton> |
||||||
|
</NcTooltip> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
:class="{ |
||||||
|
'mt-3': commentItem.id === editCommentValue?.id, |
||||||
|
}" |
||||||
|
class="flex-1 flex flex-col gap-1 max-w-[calc(100%)]" |
||||||
|
> |
||||||
|
<SmartsheetExpandedFormRichComment |
||||||
|
v-if="commentItem.id === editCommentValue?.id && hasEditPermission" |
||||||
|
v-model:value="value" |
||||||
|
autofocus |
||||||
|
:hide-options="false" |
||||||
|
class="expanded-form-comment-edit-input cursor-text expanded-form-comment-input !py-2 !px-2 !m-0 w-full !border-1 !border-gray-200 !rounded-lg !bg-white !text-gray-800 !text-small !leading-18px !max-h-[240px]" |
||||||
|
data-testid="expanded-form-comment-input" |
||||||
|
sync-value-change |
||||||
|
@save="onEditComment" |
||||||
|
@keydown.esc="onCancel" |
||||||
|
@blur=" |
||||||
|
() => { |
||||||
|
editCommentValue = undefined |
||||||
|
isEditing = false |
||||||
|
} |
||||||
|
" |
||||||
|
@keydown.enter.exact.prevent="onEditComment" |
||||||
|
/> |
||||||
|
|
||||||
|
<div v-else class="space-y-1 pl-9"> |
||||||
|
<SmartsheetExpandedFormRichComment |
||||||
|
:value="`${commentItem.comment} ${editedAt(commentItem)}`" |
||||||
|
class="!text-small !leading-18px !text-gray-800 -ml-1" |
||||||
|
read-only |
||||||
|
sync-value-change |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-if="hasEditPermission" class="px-3 pb-3 nc-comment-input !rounded-br-2xl gap-2 flex"> |
||||||
|
<SmartsheetExpandedFormRichComment |
||||||
|
ref="commentInputRef" |
||||||
|
v-model:value="comment" |
||||||
|
:hide-options="false" |
||||||
|
placeholder="Comment..." |
||||||
|
class="expanded-form-comment-input !py-2 !px-2 cursor-text border-1 rounded-lg w-full bg-transparent !text-gray-800 !text-small !leading-18px !max-h-[240px]" |
||||||
|
:autofocus="isExpandedFormCommentMode" |
||||||
|
data-testid="expanded-form-comment-input" |
||||||
|
@focus="isExpandedFormCommentMode = false" |
||||||
|
@keydown.stop |
||||||
|
@save="saveComment" |
||||||
|
@keydown.enter.exact.prevent="saveComment" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
:deep(.expanded-form-comment-input) { |
||||||
|
@apply transition-all duration-150 min-h-8; |
||||||
|
box-shadow: none; |
||||||
|
&:focus, |
||||||
|
&:focus-within { |
||||||
|
@apply min-h-16 !bg-white border-brand-500; |
||||||
|
box-shadow: 0px 0px 0px 2px rgba(51, 102, 255, 0.24); |
||||||
|
} |
||||||
|
&::placeholder { |
||||||
|
@apply !text-gray-400; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.expanded-form-comment-edit-input .nc-comment-rich-editor) { |
||||||
|
@apply bg-white; |
||||||
|
} |
||||||
|
|
||||||
|
.nc-hovered-comment { |
||||||
|
.nc-expand-form-more-actions, |
||||||
|
.nc-resolve-comment-btn { |
||||||
|
@apply !block; |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,80 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
const { appInfo } = useGlobal() |
||||||
|
|
||||||
|
const tab = ref<'comments' | 'audits'>('comments') |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col bg-white !h-full w-full rounded-br-2xl overflow-hidden"> |
||||||
|
<NcTabs v-model:activeKey="tab" class="h-full"> |
||||||
|
<a-tab-pane key="comments" class="w-full h-full"> |
||||||
|
<template #tab> |
||||||
|
<div v-e="['c:row-expand:comment']" class="flex items-center gap-2"> |
||||||
|
<GeneralIcon icon="messageCircle" class="w-4 h-4" /> |
||||||
|
<span class="<lg:hidden"> Comments </span> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<SmartsheetExpandedFormSidebarComments /> |
||||||
|
</a-tab-pane> |
||||||
|
|
||||||
|
<a-tab-pane key="audits" class="w-full" :disabled="appInfo.ee"> |
||||||
|
<template #tab> |
||||||
|
<NcTooltip v-if="appInfo.ee" class="tab flex-1"> |
||||||
|
<template #title>{{ $t('title.comingSoon') }}</template> |
||||||
|
|
||||||
|
<div v-e="['c:row-expand:audit']" class="flex items-center gap-2 text-gray-400"> |
||||||
|
<GeneralIcon icon="audit" class="w-4 h-4" /> |
||||||
|
<span class="<lg:hidden"> Audits </span> |
||||||
|
</div> |
||||||
|
</NcTooltip> |
||||||
|
|
||||||
|
<div v-else v-e="['c:row-expand:audit']" class="flex items-center gap-2"> |
||||||
|
<GeneralIcon icon="audit" class="w-4 h-4" /> |
||||||
|
<span class="<lg:hidden"> Audits </span> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<SmartsheetExpandedFormSidebarAudits /> |
||||||
|
</a-tab-pane> |
||||||
|
</NcTabs> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss" scoped> |
||||||
|
.tab { |
||||||
|
@apply max-w-1/2; |
||||||
|
} |
||||||
|
|
||||||
|
.tab .tab-title { |
||||||
|
@apply min-w-0 flex justify-center gap-2 font-semibold items-center; |
||||||
|
word-break: 'keep-all'; |
||||||
|
white-space: 'nowrap'; |
||||||
|
display: 'inline'; |
||||||
|
} |
||||||
|
|
||||||
|
.text-decoration-line-through { |
||||||
|
text-decoration: line-through; |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.ant-tabs) { |
||||||
|
@apply !overflow-visible; |
||||||
|
.ant-tabs-nav { |
||||||
|
@apply px-3 bg-white; |
||||||
|
.ant-tabs-nav-list { |
||||||
|
@apply w-[99%] mx-auto gap-6; |
||||||
|
|
||||||
|
.ant-tabs-tab { |
||||||
|
@apply flex-1 flex items-center justify-center pt-3 pb-2.5; |
||||||
|
|
||||||
|
& + .ant-tabs-tab { |
||||||
|
@apply !ml-0; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
.ant-tabs-content-holder { |
||||||
|
.ant-tabs-content { |
||||||
|
@apply h-full; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,235 @@ |
|||||||
|
import type { ColumnType, CommentType, TableType } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const [useProvideRowComments, useRowComments] = useInjectionState((meta: Ref<TableType>, row: Ref<Row>) => { |
||||||
|
const isCommentsLoading = ref(false) |
||||||
|
|
||||||
|
const { isUIAllowed } = useRoles() |
||||||
|
|
||||||
|
const { $e, $state, $api } = useNuxtApp() |
||||||
|
|
||||||
|
const comments = ref< |
||||||
|
Array< |
||||||
|
CommentType & { |
||||||
|
created_display_name: string |
||||||
|
resolved_display_name?: string |
||||||
|
} |
||||||
|
> |
||||||
|
>([]) |
||||||
|
|
||||||
|
const basesStore = useBases() |
||||||
|
|
||||||
|
const { basesUser } = storeToRefs(basesStore) |
||||||
|
|
||||||
|
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : [])) |
||||||
|
|
||||||
|
const loadComments = async (_rowId?: string, ignoreLoadingIndicator = true) => { |
||||||
|
if (!isUIAllowed('commentList') || (!row.value && !_rowId)) return |
||||||
|
|
||||||
|
const rowId = _rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) |
||||||
|
|
||||||
|
if (!rowId) return |
||||||
|
|
||||||
|
try { |
||||||
|
if (!ignoreLoadingIndicator) isCommentsLoading.value = true |
||||||
|
|
||||||
|
const res = (( |
||||||
|
await $api.utils.commentList({ |
||||||
|
row_id: rowId, |
||||||
|
fk_model_id: meta.value.id as string, |
||||||
|
}) |
||||||
|
).list || []) as Array< |
||||||
|
CommentType & { |
||||||
|
created_display_name: string |
||||||
|
} |
||||||
|
> |
||||||
|
|
||||||
|
comments.value = res.map((comment) => { |
||||||
|
const user = baseUsers.value.find((u) => u.id === comment.created_by) |
||||||
|
const resolvedUser = comment.resolved_by ? baseUsers.value.find((u) => u.id === comment.resolved_by) : null |
||||||
|
return { |
||||||
|
...comment, |
||||||
|
created_display_name: user?.display_name ?? (user?.email ?? '').split('@')[0], |
||||||
|
resolved_display_name: resolvedUser ? resolvedUser.display_name ?? resolvedUser.email.split('@')[0] : undefined, |
||||||
|
} |
||||||
|
}) |
||||||
|
} catch (e: unknown) { |
||||||
|
message.error( |
||||||
|
await extractSdkResponseErrorMsg( |
||||||
|
e as Error & { |
||||||
|
response: any |
||||||
|
}, |
||||||
|
), |
||||||
|
) |
||||||
|
} finally { |
||||||
|
if (!ignoreLoadingIndicator) isCommentsLoading.value = false |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const deleteComment = async (commentId: string) => { |
||||||
|
if (!isUIAllowed('commentDelete')) return |
||||||
|
const tempC = comments.value.find((c) => c.id === commentId) |
||||||
|
|
||||||
|
if (!tempC) return |
||||||
|
|
||||||
|
try { |
||||||
|
comments.value = comments.value.filter((c) => c.id !== commentId) |
||||||
|
|
||||||
|
await $api.utils.commentDelete(commentId) |
||||||
|
|
||||||
|
// update comment count in rowMeta
|
||||||
|
Object.assign(row.value, { |
||||||
|
...row.value, |
||||||
|
rowMeta: { |
||||||
|
...row.value.rowMeta, |
||||||
|
commentCount: (row.value.rowMeta.commentCount ?? 1) - 1, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} catch (e: unknown) { |
||||||
|
message.error( |
||||||
|
await extractSdkResponseErrorMsg( |
||||||
|
e as Error & { |
||||||
|
response: any |
||||||
|
}, |
||||||
|
), |
||||||
|
) |
||||||
|
comments.value = [...comments.value, tempC] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const resolveComment = async (commentId: string) => { |
||||||
|
if (!isUIAllowed('commentResolve')) return |
||||||
|
const tempC = comments.value.find((c) => c.id === commentId) |
||||||
|
|
||||||
|
if (!tempC) return |
||||||
|
|
||||||
|
try { |
||||||
|
comments.value = comments.value.map((c) => { |
||||||
|
if (c.id === commentId) { |
||||||
|
return { |
||||||
|
...c, |
||||||
|
resolved_by: tempC.resolved_by ? undefined : $state.user?.value?.id, |
||||||
|
resolved_by_email: tempC.resolved_by ? undefined : $state.user?.value?.email, |
||||||
|
resolved_display_name: tempC.resolved_by |
||||||
|
? undefined |
||||||
|
: $state.user?.value?.display_name ?? $state.user?.value?.email.split('@')[0], |
||||||
|
} |
||||||
|
} |
||||||
|
return c |
||||||
|
}) |
||||||
|
await $api.utils.commentResolve(commentId, {}) |
||||||
|
} catch (e: unknown) { |
||||||
|
comments.value = comments.value.map((c) => { |
||||||
|
if (c.id === commentId) { |
||||||
|
return tempC |
||||||
|
} |
||||||
|
return c |
||||||
|
}) |
||||||
|
message.error( |
||||||
|
await extractSdkResponseErrorMsg( |
||||||
|
e as Error & { |
||||||
|
response: any |
||||||
|
}, |
||||||
|
), |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const saveComment = async (comment: string) => { |
||||||
|
try { |
||||||
|
if (!row.value || !comment) { |
||||||
|
comments.value = comments.value.filter((c) => !c.id?.startsWith('temp-')) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const rowId = extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) |
||||||
|
|
||||||
|
if (!rowId) return |
||||||
|
|
||||||
|
await $api.utils.commentRow({ |
||||||
|
fk_model_id: meta.value?.id as string, |
||||||
|
row_id: rowId, |
||||||
|
comment: `${comment}`.replace(/(<br \/>)+$/g, ''), |
||||||
|
}) |
||||||
|
|
||||||
|
// Increase Comment Count in rowMeta
|
||||||
|
Object.assign(row.value, { |
||||||
|
rowMeta: { |
||||||
|
...row.value.rowMeta, |
||||||
|
commentCount: (row.value.rowMeta.commentCount ?? 0) + 1, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
// reloadTrigger?.trigger()
|
||||||
|
|
||||||
|
await loadComments() |
||||||
|
} catch (e: any) { |
||||||
|
comments.value = comments.value.filter((c) => !(c.id ?? '').startsWith('temp-')) |
||||||
|
message.error( |
||||||
|
await extractSdkResponseErrorMsg( |
||||||
|
e as Error & { |
||||||
|
response: any |
||||||
|
}, |
||||||
|
), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
$e('a:row-expand:comment') |
||||||
|
} |
||||||
|
|
||||||
|
const updateComment = async (commentId: string, comment: Partial<CommentType>) => { |
||||||
|
const tempEdit = comments.value.find((c) => c.id === commentId) |
||||||
|
if (!tempEdit) return |
||||||
|
try { |
||||||
|
comments.value = comments.value.map((c) => { |
||||||
|
if (c.id === commentId) { |
||||||
|
return { |
||||||
|
...c, |
||||||
|
...comment, |
||||||
|
updated_at: new Date().toISOString(), |
||||||
|
} |
||||||
|
} |
||||||
|
return c |
||||||
|
}) |
||||||
|
await $api.utils.commentUpdate(commentId, comment) |
||||||
|
} catch (e: any) { |
||||||
|
comments.value = comments.value.map((c) => { |
||||||
|
if (c.id === commentId) { |
||||||
|
return tempEdit |
||||||
|
} |
||||||
|
return c |
||||||
|
}) |
||||||
|
message.error( |
||||||
|
await extractSdkResponseErrorMsg( |
||||||
|
e as Error & { |
||||||
|
response: any |
||||||
|
}, |
||||||
|
), |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const primaryKey = computed(() => { |
||||||
|
return extractPkFromRow(row.value.row, meta.value.columns as ColumnType[]) |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
comments, |
||||||
|
loadComments, |
||||||
|
saveComment, |
||||||
|
updateComment, |
||||||
|
resolveComment, |
||||||
|
deleteComment, |
||||||
|
isCommentsLoading, |
||||||
|
primaryKey, |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
export { useProvideRowComments } |
||||||
|
|
||||||
|
export function useRowCommentsOrThrow() { |
||||||
|
const rowComments = useRowComments() |
||||||
|
if (!rowComments) { |
||||||
|
throw new Error('useRowComments is not provided') |
||||||
|
} |
||||||
|
return rowComments |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
import PDFObjectPlugin from 'pdfobject-vue' |
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => { |
||||||
|
nuxtApp.vueApp.use(PDFObjectPlugin) |
||||||
|
}) |
Loading…
Reference in new issue