mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
510 lines
14 KiB
510 lines
14 KiB
1 week ago
|
<script lang="ts" setup>
|
||
|
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
|
||
|
import { Upload } from 'ant-design-vue'
|
||
|
import { WorkspaceIconType } from '#imports'
|
||
|
import data from 'emoji-mart-vue-fast/data/apple.json'
|
||
|
import { EmojiIndex, Picker } from 'emoji-mart-vue-fast/src'
|
||
|
import 'emoji-mart-vue-fast/css/emoji-mart.css'
|
||
|
|
||
|
interface Props {
|
||
|
icon: string | Record<string, any>
|
||
|
iconType: WorkspaceIconType | string
|
||
|
currentWorkspace: any
|
||
|
}
|
||
|
|
||
|
const props = withDefaults(defineProps<Props>(), {})
|
||
|
|
||
|
const emits = defineEmits(['update:icon', 'update:iconType'])
|
||
|
|
||
|
const { currentWorkspace } = toRefs(props)
|
||
|
|
||
|
const vIcon = useVModel(props, 'icon', emits)
|
||
|
|
||
|
const vIconType = useVModel(props, 'iconType', emits)
|
||
|
|
||
|
const { t } = useI18n()
|
||
|
|
||
|
const { getPossibleAttachmentSrc } = useAttachment()
|
||
|
|
||
|
const isOpen = ref<boolean>(false)
|
||
|
|
||
|
const isLoading = ref<boolean>(false)
|
||
|
|
||
|
const inputRef = ref<HTMLInputElement>()
|
||
|
|
||
|
const searchQuery = ref<string>('')
|
||
|
|
||
|
const activeTabLocal = ref<WorkspaceIconType>(WorkspaceIconType.ICON)
|
||
|
|
||
|
const activeTab = computed({
|
||
|
get: () => activeTabLocal.value,
|
||
|
set: (value: WorkspaceIconType) => {
|
||
|
searchQuery.value = ''
|
||
|
|
||
|
activeTabLocal.value = value
|
||
|
|
||
|
nextTick(() => {
|
||
|
focusInput()
|
||
|
})
|
||
|
},
|
||
|
})
|
||
|
|
||
|
const icons = computed(() => {
|
||
|
return searchIcons(searchQuery.value)
|
||
|
})
|
||
|
|
||
|
const selectIcon = (icon: string) => {
|
||
|
vIcon.value = icon
|
||
|
vIconType.value = WorkspaceIconType.ICON
|
||
|
|
||
|
isOpen.value = false
|
||
|
}
|
||
|
|
||
|
const handleRemoveIcon = (closeDropdown = true) => {
|
||
|
vIcon.value = ''
|
||
|
vIconType.value = ''
|
||
|
|
||
|
if (closeDropdown) {
|
||
|
isOpen.value = false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const emojiIndex = new EmojiIndex(data, {
|
||
|
emojisToShowFilter: (emoji: any) => {
|
||
|
if (Number(emoji.added_in) >= 14) {
|
||
|
return false
|
||
|
}
|
||
|
|
||
|
return true
|
||
|
},
|
||
|
})
|
||
|
|
||
|
function selectEmoji(_emoji: any) {
|
||
|
vIcon.value = _emoji.native
|
||
|
vIconType.value = WorkspaceIconType.EMOJI
|
||
|
|
||
|
isOpen.value = false
|
||
|
}
|
||
|
|
||
|
const fileList = ref<UploadFile[]>([])
|
||
|
|
||
|
const imageCropperData = ref({
|
||
|
cropperConfig: {
|
||
|
stencilProps: {
|
||
|
aspectRatio: 1,
|
||
|
},
|
||
|
minHeight: 150,
|
||
|
minWidth: 150,
|
||
|
},
|
||
|
imageConfig: {
|
||
|
src: '',
|
||
|
type: 'image',
|
||
|
name: 'icon',
|
||
|
},
|
||
|
uploadConfig: {
|
||
|
path: [NOCO, 'workspace', currentWorkspace.value?.id, 'icon'].join('/'),
|
||
|
},
|
||
|
})
|
||
|
|
||
|
const handleOnUploadImage = async (data: any) => {
|
||
|
vIcon.value = data
|
||
|
vIconType.value = WorkspaceIconType.IMAGE
|
||
|
|
||
|
isOpen.value = false
|
||
|
}
|
||
|
|
||
|
const showImageCropperLocal = ref(false)
|
||
|
|
||
|
const showImageCropper = ref(false)
|
||
|
|
||
|
const getWorkspaceLogoSrc = computed(() => {
|
||
|
if (vIcon.value && vIconType.value === WorkspaceIconType.IMAGE) {
|
||
|
return getPossibleAttachmentSrc(vIcon.value)
|
||
|
}
|
||
|
|
||
|
return []
|
||
|
})
|
||
|
|
||
|
const isUploadingImage = ref(false)
|
||
|
|
||
|
function rejectDrop(fileList: UploadFile[]) {
|
||
|
fileList.map((file) => {
|
||
|
return message.error(`${t('msg.error.fileUploadFailed')} ${file.name}`)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
const handleChange = (info: UploadChangeParam) => {
|
||
|
const status = info.file.status
|
||
|
|
||
|
if (status === 'uploading') {
|
||
|
isUploadingImage.value = true
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (status && status !== 'removed' && status === 'done' && info.file.originFileObj instanceof File) {
|
||
|
// 1. Revoke the object URL, to allow the garbage collector to destroy the uploaded before file
|
||
|
if (imageCropperData.value.imageConfig.src) {
|
||
|
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
|
||
|
}
|
||
|
// 2. Create the blob link to the file to optimize performance:
|
||
|
const blob = URL.createObjectURL(info.file.originFileObj)
|
||
|
|
||
|
// 3. Update the image. The type will be derived from the extension
|
||
|
imageCropperData.value.imageConfig = {
|
||
|
src: blob,
|
||
|
type: info.file.originFileObj.type,
|
||
|
name: info.file.originFileObj.name,
|
||
|
}
|
||
|
|
||
|
isUploadingImage.value = false
|
||
|
showImageCropper.value = true
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if (status === 'error') {
|
||
|
isUploadingImage.value = false
|
||
|
message.error(`${t('msg.error.fileUploadFailed')} ${info.file.name}`)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** a workaround to override default antd upload api call */
|
||
|
const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
|
||
|
fileList.value.forEach((f) => {
|
||
|
if (f.uid === customReqArgs.file.uid) {
|
||
|
f.status = 'done'
|
||
|
handleChange({ file: f, fileList: fileList.value })
|
||
|
}
|
||
|
})
|
||
|
|
||
|
customReqArgs.onSuccess()
|
||
|
}
|
||
|
|
||
|
/** check if the file size exceeds the limit */
|
||
|
const beforeUpload = (file: UploadFile) => {
|
||
|
const exceedLimit = file.size! / 1024 / 1024 > 2
|
||
|
if (exceedLimit) {
|
||
|
message.error(`File ${file.name} is too big. The accepted file size is less than 2MB.`)
|
||
|
}
|
||
|
return !exceedLimit || Upload.LIST_IGNORE
|
||
|
}
|
||
|
|
||
|
const onVisibilityChange = (value: boolean) => {
|
||
|
if (!value && showImageCropperLocal.value) {
|
||
|
isOpen.value = true
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function focusInput() {
|
||
|
if (activeTab.value === WorkspaceIconType.EMOJI) {
|
||
|
setTimeout(() => {
|
||
|
const emojiInput = document.querySelector('.emoji-mart-search input') as HTMLInputElement
|
||
|
|
||
|
if (!emojiInput) return
|
||
|
|
||
|
emojiInput.focus()
|
||
|
emojiInput.select()
|
||
|
})
|
||
|
} else if (activeTab.value === WorkspaceIconType.ICON) {
|
||
|
setTimeout(() => {
|
||
|
inputRef.value?.focus()
|
||
|
inputRef.value?.select()
|
||
|
}, 250)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
watch(showImageCropper, (newValue) => {
|
||
|
if (newValue) {
|
||
|
showImageCropperLocal.value = true
|
||
|
} else {
|
||
|
setTimeout(() => {
|
||
|
showImageCropperLocal.value = false
|
||
|
}, 500)
|
||
|
}
|
||
|
})
|
||
|
|
||
|
watch(isOpen, (newValue) => {
|
||
|
if (newValue) {
|
||
|
nextTick(() => {
|
||
|
focusInput()
|
||
|
})
|
||
|
}
|
||
|
})
|
||
|
</script>
|
||
|
|
||
|
<template>
|
||
|
<div>
|
||
|
<NcDropdown v-model:visible="isOpen" overlay-class-name="w-[432px]" @visible-change="onVisibilityChange">
|
||
|
<div
|
||
|
class="rounded-lg border-1 flex-none w-17 h-17 overflow-hidden transition-all duration-300 cursor-pointer"
|
||
|
:class="{
|
||
|
'border-transparent': !isOpen && vIconType === WorkspaceIconType.IMAGE,
|
||
|
'border-nc-gray-medium': !isOpen && vIconType !== WorkspaceIconType.IMAGE,
|
||
|
'border-primary shadow-selected': isOpen,
|
||
|
}"
|
||
|
>
|
||
|
<GeneralWorkspaceIcon
|
||
|
:workspace="currentWorkspace"
|
||
|
:workspace-icon="{
|
||
|
icon: vIcon,
|
||
|
iconType: vIconType,
|
||
|
}"
|
||
|
size="xlarge"
|
||
|
class="!w-full !h-full !min-w-full rounded-none select-none"
|
||
|
/>
|
||
|
</div>
|
||
|
<template #overlay>
|
||
|
<div class="pt-2 h-[320px]">
|
||
|
<NcTabs v-model:activeKey="activeTab" theme="ai" class="nc-workspace-icon-dropdown-tabs h-full">
|
||
|
<template #leftExtra>
|
||
|
<div class="w-0"></div>
|
||
|
</template>
|
||
|
<template #rightExtra>
|
||
|
<div>
|
||
|
<NcButton size="xs" type="text" :disabled="!vIcon" @click.stop="handleRemoveIcon"> Remove </NcButton>
|
||
|
</div>
|
||
|
</template>
|
||
|
<a-tab-pane :key="WorkspaceIconType.ICON" class="w-full" :disabled="isLoading">
|
||
|
<template #tab>
|
||
|
<div class="tab-title">
|
||
|
<GeneralIcon icon="ncPlaceholderIcon" class="flex-none" />
|
||
|
Icon
|
||
|
</div>
|
||
|
</template>
|
||
|
|
||
|
<div class="h-full overflow-auto nc-scrollbar-thin flex flex-col">
|
||
|
<div class="!sticky top-0 flex gap-2 bg-white px-2 py-2">
|
||
|
<a-input
|
||
|
ref="inputRef"
|
||
|
v-model:value="searchQuery"
|
||
|
:placeholder="$t('placeholder.searchIcons')"
|
||
|
class="nc-dropdown-search-unified-input z-10"
|
||
|
>
|
||
|
</a-input>
|
||
|
</div>
|
||
|
|
||
|
<div v-if="icons.length" class="grid px-3 auto-rows-max pb-2 gap-3 grid-cols-10">
|
||
|
<component
|
||
|
:is="i"
|
||
|
v-for="({ icon: i, name }, idx) in icons"
|
||
|
:key="idx"
|
||
|
:icon="i"
|
||
|
class="w-6 hover:bg-gray-100 cursor-pointer rounded p-1 text-gray-700 h-6"
|
||
|
@click="selectIcon(name)"
|
||
|
/>
|
||
|
</div>
|
||
|
<div v-else class="flex-1 grid place-items-center">
|
||
|
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
|
||
|
</div>
|
||
|
</div>
|
||
|
</a-tab-pane>
|
||
|
<a-tab-pane :key="WorkspaceIconType.IMAGE" class="w-full" :disabled="isLoading">
|
||
|
<template #tab>
|
||
|
<div
|
||
|
class="tab-title"
|
||
|
:class="{
|
||
|
'!cursor-wait': isLoading,
|
||
|
}"
|
||
|
>
|
||
|
<GeneralIcon icon="ncUpload" class="flex-none" />
|
||
|
Upload
|
||
|
</div>
|
||
|
</template>
|
||
|
<div class="p-2 flex flex-col gap-2.5 h-full">
|
||
|
<div v-if="getWorkspaceLogoSrc.length" class="flex items-center gap-4">
|
||
|
<div class="h-12 w-12 p-2">
|
||
|
<CellAttachmentPreviewImage
|
||
|
:srcs="getWorkspaceLogoSrc"
|
||
|
class="flex-none !object-contain max-h-full max-w-full !m-0 rounded-lg"
|
||
|
/>
|
||
|
</div>
|
||
|
<div class="flex-1 w-[calc(100%_-_108px)]">
|
||
|
<NcTooltip class="truncate flex-1" show-on-truncate-only>
|
||
|
<template #title> {{ vIcon?.title || 'Workspace logo' }}</template>
|
||
|
{{ vIcon?.title || 'Workspace logo' }}
|
||
|
</NcTooltip>
|
||
|
<div class="text-nc-content-gray-muted text-sm">
|
||
|
{{ vIcon?.size ? `${(vIcon?.size / 1048576).toFixed(2)} MB` : '0 MB' }}
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
<div>
|
||
|
<NcButton icon-only type="text" size="xs" class="!px-1" @click="handleRemoveIcon(false)">
|
||
|
<template #icon>
|
||
|
<GeneralIcon icon="deleteListItem" />
|
||
|
</template>
|
||
|
</NcButton>
|
||
|
</div>
|
||
|
</div>
|
||
|
<div class="flex-1">
|
||
|
<a-upload-dragger
|
||
|
v-model:fileList="fileList"
|
||
|
name="file"
|
||
|
accept="image/*"
|
||
|
:disabled="isUploadingImage"
|
||
|
:multiple="false"
|
||
|
:show-upload-list="false"
|
||
|
class="nc-workspace-image-uploader"
|
||
|
:custom-request="customReqCbk"
|
||
|
:before-upload="beforeUpload"
|
||
|
@change="handleChange"
|
||
|
@reject="rejectDrop"
|
||
|
>
|
||
|
<div class="ant-upload-drag-icon !text-nc-content-gray-muted !mb-2 text-center">
|
||
|
<div v-if="isUploadingImage" class="h-6 grid place-items-center">
|
||
|
<GeneralLoader size="regular" />
|
||
|
</div>
|
||
|
<GeneralIcon v-else icon="upload" class="h-6 w-6" />
|
||
|
</div>
|
||
|
|
||
|
<div class="ant-upload-text !text-nc-content-gray-muted !text-sm">
|
||
|
Drop your icon here or <span class="text-nc-content-brand hover:underline">browse file</span>
|
||
|
<div class="mt-1">Supported: image/*</div>
|
||
|
</div>
|
||
|
</a-upload-dragger>
|
||
|
</div>
|
||
|
</div>
|
||
|
</a-tab-pane>
|
||
|
<a-tab-pane :key="WorkspaceIconType.EMOJI" class="w-full" :disabled="isLoading">
|
||
|
<template #tab>
|
||
|
<div class="tab-title">
|
||
|
<GeneralIcon icon="ncSmile" class="flex-none" />
|
||
|
Emoji
|
||
|
</div>
|
||
|
</template>
|
||
|
<div class="h-full">
|
||
|
<Picker
|
||
|
:data="emojiIndex"
|
||
|
:native="true"
|
||
|
:show-preview="false"
|
||
|
color="#40444D"
|
||
|
:auto-focus="true"
|
||
|
:show-categories="false"
|
||
|
:i18n="{
|
||
|
search: 'Search emoji',
|
||
|
}"
|
||
|
class="nc-workspace-emoji-picker"
|
||
|
@select="selectEmoji"
|
||
|
@click.stop="() => {}"
|
||
|
></Picker>
|
||
|
</div>
|
||
|
</a-tab-pane>
|
||
|
</NcTabs>
|
||
|
</div>
|
||
|
</template>
|
||
|
</NcDropdown>
|
||
|
|
||
|
<GeneralImageCropper
|
||
|
v-model:show-cropper="showImageCropper"
|
||
|
:cropper-config="imageCropperData.cropperConfig"
|
||
|
:image-config="imageCropperData.imageConfig"
|
||
|
:upload-config="imageCropperData.uploadConfig"
|
||
|
@submit="handleOnUploadImage"
|
||
|
></GeneralImageCropper>
|
||
|
</div>
|
||
|
</template>
|
||
|
|
||
|
<style lang="scss" scoped>
|
||
|
.nc-workspace-icon-dropdown-tabs {
|
||
|
:deep(.ant-tabs-nav) {
|
||
|
@apply px-3;
|
||
|
|
||
|
.ant-tabs-extra-content {
|
||
|
@apply self-start;
|
||
|
}
|
||
|
|
||
|
.ant-tabs-tab {
|
||
|
@apply px-0 pt-1 pb-2;
|
||
|
|
||
|
&.ant-tabs-tab-active {
|
||
|
@apply font-medium;
|
||
|
}
|
||
|
|
||
|
& + .ant-tabs-tab {
|
||
|
@apply ml-4;
|
||
|
}
|
||
|
|
||
|
.tab-title {
|
||
|
@apply text-xs leading-[24px] px-2 rounded hover:bg-gray-100 transition-colors flex items-center gap-2;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
:deep(.ant-tabs-content) {
|
||
|
@apply h-full;
|
||
|
}
|
||
|
|
||
|
&.nc-ai-loading {
|
||
|
:deep(.ant-tabs-tab) {
|
||
|
@apply !cursor-wait;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
:deep(.ant-tabs-tab-disabled) {
|
||
|
.tab-title {
|
||
|
@apply text-nc-content-gray-muted hover:bg-transparent;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
:deep(.ant-input::placeholder) {
|
||
|
@apply text-gray-500;
|
||
|
}
|
||
|
</style>
|
||
|
|
||
|
<style>
|
||
|
.nc-workspace-image-uploader {
|
||
|
&.ant-upload.ant-upload-drag {
|
||
|
@apply !rounded-lg !bg-white !hover:bg-nc-bg-gray-light !transition-colors duration-300;
|
||
|
}
|
||
|
.ant-upload-btn {
|
||
|
@apply !flex flex-col items-center justify-center !min-h-[176px];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
.nc-workspace-emoji-picker.emoji-mart {
|
||
|
@apply !w-108 !h-full !border-none bg-transparent rounded-t-none rounded-b-lg;
|
||
|
|
||
|
span.emoji-type-native {
|
||
|
@apply cursor-pointer;
|
||
|
}
|
||
|
|
||
|
.emoji-mart-anchor {
|
||
|
@apply h-8 py-1.5;
|
||
|
svg {
|
||
|
@apply h-3.5 !important;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
.emoji-mart-search {
|
||
|
@apply px-2 mt-2;
|
||
|
input {
|
||
|
@apply text-sm pl-[11px] rounded-lg !h-8 transition-all duration-300 !outline-none ring-0;
|
||
|
|
||
|
&:focus {
|
||
|
@apply !outline-none ring-0 !h-8 shadow-selected border-primary;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
.emoji-mart-scroll {
|
||
|
@apply mt-1 px-1 overflow-x-hidden;
|
||
|
|
||
|
h3.emoji-mart-category-label {
|
||
|
@apply text-xs text-gray-500 mb-0;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
.emoji-mart-scroll {
|
||
|
@apply nc-scrollbar-thin;
|
||
|
overflow-y: overlay;
|
||
|
}
|
||
|
|
||
|
.emoji-mart-emoji {
|
||
|
@apply !px-1 !py-0.75 !m-0.5;
|
||
|
}
|
||
|
.emoji-mart-emoji:hover:before {
|
||
|
@apply !rounded-md;
|
||
|
}
|
||
|
}
|
||
|
</style>
|