Browse Source

feat(gui-v2): add attachment views modal

pull/2972/head
braks 2 years ago
parent
commit
e1e3c5a2ee
  1. 207
      packages/nc-gui-v2/components/cell/Attachment.vue
  2. 131
      packages/nc-gui-v2/components/cell/attachment/Modal.vue
  3. 132
      packages/nc-gui-v2/components/cell/attachment/index.vue
  4. 135
      packages/nc-gui-v2/components/cell/attachment/utils.ts
  5. 13
      packages/nc-gui-v2/package-lock.json
  6. 1
      packages/nc-gui-v2/package.json

207
packages/nc-gui-v2/components/cell/Attachment.vue

@ -1,207 +0,0 @@
<script setup lang="ts">
import { notification } from 'ant-design-vue'
import { computed, inject, ref, useApi, useDropZone, useFileDialog, useProject, watch } from '#imports'
import { ColumnInj, EditModeInj, MetaInj } from '~/context'
import { NOCO } from '~/lib'
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'
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'
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 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 dropZoneRef = ref<HTMLDivElement>()
const { api, isLoading } = useApi()
const { project } = useProject()
const { files, open, reset } = useFileDialog()
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
watch(
() => modelValue,
(nextModel) => {
if (nextModel) {
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
}
},
{ immediate: true },
)
function onDrop(droppedFiles: File[] | null) {
if (droppedFiles) {
// set files
onFileSelection(droppedFiles)
}
}
const selectImage = (file: any, i: unknown) => {
// todo: implement
}
async function onFileSelection(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
}),
)
emits(
'update:modelValue',
storedFiles.value.map((f) => f.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',
})
}
}
emits('update:modelValue', JSON.stringify([...attachments.value, ...newAttachments]))
}
watch(files, (nextFiles) => nextFiles && onFileSelection(nextFiles))
const items = computed(() => (isPublicForm ? storedFiles.value : attachments.value) || [])
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
}
}
</script>
<template>
<div ref="dropZoneRef" class="flex-1 color-transition flex items-center justify-between gap-1">
<template v-if="isOverDropZone">
<div
class="w-full h-full flex items-center justify-center p-1 rounded gap-1 bg-gradient-to-t from-primary/10 via-primary/25 to-primary/10 !text-primary"
>
<MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here
</div>
</template>
<template v-else>
<div
:class="{ 'mx-auto px-4': !items.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="!items.length" class="group-hover:text-primary">Add file(s)</div>
</div>
</a-tooltip>
</div>
<div class="h-full w-full flex flex-wrap flex-col overflow-x-scroll overflow-y-hidden gap-2 scrollbar-thin-primary py-1">
<div
v-for="(item, i) of items"
:key="item.url || item.title"
class="flex-auto flex items-center justify-center w-[45px] border-1"
>
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ item.title }}</div>
</template>
<img
v-if="isImage(item.title, item.mimetype)"
:alt="item.title || `#${i}`"
:src="item.url || item.data"
@click="selectImage(item.url || item.data, i)"
/>
<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 v-if="items.length" class="group flex gap-1 items-center active:ring rounded border-1 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="transform group-hover:(text-pink-500 scale-120)" @click.stop />
</a-tooltip>
</div>
</template>
</div>
</template>

131
packages/nc-gui-v2/components/cell/attachment/Modal.vue

@ -0,0 +1,131 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import FileSaver from 'file-saver'
import { useAttachmentCell } from './utils'
import { ref, 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 IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
const { isUIAllowed } = useUIPermission()
const { open, isLoading, isPublicGrid, isForm, visibleItems, modalVisible, column, FileIcon, removeFile } = useAttachmentCell()
// todo: replace placeholder var
const isLocked = ref(false)
onKeyDown('Escape', () => (modalVisible.value = false))
async function downloadFile(item: Record<string, any>) {
FileSaver.saveAs(item.url || item.data, item.title)
}
</script>
<template>
<a-modal v-model:visible="modalVisible" width="80%" :footer="null">
<template #title>
<div class="flex gap-4">
<div
v-if="(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">
Viewing Attachments of
<div class="font-semibold underline">{{ column.title }}</div>
</div>
</div>
</template>
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-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>
<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">
<MdiDownload @click.stop="downloadFile(item)" />
</div>
</a-tooltip>
<div class="p-2 flex items-center cursor-pointer">
<img v-if="isImage(item.title, item.mimetype)" :alt="item.title || `#${i}`" :src="item.url || item.data" />
<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>
</a-modal>
</template>
<style lang="scss" scoped>
.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 cursor-pointer !h-2/3 !min-h-[200px] flex items-center justify-center relative;
&::after {
@apply pointer-events-none rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out;
content: '';
}
&:hover::after {
@apply ring shadow transform scale-103;
}
&:active::after {
@apply ring ring-pink-500 shadow transform scale-103;
}
}
.nc-attachment-download {
@apply absolute bottom-2 right-2;
@apply transition-opacity duration-150 ease-in opacity-0 group-hover:(opacity-100) 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;
@apply hover:(ring ring-red-500);
@apply cursor-pointer rounded-full border-1;
@apply active:(ring border-0 ring-red-500);
}
:deep(.ant-card-body) {
@apply !p-2;
}
</style>

