mirror of https://github.com/nocodb/nocodb
navi
2 years ago
committed by
GitHub
28 changed files with 1894 additions and 1133 deletions
@ -1,338 +0,0 @@ |
|||||||
<script setup lang="ts"> |
|
||||||
import { useToast } from 'vue-toastification' |
|
||||||
import { inject, ref, useProject, watchEffect } from '#imports' |
|
||||||
import { useNuxtApp } from '#app' |
|
||||||
import { ColumnInj, MetaInj } from '~/context' |
|
||||||
import { NOCO } from '~/lib' |
|
||||||
import { isImage } from '~/utils' |
|
||||||
import MaterialPlusIcon from '~icons/mdi/plus' |
|
||||||
import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand' |
|
||||||
|
|
||||||
interface Props { |
|
||||||
modelValue: string | any[] | null |
|
||||||
} |
|
||||||
|
|
||||||
const { modelValue } = defineProps<Props>() |
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']) |
|
||||||
|
|
||||||
const isPublicForm = inject<boolean>('isPublicForm', false) |
|
||||||
const isForm = inject<boolean>('isForm', false) |
|
||||||
const meta = inject(MetaInj) |
|
||||||
const column = inject(ColumnInj) |
|
||||||
const editEnabled = inject<boolean>('editEnabled', false) |
|
||||||
|
|
||||||
const localFilesState = reactive([]) |
|
||||||
const attachments = ref([]) |
|
||||||
const uploading = ref(false) |
|
||||||
const fileInput = ref<HTMLInputElement>() |
|
||||||
|
|
||||||
const { $api } = useNuxtApp() |
|
||||||
const { project } = useProject() |
|
||||||
const toast = useToast() |
|
||||||
|
|
||||||
watchEffect(() => { |
|
||||||
if (modelValue) { |
|
||||||
attachments.value = ((typeof modelValue === 'string' ? JSON.parse(modelValue) : modelValue) || []).filter(Boolean) |
|
||||||
} |
|
||||||
}) |
|
||||||
|
|
||||||
const selectImage = (file: any, i: unknown) => { |
|
||||||
// todo: implement |
|
||||||
} |
|
||||||
|
|
||||||
const openUrl = (url: string, target = '_blank') => { |
|
||||||
window.open(url, target) |
|
||||||
} |
|
||||||
|
|
||||||
const addFile = () => { |
|
||||||
fileInput.value?.click() |
|
||||||
} |
|
||||||
|
|
||||||
const onFileSelection = async (e: unknown) => { |
|
||||||
// if (this.isPublicGrid) { |
|
||||||
// return |
|
||||||
// } |
|
||||||
// if (!this.$refs.file.files || !this.$refs.file.files.length) { |
|
||||||
// return |
|
||||||
// } |
|
||||||
|
|
||||||
// if (this.isPublicForm) { |
|
||||||
// this.localFilesState.push(...Array.from(this.$refs.file.files).map((file) => { |
|
||||||
// const res = { file, title: file.name } |
|
||||||
// if (isImage(file.name, file.mimetype)) { |
|
||||||
// const reader = new FileReader() |
|
||||||
// reader.onload = (e) => { |
|
||||||
// this.$set(res, 'data', e.target.result) |
|
||||||
// } |
|
||||||
// reader.readAsDataURL(file) |
|
||||||
// } |
|
||||||
// return res |
|
||||||
// })) |
|
||||||
// |
|
||||||
// this.$emit('input', this.localFilesState.map(f => f.file)) |
|
||||||
// return |
|
||||||
// } |
|
||||||
|
|
||||||
// todo : move to com |
|
||||||
uploading.value = true |
|
||||||
const newAttachments = [] |
|
||||||
for (const file of fileInput.value?.files ?? []) { |
|
||||||
try { |
|
||||||
const data = await $api.storage.upload( |
|
||||||
{ |
|
||||||
path: [NOCO, project.value.title, meta?.value?.title, column?.title].join('/'), |
|
||||||
}, |
|
||||||
{ |
|
||||||
files: file, |
|
||||||
json: '{}', |
|
||||||
}, |
|
||||||
) |
|
||||||
newAttachments.push(...data) |
|
||||||
} catch (e: any) { |
|
||||||
toast.error(e.message || 'Some internal error occurred') |
|
||||||
uploading.value = false |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
uploading.value = false |
|
||||||
emit('update:modelValue', JSON.stringify([...attachments.value, ...newAttachments])) |
|
||||||
|
|
||||||
// this.$emit('input', JSON.stringify(this.localState)) |
|
||||||
// this.$emit('update') |
|
||||||
} |
|
||||||
</script> |
|
||||||
|
|
||||||
<template> |
|
||||||
<div class="main h-100"> |
|
||||||
<div class="d-flex align-center img-container"> |
|
||||||
<div class="d-flex no-overflow"> |
|
||||||
<div |
|
||||||
v-for="(item, i) in isPublicForm ? localFilesState : attachments" |
|
||||||
:key="item.url || item.title" |
|
||||||
class="thumbnail align-center justify-center d-flex" |
|
||||||
> |
|
||||||
<!-- <v-tooltip bottom> --> |
|
||||||
<!-- <template #activator="{ on }"> --> |
|
||||||
<!-- <v-img |
|
||||||
v-if="isImage(item.title, item.mimetype)" |
|
||||||
lazy-src="https://via.placeholder.com/60.png?text=Loading..." |
|
||||||
alt="#" |
|
||||||
max-height="99px" |
|
||||||
contain |
|
||||||
:src="item.url || item.data" |
|
||||||
v-on="on" |
|
||||||
@click="selectImage(item.url || item.data, i)" |
|
||||||
> --> |
|
||||||
<img |
|
||||||
v-if="isImage(item.title, item.mimetype)" |
|
||||||
alt="#" |
|
||||||
style="max-height: 30px; max-width: 30px" |
|
||||||
:src="item.url || item.data" |
|
||||||
@click="selectImage(item.url || item.data, i)" |
|
||||||
/> |
|
||||||
<!-- <template #placeholder> --> |
|
||||||
<!-- <v-skeleton-loader type="image" :height="active ? 33 : 22" :width="active ? 33 : 22" /> --> |
|
||||||
<!-- </template> --> |
|
||||||
<v-icon v-else-if="item.icon" :size="active ? 33 : 22" v-on="on" @click="openUrl(item.url || item.data, '_blank')"> |
|
||||||
{{ item.icon }} |
|
||||||
</v-icon> |
|
||||||
<v-icon v-else :size="active ? 33 : 22" v-on="on" @click="openUrl(item.url || item.data, '_blank')"> mdi-file </v-icon> |
|
||||||
<!-- </template> --> |
|
||||||
<!-- <span>{{ item.title }}</span> --> |
|
||||||
<!-- </v-tooltip> --> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<!-- todo: hide or toggle based on ancestor --> |
|
||||||
<div class="add d-flex align-center justify-center px-1 nc-attachment-add" @click="addFile"> |
|
||||||
<v-icon v-if="uploading" small color="primary" class="nc-attachment-add-spinner"> mdi-loading mdi-spin</v-icon> |
|
||||||
<!-- <v-btn v-else-if="isForm" outlined x-small color="" text class="nc-attachment-add-btn"> |
|
||||||
<v-icon x-small color="" icon="MaterialPlusIcon"> mdi-plus </v-icon> |
|
||||||
Attachment |
|
||||||
</v-btn> |
|
||||||
<v-icon small color="primary nc-attachment-add-icon"> |
|
||||||
mdi-plus |
|
||||||
</v-icon> --> |
|
||||||
<MaterialPlusIcon /> |
|
||||||
</div> |
|
||||||
|
|
||||||
<v-spacer /> |
|
||||||
|
|
||||||
<MaterialArrowExpandIcon @click.stop="dialog = true" /> |
|
||||||
<!-- <v-icon class="expand-icon mr-1" x-small color="primary" @click.stop="dialog = true"> mdi-arrow-expand </v-icon> --> |
|
||||||
</div> |
|
||||||
|
|
||||||
<input ref="fileInput" type="file" multiple class="d-none" @change="onFileSelection" /> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
|
|
||||||
<style scoped lang="scss"> |
|
||||||
.thumbnail { |
|
||||||
height: 30px; |
|
||||||
width: 30px; |
|
||||||
margin: 2px; |
|
||||||
border-radius: 4px; |
|
||||||
|
|
||||||
img { |
|
||||||
max-height: 33px; |
|
||||||
max-width: 33px; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
.expand-icon { |
|
||||||
margin-left: 8px; |
|
||||||
border-radius: 2px; |
|
||||||
transition: 0.3s background-color; |
|
||||||
} |
|
||||||
|
|
||||||
.expand-icon:hover { |
|
||||||
background-color: var(--v-primary-lighten4); |
|
||||||
} |
|
||||||
|
|
||||||
/*.img-container { |
|
||||||
margin: 0 -2px; |
|
||||||
} |
|
||||||
|
|
||||||
.no-overflow { |
|
||||||
overflow: hidden; |
|
||||||
} |
|
||||||
|
|
||||||
.add { |
|
||||||
transition: 0.2s background-color; |
|
||||||
!*background-color: #666666ee;*! |
|
||||||
border-radius: 4px; |
|
||||||
height: 33px; |
|
||||||
margin: 5px 2px; |
|
||||||
} |
|
||||||
|
|
||||||
.add:hover { |
|
||||||
!*background-color: #66666699;*! |
|
||||||
} |
|
||||||
|
|
||||||
.thumbnail { |
|
||||||
height: 99px; |
|
||||||
width: 99px; |
|
||||||
margin: 2px; |
|
||||||
border-radius: 4px; |
|
||||||
} |
|
||||||
|
|
||||||
.thumbnail img { |
|
||||||
!*max-height: 33px;*! |
|
||||||
max-width: 99px; |
|
||||||
} |
|
||||||
|
|
||||||
.main { |
|
||||||
min-height: 20px; |
|
||||||
position: relative; |
|
||||||
height: auto; |
|
||||||
} |
|
||||||
|
|
||||||
.expand-icon { |
|
||||||
margin-left: 8px; |
|
||||||
border-radius: 2px; |
|
||||||
!*opacity: 0;*! |
|
||||||
transition: 0.3s background-color; |
|
||||||
} |
|
||||||
|
|
||||||
.expand-icon:hover { |
|
||||||
!*opacity: 1;*! |
|
||||||
background-color: var(--v-primary-lighten4); |
|
||||||
} |
|
||||||
|
|
||||||
.modal-thumbnail img { |
|
||||||
height: 50px; |
|
||||||
max-width: 100%; |
|
||||||
border-radius: 4px; |
|
||||||
} |
|
||||||
|
|
||||||
.modal-thumbnail { |
|
||||||
position: relative; |
|
||||||
margin: 10px 10px; |
|
||||||
} |
|
||||||
|
|
||||||
.remove-icon { |
|
||||||
position: absolute; |
|
||||||
top: 5px; |
|
||||||
right: 5px; |
|
||||||
} |
|
||||||
|
|
||||||
.modal-thumbnail-card { |
|
||||||
.download-icon { |
|
||||||
position: absolute; |
|
||||||
bottom: 5px; |
|
||||||
right: 5px; |
|
||||||
opacity: 0; |
|
||||||
transition: 0.4s opacity; |
|
||||||
} |
|
||||||
|
|
||||||
&:hover .download-icon { |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
.image-overlay-container { |
|
||||||
max-height: 100vh; |
|
||||||
overflow-y: auto; |
|
||||||
position: relative; |
|
||||||
} |
|
||||||
|
|
||||||
.image-overlay-container .close-icon { |
|
||||||
position: fixed; |
|
||||||
top: 15px; |
|
||||||
right: 15px; |
|
||||||
} |
|
||||||
|
|
||||||
.overlay-thumbnail { |
|
||||||
transition: 0.4s transform, 0.4s opacity; |
|
||||||
opacity: 0.5; |
|
||||||
} |
|
||||||
|
|
||||||
.overlay-thumbnail.active { |
|
||||||
transform: scale(1.4); |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
|
|
||||||
.overlay-thumbnail:hover { |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
|
|
||||||
.modal-title { |
|
||||||
text-overflow: ellipsis; |
|
||||||
white-space: nowrap; |
|
||||||
width: 100%; |
|
||||||
overflow: hidden; |
|
||||||
} |
|
||||||
|
|
||||||
.modal-thumbnail-card { |
|
||||||
transition: 0.4s transform; |
|
||||||
} |
|
||||||
|
|
||||||
.modal-thumbnail-card:hover { |
|
||||||
transform: scale(1.05); |
|
||||||
} |
|
||||||
|
|
||||||
.drop-overlay { |
|
||||||
z-index: 5; |
|
||||||
position: absolute; |
|
||||||
width: 100%; |
|
||||||
height: 100%; |
|
||||||
left: 0; |
|
||||||
right: 0; |
|
||||||
top: 0; |
|
||||||
bottom: 5px; |
|
||||||
background: #aaaaaa44; |
|
||||||
display: flex; |
|
||||||
justify-content: center; |
|
||||||
align-items: center; |
|
||||||
pointer-events: none; |
|
||||||
} |
|
||||||
|
|
||||||
.expand-icon { |
|
||||||
opacity: 0; |
|
||||||
transition: 0.4s opacity; |
|
||||||
} |
|
||||||
|
|
||||||
.main:hover .expand-icon { |
|
||||||
opacity: 1; |
|
||||||
}*/ |
|
||||||
</style> |
|
@ -0,0 +1,148 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { onKeyDown } from '@vueuse/core' |
||||||
|
import { useAttachmentCell } from './utils' |
||||||
|
import { isImage } from '~/utils' |
||||||
|
import { computed, onClickOutside, ref, watch } from '#imports' |
||||||
|
import MaterialSymbolsArrowCircleRightRounded from '~icons/material-symbols/arrow-circle-right-rounded' |
||||||
|
import MaterialSymbolsArrowCircleLeftRounded from '~icons/material-symbols/arrow-circle-left-rounded' |
||||||
|
import MdiCloseCircle from '~icons/mdi/close-circle' |
||||||
|
|
||||||
|
const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()! |
||||||
|
|
||||||
|
const carouselRef = ref() |
||||||
|
|
||||||
|
const imageItems = computed(() => visibleItems.value.filter((item) => isImage(item.title, item.mimetype))) |
||||||
|
|
||||||
|
/** 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] |
||||||
|
} |
||||||
|
|
||||||
|
/** set our carousel ref and move to initial slide */ |
||||||
|
const setCarouselRef = (el: Element) => { |
||||||
|
carouselRef.value = el |
||||||
|
|
||||||
|
carouselRef.value?.goTo( |
||||||
|
imageItems.value.findIndex((item) => item === selectedImage.value), |
||||||
|
true, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
/** close overlay view when clicking outside of image */ |
||||||
|
onClickOutside(carouselRef, () => { |
||||||
|
selectedImage.value = false |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<general-overlay v-model="selectedImage"> |
||||||
|
<template v-if="selectedImage"> |
||||||
|
<div class="overflow-hidden p-12 text-center relative"> |
||||||
|
<div class="text-white group absolute top-5 right-5"> |
||||||
|
<MdiCloseCircle class="group-hover:text-red-500 cursor-pointer text-4xl" @click.stop="selectedImage = false" /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div |
||||||
|
class="select-none group hover:ring active:ring-pink-500 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow" |
||||||
|
@click.stop="downloadFile(selectedImage)" |
||||||
|
> |
||||||
|
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3> |
||||||
|
</div> |
||||||
|
|
||||||
|
<a-carousel |
||||||
|
v-if="!!selectedImage" |
||||||
|
:ref="setCarouselRef" |
||||||
|
dots-class="slick-dots slick-thumb" |
||||||
|
:after-change="onSlideChange" |
||||||
|
arrows |
||||||
|
> |
||||||
|
<template #prevArrow> |
||||||
|
<div class="custom-slick-arrow left-2 z-1"> |
||||||
|
<MaterialSymbolsArrowCircleLeftRounded class="bg-white rounded-full" /> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<template #nextArrow> |
||||||
|
<div class="custom-slick-arrow !right-2 z-1"> |
||||||
|
<MaterialSymbolsArrowCircleRightRounded class="bg-white rounded-full" /> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<template #customPaging="props"> |
||||||
|
<a> |
||||||
|
<nuxt-img |
||||||
|
class="!block" |
||||||
|
:alt="imageItems[props.i].title || `#${props.i}`" |
||||||
|
:src="imageItems[props.i].url || imageItems[props.i].data" |
||||||
|
/> |
||||||
|
</a> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div v-for="item of imageItems" :key="item.url"> |
||||||
|
<div |
||||||
|
:style="{ backgroundImage: `url('${item.url}')` }" |
||||||
|
class="min-w-70vw min-h-70vh w-full h-full bg-contain bg-center bg-no-repeat" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</a-carousel> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</general-overlay> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
.ant-carousel :deep(.slick-dots) { |
||||||
|
@apply relative mt-4; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-slide) { |
||||||
|
@apply w-full; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-slide img) { |
||||||
|
@apply border-1 m-auto; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-thumb) { |
||||||
|
@apply bottom-2; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-thumb li) { |
||||||
|
@apply w-[60px] h-[45px]; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-thumb li img) { |
||||||
|
@apply w-full h-full block; |
||||||
|
filter: grayscale(100%); |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep .slick-thumb li.slick-active img { |
||||||
|
filter: grayscale(0%); |
||||||
|
} |
||||||
|
|
||||||
|
.ant-carousel :deep(.slick-arrow.custom-slick-arrow) { |
||||||
|
@apply text-4xl text-white hover:text-primary active:text-pink-500 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> |
@ -0,0 +1,211 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { onKeyDown } from '@vueuse/core' |
||||||
|
import { useAttachmentCell } from './utils' |
||||||
|
import { useSortable } from './sort' |
||||||
|
import { ref, useDropZone, useUIPermission } from '#imports' |
||||||
|
import { isImage, openLink } from '~/utils' |
||||||
|
import MaterialSymbolsAttachFile from '~icons/material-symbols/attach-file' |
||||||
|
import MdiCloseCircle from '~icons/mdi/close-circle' |
||||||
|
import MdiDownload from '~icons/mdi/download' |
||||||
|
import MaterialSymbolsFileCopyOutline from '~icons/material-symbols/file-copy-outline' |
||||||
|
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' |
||||||
|
|
||||||
|
const { isUIAllowed } = useUIPermission() |
||||||
|
|
||||||
|
const { |
||||||
|
open, |
||||||
|
isLoading, |
||||||
|
isPublicGrid, |
||||||
|
isForm, |
||||||
|
isReadonly, |
||||||
|
visibleItems, |
||||||
|
modalVisible, |
||||||
|
column, |
||||||
|
FileIcon, |
||||||
|
removeFile, |
||||||
|
onDrop, |
||||||
|
downloadFile, |
||||||
|
updateModelValue, |
||||||
|
selectedImage, |
||||||
|
} = useAttachmentCell()! |
||||||
|
|
||||||
|
// todo: replace placeholder var |
||||||
|
const isLocked = ref(false) |
||||||
|
|
||||||
|
const dropZoneRef = ref<HTMLDivElement>() |
||||||
|
|
||||||
|
const sortableRef = ref<HTMLDivElement>() |
||||||
|
|
||||||
|
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly) |
||||||
|
|
||||||
|
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop) |
||||||
|
|
||||||
|
onKeyDown('Escape', () => { |
||||||
|
modalVisible.value = false |
||||||
|
isOverDropZone.value = false |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-modal v-model:visible="modalVisible" class="nc-attachment-modal" width="80%" :footer="null"> |
||||||
|
<template #title> |
||||||
|
<div class="flex gap-4"> |
||||||
|
<div |
||||||
|
v-if="!isReadonly && (isForm || isUIAllowed('tableAttachment')) && !isPublicGrid && !isLocked" |
||||||
|
class="nc-attach-file group" |
||||||
|
@click="open" |
||||||
|
> |
||||||
|
<MaterialSymbolsAttachFile class="transform group-hover:(text-pink-500 scale-120)" /> |
||||||
|
Attach File |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<div v-if="isReadonly" class="text-gray-400">[Readonly]</div> |
||||||
|
Viewing Attachments of |
||||||
|
<div class="font-semibold underline">{{ column.title }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div ref="dropZoneRef"> |
||||||
|
<template v-if="!isReadonly && !dragging"> |
||||||
|
<general-overlay |
||||||
|
v-model="isOverDropZone" |
||||||
|
inline |
||||||
|
class="text-white ring ring-pink-500 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl" |
||||||
|
> |
||||||
|
<MaterialSymbolsFileCopyOutline class="text-pink-500" height="35" width="35" /> |
||||||
|
<div class="text-white text-3xl">Drop here</div> |
||||||
|
</general-overlay> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6"> |
||||||
|
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1"> |
||||||
|
<a-card class="nc-attachment-item group"> |
||||||
|
<a-tooltip v-if="!isReadonly"> |
||||||
|
<template #title> Remove File </template> |
||||||
|
|
||||||
|
<MdiCloseCircle |
||||||
|
v-if="isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked" |
||||||
|
class="nc-attachment-remove" |
||||||
|
@click.stop="removeFile(i)" |
||||||
|
/> |
||||||
|
</a-tooltip> |
||||||
|
|
||||||
|
<a-tooltip placement="bottom"> |
||||||
|
<template #title> Download file </template> |
||||||
|
|
||||||
|
<div class="nc-attachment-download group-hover:(opacity-100)"> |
||||||
|
<MdiDownload @click.stop="downloadFile(item)" /> |
||||||
|
</div> |
||||||
|
</a-tooltip> |
||||||
|
|
||||||
|
<div |
||||||
|
:class="[dragging ? 'cursor-move' : 'cursor-pointer']" |
||||||
|
class="nc-attachment h-full w-full flex items-center justify-center" |
||||||
|
> |
||||||
|
<div |
||||||
|
v-if="isImage(item.title, item.mimetype)" |
||||||
|
:style="{ backgroundImage: `url('${item.url}')` }" |
||||||
|
class="w-full h-full bg-contain bg-center bg-no-repeat" |
||||||
|
@click.stop="() => (selectedImage = item) && (modalVisible = false)" |
||||||
|
/> |
||||||
|
|
||||||
|
<component |
||||||
|
:is="FileIcon(item.icon)" |
||||||
|
v-else-if="item.icon" |
||||||
|
height="150" |
||||||
|
width="150" |
||||||
|
@click.stop="openLink(item.url || item.data)" |
||||||
|
/> |
||||||
|
|
||||||
|
<IcOutlineInsertDriveFile v-else height="150" width="150" @click.stop="openLink(item.url || item.data)" /> |
||||||
|
</div> |
||||||
|
</a-card> |
||||||
|
|
||||||
|
<div class="truncate" :title="item.title"> |
||||||
|
{{ item.title }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex flex-col gap-1"> |
||||||
|
<a-card class="nc-attachment-item group"> |
||||||
|
<div class="nc-attachment h-full w-full flex items-center justify-center"> |
||||||
|
<a-skeleton-image class /> |
||||||
|
</div> |
||||||
|
</a-card> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.nc-attachment-modal { |
||||||
|
.nc-attach-file { |
||||||
|
@apply select-none cursor-pointer color-transition flex items-center gap-1 border-1 p-2 rounded |
||||||
|
@apply hover:(bg-primary/10 text-primary ring); |
||||||
|
@apply active:(ring-pink-500 bg-primary/20); |
||||||
|
} |
||||||
|
|
||||||
|
.nc-attachment-item { |
||||||
|
@apply !h-2/3 !min-h-[200px] flex items-center justify-center relative; |
||||||
|
|
||||||
|
@supports (-moz-appearance: none) { |
||||||
|
@apply hover:border-0; |
||||||
|
} |
||||||
|
|
||||||
|
&::after { |
||||||
|
@apply pointer-events-none rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out; |
||||||
|
content: ''; |
||||||
|
} |
||||||
|
|
||||||
|
@supports (-moz-appearance: none) { |
||||||
|
&:hover::after { |
||||||
|
@apply ring shadow transform scale-103; |
||||||
|
} |
||||||
|
|
||||||
|
&:active::after { |
||||||
|
@apply ring ring-pink-500 shadow transform scale-103; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.nc-attachment-download { |
||||||
|
@apply bg-white absolute bottom-2 right-2; |
||||||
|
@apply transition-opacity duration-150 ease-in opacity-0 hover:ring; |
||||||
|
@apply cursor-pointer rounded shadow flex items-center p-1 border-1; |
||||||
|
@apply active:(ring border-0 ring-pink-500); |
||||||
|
} |
||||||
|
|
||||||
|
.nc-attachment-remove { |
||||||
|
@apply absolute top-2 right-2 bg-white; |
||||||
|
@apply hover:(ring ring-red-500); |
||||||
|
@apply cursor-pointer rounded-full border-2; |
||||||
|
@apply active:(ring border-0 ring-red-500); |
||||||
|
} |
||||||
|
|
||||||
|
.ant-card-body { |
||||||
|
@apply !p-2 w-full h-full; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-modal-body { |
||||||
|
@apply !p-0; |
||||||
|
} |
||||||
|
|
||||||
|
.ghost, |
||||||
|
.ghost > * { |
||||||
|
@apply !pointer-events-none; |
||||||
|
} |
||||||
|
|
||||||
|
.dragging { |
||||||
|
.nc-attachment-item { |
||||||
|
@apply !pointer-events-none; |
||||||
|
} |
||||||
|
|
||||||
|
.ant-tooltip { |
||||||
|
@apply !hidden; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,168 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { onKeyDown } from '@vueuse/core' |
||||||
|
import { useProvideAttachmentCell } from './utils' |
||||||
|
import Modal from './Modal.vue' |
||||||
|
import { useSortable } from './sort' |
||||||
|
import Carousel from './Carousel.vue' |
||||||
|
import { onMounted, ref, useDropZone, watch } from '#imports' |
||||||
|
import { isImage, openLink } from '~/utils' |
||||||
|
import MaterialSymbolsAttachFile from '~icons/material-symbols/attach-file' |
||||||
|
import MaterialArrowExpandIcon from '~icons/mdi/arrow-expand' |
||||||
|
import MaterialSymbolsFileCopyOutline from '~icons/material-symbols/file-copy-outline' |
||||||
|
import MdiReload from '~icons/mdi/reload' |
||||||
|
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
modelValue: string | Record<string, any>[] | null |
||||||
|
} |
||||||
|
|
||||||
|
interface Emits { |
||||||
|
(event: 'update:modelValue', value: string | Record<string, any>): void |
||||||
|
} |
||||||
|
|
||||||
|
const { modelValue } = defineProps<Props>() |
||||||
|
|
||||||
|
const emits = defineEmits<Emits>() |
||||||
|
|
||||||
|
const dropZoneRef = ref<HTMLTableDataCellElement>() |
||||||
|
|
||||||
|
const sortableRef = ref<HTMLDivElement>() |
||||||
|
|
||||||
|
const { column, modalVisible, attachments, visibleItems, onDrop, isLoading, open, FileIcon, selectedImage, isReadonly } = |
||||||
|
useProvideAttachmentCell(updateModelValue) |
||||||
|
|
||||||
|
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly) |
||||||
|
|
||||||
|
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop) |
||||||
|
|
||||||
|
/** on new value, reparse our stored attachments */ |
||||||
|
watch( |
||||||
|
() => modelValue, |
||||||
|
(nextModel) => { |
||||||
|
if (nextModel) { |
||||||
|
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean) |
||||||
|
} |
||||||
|
}, |
||||||
|
{ immediate: true }, |
||||||
|
) |
||||||
|
|
||||||
|
/** updates attachments array for autosave */ |
||||||
|
function updateModelValue(data: string | Record<string, any>) { |
||||||
|
emits('update:modelValue', typeof data !== 'string' ? JSON.stringify(data) : data) |
||||||
|
} |
||||||
|
|
||||||
|
/** Close modal on escape press, disable dropzone as well */ |
||||||
|
onKeyDown('Escape', () => { |
||||||
|
modalVisible.value = false |
||||||
|
isOverDropZone.value = false |
||||||
|
}) |
||||||
|
|
||||||
|
/** if possible, on mounted we try to fetch the relevant `td` cell to use as a dropzone */ |
||||||
|
onMounted(() => { |
||||||
|
if (typeof document !== 'undefined') { |
||||||
|
dropZoneRef.value = document.querySelector(`td[data-col="${column.id}"]`) as HTMLTableDataCellElement |
||||||
|
} |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="nc-attachment-cell relative flex-1 color-transition flex items-center justify-between gap-1"> |
||||||
|
<Carousel /> |
||||||
|
|
||||||
|
<template v-if="!isReadonly && !dragging && dropZoneRef"> |
||||||
|
<general-overlay |
||||||
|
v-model="isOverDropZone" |
||||||
|
inline |
||||||
|
:target="`td[data-col='${column.id}']`" |
||||||
|
class="text-white text-lg ring ring-pink-500 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl" |
||||||
|
> |
||||||
|
<MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here |
||||||
|
</general-overlay> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div |
||||||
|
v-if="!isReadonly" |
||||||
|
:class="{ 'mx-auto px-4': !visibleItems.length }" |
||||||
|
class="group flex gap-1 items-center active:ring rounded border-1 p-1 hover:bg-primary/10" |
||||||
|
@click.stop="open" |
||||||
|
> |
||||||
|
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> |
||||||
|
|
||||||
|
<a-tooltip v-else placement="bottom"> |
||||||
|
<template #title> Click or drop a file into cell </template> |
||||||
|
|
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<MaterialSymbolsAttachFile class="transform group-hover:(text-pink-500 scale-120)" /> |
||||||
|
|
||||||
|
<div v-if="!visibleItems.length" class="group-hover:text-primary">Add file(s)</div> |
||||||
|
</div> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
|
||||||
|
<template v-if="visibleItems.length"> |
||||||
|
<div ref="sortableRef" :class="{ dragging }" class="flex flex-wrap gap-2 p-1 scrollbar-thin-primary"> |
||||||
|
<div |
||||||
|
v-for="(item, i) of visibleItems" |
||||||
|
:id="item.url" |
||||||
|
:key="item.url || item.title" |
||||||
|
style="flex: 1 1 50px" |
||||||
|
:class="isImage(item.title, item.mimetype) ? '' : 'border-1 rounded'" |
||||||
|
class="nc-attachment flex items-center justify-center" |
||||||
|
> |
||||||
|
<a-tooltip placement="bottom"> |
||||||
|
<template #title> |
||||||
|
<div class="text-center w-full">{{ item.title }}</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<nuxt-img |
||||||
|
v-if="isImage(item.title, item.mimetype)" |
||||||
|
placeholder |
||||||
|
width="150" |
||||||
|
height="150" |
||||||
|
:alt="item.title || `#${i}`" |
||||||
|
:src="item.url || item.data" |
||||||
|
class="ring-1 ring-gray-300 rounded" |
||||||
|
@click="selectedImage = item" |
||||||
|
/> |
||||||
|
|
||||||
|
<component :is="FileIcon(item.icon)" v-else-if="item.icon" @click="openLink(item.url || item.data)" /> |
||||||
|
|
||||||
|
<IcOutlineInsertDriveFile v-else @click.stop="openLink(item.url || item.data)" /> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="group flex gap-1 items-center border-1 active:ring rounded p-1 hover:bg-primary/10"> |
||||||
|
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> |
||||||
|
|
||||||
|
<a-tooltip v-else placement="bottom"> |
||||||
|
<template #title> View attachments </template> |
||||||
|
|
||||||
|
<MaterialArrowExpandIcon |
||||||
|
class="select-none transform group-hover:(text-pink-500 scale-120)" |
||||||
|
@click.stop="modalVisible = true" |
||||||
|
/> |
||||||
|
</a-tooltip> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<Modal /> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.nc-cell { |
||||||
|
.nc-attachment-cell { |
||||||
|
.ghost, |
||||||
|
.ghost > * { |
||||||
|
@apply !pointer-events-none; |
||||||
|
} |
||||||
|
|
||||||
|
.dragging { |
||||||
|
.ant-tooltip { |
||||||
|
@apply !hidden; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,65 @@ |
|||||||
|
import type { SortableEvent } from 'sortablejs' |
||||||
|
import Sortable from 'sortablejs' |
||||||
|
import type { MaybeRef } from '@vueuse/core' |
||||||
|
import { watchPostEffect } from '@vue/runtime-core' |
||||||
|
import { unref } from '#imports' |
||||||
|
|
||||||
|
export function useSortable( |
||||||
|
element: MaybeRef<HTMLElement | undefined>, |
||||||
|
items: MaybeRef<any[]>, |
||||||
|
updateModelValue: (data: string | Record<string, any>[]) => void, |
||||||
|
isReadonly: MaybeRef<boolean> = false, |
||||||
|
) { |
||||||
|
let dragging = $ref(false) |
||||||
|
|
||||||
|
function onSortStart(evt: SortableEvent) { |
||||||
|
evt.stopImmediatePropagation() |
||||||
|
evt.preventDefault() |
||||||
|
dragging = true |
||||||
|
} |
||||||
|
|
||||||
|
async function onSortEnd(evt: SortableEvent) { |
||||||
|
evt.stopImmediatePropagation() |
||||||
|
evt.preventDefault() |
||||||
|
dragging = false |
||||||
|
|
||||||
|
const _items = unref(items) |
||||||
|
|
||||||
|
if (_items.length < 2) return |
||||||
|
|
||||||
|
const { newIndex = 0, oldIndex = 0 } = evt |
||||||
|
|
||||||
|
if (newIndex === oldIndex) return |
||||||
|
|
||||||
|
_items.splice(newIndex, 0, ..._items.splice(oldIndex, 1)) |
||||||
|
|
||||||
|
updateModelValue(_items) |
||||||
|
} |
||||||
|
|
||||||
|
let sortable: Sortable |
||||||
|
|
||||||
|
// todo: replace with vuedraggable
|
||||||
|
const initSortable = (el: HTMLElement) => { |
||||||
|
sortable = new Sortable(el, { |
||||||
|
handle: '.nc-attachment', |
||||||
|
ghostClass: 'ghost', |
||||||
|
onStart: onSortStart, |
||||||
|
onEnd: onSortEnd, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
watchPostEffect((onCleanup) => { |
||||||
|
const _element = unref(element) |
||||||
|
|
||||||
|
onCleanup(() => { |
||||||
|
if (_element && sortable) sortable.destroy() |
||||||
|
}) |
||||||
|
|
||||||
|
if (_element && !unref(isReadonly)) initSortable(_element) |
||||||
|
}) |
||||||
|
|
||||||
|
return { |
||||||
|
dragging: $$(dragging), |
||||||
|
initSortable, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,157 @@ |
|||||||
|
import { notification } from 'ant-design-vue' |
||||||
|
import FileSaver from 'file-saver' |
||||||
|
import { computed, inject, ref, useApi, useFileDialog, useInjectionState, useProject, watch } from '#imports' |
||||||
|
import { ColumnInj, EditModeInj, MetaInj, ReadonlyInj } from '~/context' |
||||||
|
import { isImage } from '~/utils' |
||||||
|
import { NOCO } from '~/lib' |
||||||
|
import MdiPdfBox from '~icons/mdi/pdf-box' |
||||||
|
import MdiFileWordOutline from '~icons/mdi/file-word-outline' |
||||||
|
import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box' |
||||||
|
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline' |
||||||
|
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' |
||||||
|
|
||||||
|
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( |
||||||
|
(updateModelValue: (data: string | Record<string, any>[]) => void) => { |
||||||
|
const isReadonly = inject(ReadonlyInj, false) |
||||||
|
|
||||||
|
const isPublicForm = inject('isPublicForm', false) |
||||||
|
|
||||||
|
const isForm = inject('isForm', false) |
||||||
|
|
||||||
|
// todo: replace placeholder var
|
||||||
|
const isPublicGrid = $ref(false) |
||||||
|
|
||||||
|
const meta = inject(MetaInj)! |
||||||
|
|
||||||
|
const column = inject(ColumnInj)! |
||||||
|
|
||||||
|
const editEnabled = inject(EditModeInj, ref(false)) |
||||||
|
|
||||||
|
const storedFiles = ref<{ title: string; file: File }[]>([]) |
||||||
|
|
||||||
|
const attachments = ref<File[]>([]) |
||||||
|
|
||||||
|
const modalVisible = ref(false) |
||||||
|
|
||||||
|
const selectedImage = ref() |
||||||
|
|
||||||
|
const { project } = useProject() |
||||||
|
|
||||||
|
const { api, isLoading } = useApi() |
||||||
|
|
||||||
|
const { files, open } = useFileDialog() |
||||||
|
|
||||||
|
/** remove a file from our stored attachments (either locally stored or saved ones) */ |
||||||
|
function removeFile(i: number) { |
||||||
|
if (isPublicForm) { |
||||||
|
storedFiles.value.splice(i, 1) |
||||||
|
|
||||||
|
updateModelValue(storedFiles.value.map((storedFile) => storedFile.file)) |
||||||
|
} else { |
||||||
|
attachments.value.splice(i, 1) |
||||||
|
updateModelValue(attachments.value) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** save a file on select / drop, either locally (in-memory) or in the db */ |
||||||
|
async function onFileSelect(selectedFiles: FileList | File[]) { |
||||||
|
if (!selectedFiles.length || isPublicGrid) return |
||||||
|
|
||||||
|
if (isPublicForm) { |
||||||
|
storedFiles.value.push( |
||||||
|
...Array.from(selectedFiles).map((file) => { |
||||||
|
const res = { file, title: file.name } |
||||||
|
if (isImage(file.name, (file as any).mimetype)) { |
||||||
|
const reader = new FileReader() |
||||||
|
reader.readAsDataURL(file) |
||||||
|
} |
||||||
|
return res |
||||||
|
}), |
||||||
|
) |
||||||
|
|
||||||
|
return updateModelValue(storedFiles.value.map((storedFile) => storedFile.file)) |
||||||
|
} |
||||||
|
|
||||||
|
const newAttachments = [] |
||||||
|
|
||||||
|
for (const file of selectedFiles) { |
||||||
|
try { |
||||||
|
const data = await api.storage.upload( |
||||||
|
{ |
||||||
|
path: [NOCO, project.value.title, meta.value.title, column.title].join('/'), |
||||||
|
}, |
||||||
|
{ |
||||||
|
files: file, |
||||||
|
json: '{}', |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
newAttachments.push(...data) |
||||||
|
} catch (e: any) { |
||||||
|
notification.error({ |
||||||
|
message: e.message || 'Some internal error occurred', |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
updateModelValue([...attachments.value, ...newAttachments]) |
||||||
|
} |
||||||
|
|
||||||
|
/** save files on drop */ |
||||||
|
async function onDrop(droppedFiles: File[] | null) { |
||||||
|
if (droppedFiles) { |
||||||
|
// set files
|
||||||
|
await onFileSelect(droppedFiles) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** download a file */ |
||||||
|
async function downloadFile(item: Record<string, any>) { |
||||||
|
FileSaver.saveAs(item.url || item.data, item.title) |
||||||
|
} |
||||||
|
|
||||||
|
const FileIcon = (icon: string) => { |
||||||
|
switch (icon) { |
||||||
|
case 'mdi-pdf-box': |
||||||
|
return MdiPdfBox |
||||||
|
case 'mdi-file-word-outline': |
||||||
|
return MdiFileWordOutline |
||||||
|
case 'mdi-file-powerpoint-box': |
||||||
|
return MdiFilePowerpointBox |
||||||
|
case 'mdi-file-excel-outline': |
||||||
|
return MdiFileExcelOutline |
||||||
|
default: |
||||||
|
return IcOutlineInsertDriveFile |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** our currently visible items, either the locally stored or the ones from db, depending on isPublicForm status */ |
||||||
|
const visibleItems = computed<any[]>(() => (isPublicForm ? storedFiles.value : attachments.value) || ([] as any[])) |
||||||
|
|
||||||
|
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles)) |
||||||
|
|
||||||
|
return { |
||||||
|
attachments, |
||||||
|
storedFiles, |
||||||
|
visibleItems, |
||||||
|
isPublicForm, |
||||||
|
isForm, |
||||||
|
isPublicGrid, |
||||||
|
isReadonly, |
||||||
|
meta, |
||||||
|
column, |
||||||
|
editEnabled, |
||||||
|
isLoading, |
||||||
|
api, |
||||||
|
open, |
||||||
|
onDrop, |
||||||
|
modalVisible, |
||||||
|
FileIcon, |
||||||
|
removeFile, |
||||||
|
downloadFile, |
||||||
|
updateModelValue, |
||||||
|
selectedImage, |
||||||
|
} |
||||||
|
}, |
||||||
|
'attachmentCell', |
||||||
|
) |
@ -0,0 +1,58 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { onKeyDown } from '@vueuse/core' |
||||||
|
import type { TeleportProps } from '@vue/runtime-core' |
||||||
|
import { useVModel, watch } from '#imports' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
modelValue?: any |
||||||
|
/** if true, overlay will use `position: absolute` instead of `position: fixed` */ |
||||||
|
inline?: boolean |
||||||
|
/** target to teleport to */ |
||||||
|
target?: TeleportProps['to'] |
||||||
|
teleportDisabled?: TeleportProps['disabled'] |
||||||
|
transition?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
interface Emits { |
||||||
|
(event: 'update:modelValue', value: boolean): void |
||||||
|
(event: 'close'): void |
||||||
|
(event: 'open'): void |
||||||
|
} |
||||||
|
|
||||||
|
const { transition = true, teleportDisabled = false, inline = false, target, ...rest } = defineProps<Props>() |
||||||
|
|
||||||
|
const emits = defineEmits<Emits>() |
||||||
|
|
||||||
|
const vModel = useVModel(rest, 'modelValue', emits) |
||||||
|
|
||||||
|
onKeyDown('Escape', () => { |
||||||
|
vModel.value = false |
||||||
|
}) |
||||||
|
|
||||||
|
watch(vModel, (nextVal) => { |
||||||
|
if (nextVal) emits('open') |
||||||
|
else emits('close') |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<script lang="ts"> |
||||||
|
export default { |
||||||
|
inheritAttrs: false, |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<teleport :disabled="teleportDisabled || (inline && !target)" :to="target || 'body'"> |
||||||
|
<div |
||||||
|
v-bind="$attrs" |
||||||
|
:class="[ |
||||||
|
vModel ? 'opacity-100' : 'opacity-0 pointer-events-none', |
||||||
|
inline ? 'absolute' : 'fixed', |
||||||
|
transition ? 'transition-opacity duration-200 ease-in-out' : '', |
||||||
|
]" |
||||||
|
class="z-100 top-0 left-0 bottom-0 right-0 bg-gray-700/75" |
||||||
|
> |
||||||
|
<slot :is-open="vModel" /> |
||||||
|
</div> |
||||||
|
</teleport> |
||||||
|
</template> |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue