mirror of https://github.com/nocodb/nocodb
Ramesh Mane
1 week ago
committed by
GitHub
28 changed files with 1020 additions and 102 deletions
@ -0,0 +1,523 @@
|
||||
<script lang="ts" setup> |
||||
import type { UploadChangeParam, UploadFile } from 'ant-design-vue' |
||||
import { Upload } from 'ant-design-vue' |
||||
import data from 'emoji-mart-vue-fast/data/apple.json' |
||||
import { EmojiIndex, Picker } from 'emoji-mart-vue-fast/src' |
||||
import { WorkspaceIconType } from '#imports' |
||||
import 'emoji-mart-vue-fast/css/emoji-mart.css' |
||||
import { PublicAttachmentScope } from 'nocodb-sdk' |
||||
|
||||
interface Props { |
||||
icon: string | Record<string, any> |
||||
iconType: WorkspaceIconType | string |
||||
currentWorkspace: any |
||||
} |
||||
|
||||
const props = withDefaults(defineProps<Props>(), {}) |
||||
|
||||
const emits = defineEmits(['update:icon', 'update:iconType', 'submit']) |
||||
|
||||
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 |
||||
|
||||
emits('submit') |
||||
|
||||
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 |
||||
|
||||
emits('submit') |
||||
|
||||
isOpen.value = false |
||||
} |
||||
|
||||
const fileList = ref<UploadFile[]>([]) |
||||
|
||||
const imageCropperData = ref({ |
||||
cropperConfig: { |
||||
stencilProps: { |
||||
aspectRatio: 1, |
||||
fillDefault: true, |
||||
}, |
||||
minHeight: 150, |
||||
minWidth: 150, |
||||
}, |
||||
imageConfig: { |
||||
src: '', |
||||
type: 'image', |
||||
name: 'icon', |
||||
}, |
||||
uploadConfig: { |
||||
path: [NOCO, 'workspace', currentWorkspace.value?.id, 'icon'].join('/'), |
||||
scope: PublicAttachmentScope.WORKSPACEPICS, |
||||
maxFileSize: 2 * 1024 * 1024, |
||||
}, |
||||
}) |
||||
|
||||
const handleOnUploadImage = async (data: any) => { |
||||
vIcon.value = data |
||||
vIconType.value = WorkspaceIconType.IMAGE |
||||
|
||||
emits('submit') |
||||
|
||||
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 === '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 cursor-pointer" |
||||
/> |
||||
</div> |
||||
<template #overlay> |
||||
<div class="pt-2 h-[320px]"> |
||||
<NcTabs v-model:activeKey="activeTab" 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; |
||||
} |
||||
|
||||
:deep(.nc-workspace-avatar img) { |
||||
@apply !cursor-pointer; |
||||
} |
||||
</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 !py-5px transition-all duration-300 !outline-none ring-0; |
||||
|
||||
&:focus { |
||||
@apply !outline-none ring-0 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> |
Loading…
Reference in new issue