132
packages/nc-gui-v2/components/cell/attachment/index.vue

@ -0,0 +1,132 @@
<script setup lang="ts">
import { useProvideAttachmentCell } from './utils'
import Modal from './Modal.vue'
import { 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<HTMLDivElement>()
const { modalVisible, attachments, visibleItems, onFileSelect, isLoading, open, FileIcon, fileRemovedHook, fileAddedHook } =
useProvideAttachmentCell()
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
watch(
() => modelValue,
(nextModel) => {
if (nextModel) {
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
}
},
{ immediate: true },
)
fileRemovedHook.on((data) => {
emits('update:modelValue', data)
})
fileAddedHook.on((data) => {
emits('update:modelValue', data)
})
function onDrop(droppedFiles: File[] | null) {
if (droppedFiles) {
// set files
onFileSelect(droppedFiles)
}
}
const selectImage = (file: any, i: unknown) => {
// todo: implement
}
</script>
<template>
<div ref="dropZoneRef" class="flex-1 color-transition flex items-center justify-between gap-1">
<template v-if="isOverDropZone">
<div
class="w-full h-full flex items-center justify-center p-1 rounded gap-1 bg-gradient-to-t from-primary/10 via-primary/25 to-primary/10 !text-primary"
>
<MaterialSymbolsFileCopyOutline class="text-pink-500" /> Drop here
</div>
</template>
<template v-else>
<div
: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
class="h-full w-full flex flex-wrap flex-col gap-2 content-start py-1 overflow-x-scroll overflow-y-hidden scrollbar-thin-primary"
>
<div
v-for="(item, i) of visibleItems"
:key="item.url || item.title"
class="flex-auto flex items-center justify-center w-[45px] border-1"
>
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ item.title }}</div>
</template>
<img
v-if="isImage(item.title, item.mimetype)"
:alt="item.title || `#${i}`"
:src="item.url || item.data"
@click="selectImage(item.url || item.data, i)"
/>
<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 active:ring rounded border-1 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="transform group-hover:(text-pink-500 scale-120)" @click.stop="modalVisible = true" />
</a-tooltip>
</div>
</template>
</template>
<Modal />
</div>
</template>

135
packages/nc-gui-v2/components/cell/attachment/utils.ts

@ -0,0 +1,135 @@
import { notification } from 'ant-design-vue'
import { computed, createEventHook, inject, ref, useApi, useFileDialog, useInjectionState, useProject, watch } from '#imports'
import { ColumnInj, EditModeInj, MetaInj } 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(() => {
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 { project } = useProject()
const { api, isLoading } = useApi()
const { files, open } = useFileDialog()
const fileRemovedHook = createEventHook<string | Record<string, any>[]>()
const fileAddedHook = createEventHook<File[]>()
function removeFile(i: number) {
if (isPublicForm) {
storedFiles.value.splice(i, 1)
fileRemovedHook.trigger(storedFiles.value.map((storedFile) => storedFile.file))
} else {
attachments.value.splice(i, 1)
fileRemovedHook.trigger(attachments.value)
}
}
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 fileAddedHook.trigger(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',
})
}
}
fileAddedHook.trigger([...attachments.value, ...newAttachments])
}
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
}
}
const visibleItems = computed(() => (isPublicForm ? storedFiles.value : attachments.value) || [])
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
return {
attachments,
storedFiles,
visibleItems,
isPublicForm,
isForm,
isPublicGrid,
meta,
column,
editEnabled,
isLoading,
api,
open,
onFileSelect,
modalVisible,
FileIcon,
fileRemovedHook,
fileAddedHook,
removeFile,
}
}, 'attachmentCell')

13
packages/nc-gui-v2/package-lock.json generated

@ -34,6 +34,7 @@
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^4.0.0",
"@types/axios": "^0.14.0",
"@types/file-saver": "^2.0.5",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@vitejs/plugin-vue": "^2.3.3",
@ -2247,6 +2248,12 @@
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"node_modules/@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
"dev": true
},
"node_modules/@types/form-data": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz",
@ -16058,6 +16065,12 @@
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"@types/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
"dev": true
},
"@types/form-data": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz",

1
packages/nc-gui-v2/package.json

@ -40,6 +40,7 @@
"@iconify-json/ri": "^1.1.3",
"@intlify/vite-plugin-vue-i18n": "^4.0.0",
"@types/axios": "^0.14.0",
"@types/file-saver": "^2.0.5",
"@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0",
"@vitejs/plugin-vue": "^2.3.3",

Loading…
Cancel
Save