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> |
||||
import { onKeyDown } from '@vueuse/core' |
||||
import type { CarouselApi } from '../../nc/Carousel/interface' |
||||
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() |
||||
|
||||
/** navigate to previous image on button click */ |
||||
onKeyDown( |
||||
(e) => ['Left', 'ArrowLeft', 'A'].includes(e.key), |
||||
() => { |
||||
if (carouselRef.value) carouselRef.value.prev() |
||||
}, |
||||
) |
||||
|
||||
/** navigate to next image on button click */ |
||||
onKeyDown( |
||||
(e) => ['Right', 'ArrowRight', 'D'].includes(e.key), |
||||
() => { |
||||
if (carouselRef.value) carouselRef.value.next() |
||||
}, |
||||
) |
||||
|
||||
/** set our selected image when slide changes */ |
||||
function onSlideChange(index: number) { |
||||
selectedImage.value = imageItems.value[index] |
||||
useEventListener(container, 'click', (e) => { |
||||
const target = e.target as HTMLElement |
||||
if (!target.closest('.keep-open') && !target.closest('.nc-button') && !target.closest('img') && !target.closest('video')) { |
||||
selectedFile.value = false |
||||
} |
||||
}) |
||||
|
||||
const onThumbClick = (index: number) => { |
||||
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||
|
||||
emblaMainApi.value.scrollTo(index) |
||||
emblaThumbnailApi.value.scrollTo(index) |
||||
} |
||||
|
||||
const onSelect = () => { |
||||
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||
|
||||
const newSnap = emblaMainApi.value.selectedScrollSnap() |
||||
|
||||
selectedIndex.value = newSnap |
||||
selectedFile.value = visibleItems.value[newSnap] |
||||
emblaThumbnailApi.value.scrollTo(newSnap) |
||||
} |
||||
|
||||
/** set our carousel ref and move to initial slide */ |
||||
const setCarouselRef = (el: Element) => { |
||||
carouselRef.value = el |
||||
const goPrev = () => { |
||||
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||
|
||||
carouselRef.value?.goTo( |
||||
imageItems.value.findIndex((item) => item === selectedImage.value), |
||||
true, |
||||
) |
||||
emblaMainApi.value.scrollPrev() |
||||
emblaThumbnailApi.value.scrollPrev() |
||||
} |
||||
|
||||
/** close overlay view when clicking outside of image */ |
||||
useEventListener(container, 'click', (e) => { |
||||
if (!(e.target as HTMLElement)?.closest('.keep-open') && !(e.target as HTMLElement)?.closest('img')) { |
||||
selectedImage.value = false |
||||
const goNext = () => { |
||||
if (!emblaMainApi.value || !emblaThumbnailApi.value) return |
||||
|
||||
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> |
||||
|
||||
<template> |
||||
<GeneralOverlay v-model="selectedImage" :z-index="1001" class="bg-gray-500 bg-opacity-50"> |
||||
<template v-if="selectedImage"> |
||||
<div ref="container" class="overflow-hidden p-12 text-center relative xs:h-screen"> |
||||
<div class="text-white group absolute top-5 right-5"> |
||||
<component |
||||
:is="iconMap.closeCircle" |
||||
class="group-hover:text-red-500 cursor-pointer text-4xl" |
||||
@click.stop="selectedImage = false" |
||||
/> |
||||
</div> |
||||
<GeneralOverlay v-model="selectedFile" transition :z-index="isExpandedFormOpen ? 1000 : 50" class="bg-black bg-opacity-90"> |
||||
<div class="flex w-full h-full"> |
||||
<div |
||||
v-if="selectedFile" |
||||
ref="container" |
||||
class="flex w-full overflow-hidden justify-center text-center relative h-screen items-center" |
||||
> |
||||
<NcButton |
||||
class="top-5 !absolute cursor-pointer !z-30 !hover:bg-transparent left-5" |
||||
size="xsmall" |
||||
type="text" |
||||
@click.stop="selectedFile = false" |
||||
> |
||||
<component :is="iconMap.close" class="text-white" /> |
||||
</NcButton> |
||||
|
||||
<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" |
||||
@click.stop="downloadAttachment(selectedImage)" |
||||
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" |
||||
> |
||||
<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> |
||||
|
||||
<a-carousel |
||||
v-if="!!selectedImage" |
||||
:ref="setCarouselRef" |
||||
dots-class="slick-dots slick-thumb" |
||||
:after-change="onSlideChange" |
||||
arrows |
||||
<NcCarousel class="!absolute inset-y-16 inset-x-24 keep-open flex justify-center items-center" @init-api="initEmblaApi"> |
||||
<NcCarouselContent> |
||||
<NcCarouselItem v-for="(item, index) in visibleItems" :key="index"> |
||||
<div v-if="selectedIndex === index" class="justify-center w-full h-full flex items-center"> |
||||
<LazyCellAttachmentPreviewImage |
||||
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> |
||||
<div class="custom-slick-arrow left-2 z-1 keep-open"> |
||||
<MaterialSymbolsArrowCircleLeftRounded class="rounded-full" /> |
||||
</div> |
||||
</template> |
||||
<component :is="iconMap.arrowLeft" class="text-7xl" /> |
||||
</div> |
||||
<div |
||||
v-if="emblaMainApi?.canScrollNext()" |
||||
:key="selectedIndex" |
||||
class="right-2 carousel-navigation keep-open" |
||||
@click="goNext" |
||||
> |
||||
<component :is="iconMap.arrowRight" class="text-7xl" /> |
||||
</div> |
||||
|
||||
<template #nextArrow> |
||||
<div class="custom-slick-arrow !right-2 z-1 keep-open"> |
||||
<MaterialSymbolsArrowCircleRightRounded class="rounded-full" /> |
||||
<!-- <div v-if="isUIAllowed('commentList') && !isExpandedFormOpen" class="absolute top-2 right-2"> |
||||
<NcButton class="!hover:bg-transparent" type="text" size="small" @click="toggleComment"> |
||||
<div class="flex gap-1 text-white justify-center items-center"> |
||||
Comments |
||||
<GeneralIcon icon="messageCircle" /> |
||||
</div> |
||||
</template> |
||||
</NcButton> |
||||
</div> --> |
||||
|
||||
<template #customPaging="props"> |
||||
<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> |
||||
<div class="text-white absolute right-2 top-2 cursor-pointer"></div> |
||||
|
||||
<style scoped> |
||||
.ant-carousel :deep(.custom-slick-arrow .nc-icon):hover { |
||||
@apply !bg-white; |
||||
} |
||||
.ant-carousel :deep(.slick-dots) { |
||||
@apply relative mt-4; |
||||
} |
||||
<div class="absolute w-full !bottom-2 max-h-18 z-30 flex items-center justify-center"> |
||||
<NcCarousel class="absolute max-w-sm" @init-api="(val) => (emblaThumbnailApi = val)"> |
||||
<NcCarouselContent class="!flex !gap-2"> |
||||
<NcCarouselItem |
||||
v-for="(item, index) in visibleItems" |
||||
: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) { |
||||
@apply w-full; |
||||
} |
||||
<div |
||||
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) { |
||||
@apply border-1 m-auto; |
||||
} |
||||
<div v-else 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="file" /> |
||||
</div> |
||||
</div> |
||||
</NcCarouselItem> |
||||
</NcCarouselContent> |
||||
</NcCarousel> |
||||
</div> |
||||
|
||||
.ant-carousel :deep(.slick-thumb) { |
||||
@apply bottom-2; |
||||
} |
||||
<div class="absolute keep-open right-2 z-30 bottom-3 transition-all gap-3 transition-ease-in-out !h-6 flex items-center"> |
||||
<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) { |
||||
@apply w-[60px] h-[45px]; |
||||
} |
||||
<NcTooltip v-if="!isReadonly" color="light" placement="bottom"> |
||||
<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) { |
||||
@apply w-full h-full block; |
||||
filter: grayscale(100%); |
||||
} |
||||
<NcTooltip v-if="!isReadonly" color="light" placement="bottom"> |
||||
<template #title> {{ $t('title.removeFile') }} </template> |
||||
<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) { |
||||
filter: grayscale(0%); |
||||
<style scoped lang="scss"> |
||||
.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) { |
||||
@apply text-4xl text-white hover:text-primary active:text-accent opacity-100 cursor-pointer z-1; |
||||
} |
||||
.ant-carousel :deep(.custom-slick-arrow:before) { |
||||
display: none; |
||||
} |
||||
.ant-carousel :deep(.custom-slick-arrow:hover) { |
||||
opacity: 0.5; |
||||
<style lang="scss"> |
||||
.nc-attachment-carousel { |
||||
@apply w-max; |
||||
} |
||||
|
||||
.nc-attachment-img-wrapper { |
||||
width: fit-content !important; |
||||
.carousel-container { |
||||
@apply !w-full flex items-center h-full; |
||||
|
||||
.embla__container { |
||||
@apply items-center h-full w-full; |
||||
} |
||||
} |
||||
</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