1
0
forked from test/crm
jcrm/frontend/src/components/FilesUploader/FilesUploaderArea.vue

397 lines
11 KiB
Vue

<template>
<div v-if="showWebLink">
<TextInput v-model="webLink" placeholder="https://example.com" />
</div>
<div v-else-if="showCamera">
<video v-show="!cameraImage" ref="video" class="rounded" autoplay></video>
<canvas
v-show="cameraImage"
ref="canvas"
class="rounded"
style="width: -webkit-fill-available"
/>
</div>
<div v-else>
<div
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed min-h-64 text-ink-gray-5"
@dragover.prevent="dragover"
@dragleave.prevent="dragleave"
@drop.prevent="dropfiles"
v-show="files.length === 0"
>
<div v-if="!isDragging" class="flex flex-col gap-3">
<div class="text-center text-ink-gray-5">
{{ __('Drag and drop files here or upload from') }}
</div>
<div
class="grid grid-flow-col justify-center gap-4 text-center text-base"
>
<input
type="file"
class="hidden"
ref="fileInput"
@change="onFileInput"
:multiple="allowMultiple"
:accept="(restrictions.allowedFileTypes || []).join(', ')"
/>
<div>
<Button icon="monitor" size="md" @click="browseFiles" />
<div class="mt-1">{{ __('Device') }}</div>
</div>
<div v-if="!disableFileBrowser">
<Button icon="folder" size="md" @click="showFileBrowser = true" />
<div class="mt-1">{{ __('Library') }}</div>
</div>
<div v-if="allowWebLink">
<Button icon="link" size="md" @click="showWebLink = true" />
<div class="mt-1">{{ __('Link') }}</div>
</div>
<div v-if="allowTakePhoto">
<Button icon="camera" size="md" @click="startCamera" />
<div class="mt-1">{{ __('Camera') }}</div>
</div>
</div>
</div>
<div v-else>
{{ __('Drop files here') }}
</div>
</div>
<div v-show="files.length" class="flex flex-col divide-y">
<div
v-for="file in files"
:key="file.name"
class="flex items-center justify-between gap-2 py-3"
>
<div class="flex items-center gap-4 truncate">
<div
class="size-11 rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
:class="{ border: !file.type?.startsWith('image') }"
>
<img
v-if="file.type?.startsWith('image')"
class="size-full object-cover"
:src="file.src"
:alt="file.name"
/>
<component v-else class="size-4" :is="fileIcon(file.type)" />
</div>
<div class="flex flex-col gap-1 text-sm text-ink-gray-5 truncate">
<div class="text-base text-ink-gray-8 truncate">
{{ file.name }}
</div>
<div class="mb-1">
{{ convertSize(file.fileObj.size) }}
</div>
<FormControl
v-model="file.private"
type="checkbox"
class="[&>label]:text-sm [&>label]:text-ink-gray-5"
:label="__('Private')"
/>
<ErrorMessage
class="mt-2"
v-if="file.errorMessage"
:message="file.errorMessage"
/>
</div>
</div>
<div>
<CircularProgressBar
v-if="file.uploading || file.uploaded == file.total"
:class="{
'text-ink-green-2': file.uploaded == file.total,
}"
:theme="{
primary: '#22C55E',
secondary: 'lightgray',
}"
:step="file.uploaded || 1"
:totalSteps="file.total || 100"
size="xs"
variant="outline"
:showPercentage="file.uploading"
/>
<Button
v-else
variant="ghost"
icon="trash-2"
@click="removeFile(file.name)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
import { createToast, formatDate, convertSize } from '@/utils'
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
import { ref, onMounted } from 'vue'
const props = defineProps({
doctype: {
type: String,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
})
const files = defineModel()
const fileInput = ref(null)
const isDragging = ref(false)
const showWebLink = ref(false)
const showFileBrowser = ref(false)
const showCamera = ref(false)
const webLink = ref('')
const cameraImage = ref(null)
const allowMultiple = ref(props.options.allowMultiple == false ? false : true)
const disableFileBrowser = ref(props.options.disableFileBrowser || true)
const allowWebLink = ref(props.options.allowWebLink == false ? false : true)
const allowTakePhoto = ref(
props.options.allowTakePhoto || window.navigator.mediaDevices || false,
)
const restrictions = ref(props.options.restrictions || {})
const makeAttachmentsPublic = ref(props.options.makeAttachmentsPublic || false)
onMounted(() => {
createResource({
url: 'crm.api.get_file_uploader_defaults',
params: { doctype: props.doctype },
cache: ['file_uploader_defaults', props.doctype],
auto: true,
transform: (data) => {
restrictions.value = {
allowedFileTypes: data.allowed_file_types
? data.allowed_file_types.split('\n').map((ext) => `.${ext}`)
: [],
maxFileSize: data.max_file_size,
maxNumberOfFiles: data.max_number_of_files,
}
makeAttachmentsPublic.value = Boolean(data.make_attachments_public)
},
})
})
function dragover() {
isDragging.value = true
}
function dragleave() {
isDragging.value = false
}
function dropfiles(e) {
isDragging.value = false
addFiles(e.dataTransfer.files)
}
function browseFiles() {
fileInput.value.click()
}
function onFileInput(event) {
addFiles(fileInput.value.files)
}
const video = ref(null)
const facingMode = ref('environment')
const stream = ref(null)
async function startCamera() {
showCamera.value = true
stream.value = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode.value,
},
audio: false,
})
video.value.srcObject = stream.value
}
function stopStream() {
stream.value.getTracks().forEach((track) => track.stop())
showCamera.value = false
cameraImage.value = null
}
function switchCamera() {
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
stopStream()
startCamera()
}
const canvas = ref(null)
function captureImage() {
const width = video.value.videoWidth
const height = video.value.videoHeight
canvas.value.width = width
canvas.value.height = height
canvas.value.getContext('2d').drawImage(video.value, 0, 0, width, height)
cameraImage.value = canvas.value.toDataURL('image/png')
}
function uploadViaCamera() {
const nowDatetime = formatDate(new Date(), 'yyyy_MM_dd_HH_mm_ss')
let filename = `capture_${nowDatetime}.png`
urlToFile(cameraImage.value, filename, 'image/png').then((file) => {
addFiles([file])
showCamera.value = false
cameraImage.value = null
})
}
function urlToFile(url, filename, mime_type) {
return fetch(url)
.then((res) => res.arrayBuffer())
.then((buffer) => new File([buffer], filename, { type: mime_type }))
}
function addFiles(fileArray) {
let _files = Array.from(fileArray)
.filter(checkRestrictions)
.map((file, i) => {
let isImage = file.type?.startsWith('image')
let sizeKb = file.size / 1024
return {
index: i,
src: isImage ? URL.createObjectURL(file) : null,
fileObj: file,
cropperFile: file,
cropBoxData: null,
type: file.type,
optimize: sizeKb > 200 && isImage && !file.type?.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
requestSucceeded: false,
errorMessage: null,
uploading: false,
private: !makeAttachmentsPublic.value,
}
})
// pop extra files as per FileUploader.restrictions.maxNumberOfFiles
let maxNumberOfFiles = restrictions.value.maxNumberOfFiles
if (maxNumberOfFiles && _files.length > maxNumberOfFiles) {
_files.slice(maxNumberOfFiles).forEach((file) => {
showMaxFilesNumberWarning(file, maxNumberOfFiles)
})
_files = _files.slice(0, maxNumberOfFiles)
}
files.value = files.value.concat(_files)
}
function checkRestrictions(file) {
let { maxFileSize, allowedFileTypes = [] } = restrictions.value
let isCorrectType = true
let validFileSize = true
if (allowedFileTypes && allowedFileTypes.length) {
isCorrectType = allowedFileTypes.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false
return file.type.match(type)
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
return false
})
}
if (maxFileSize && file.size != null) {
validFileSize = file.size < maxFileSize
}
if (!isCorrectType) {
console.warn('File skipped because of invalid file type', file)
createToast({
title: __('File "{0}" was skipped because of invalid file type', [
file.name,
]),
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
}
if (!validFileSize) {
console.warn('File skipped because of invalid file size', file.size, file)
createToast({
title: __('File "{0}" was skipped because size exceeds {1} MB', [
file.name,
maxFileSize / (1024 * 1024),
]),
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
}
return isCorrectType && validFileSize
}
function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${maxNumberOfFiles} uploads`,
file,
)
let message = __(
'File "{0}" was skipped because only {1} uploads are allowed',
[file.name, maxNumberOfFiles],
)
if (props.doctype) {
message = __(
'File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"',
[file.name, maxNumberOfFiles, props.doctype],
)
}
createToast({
title: message,
icon: 'alert-circle',
iconClasses: 'text-orange-600',
})
}
function removeFile(name) {
files.value = files.value.filter((file) => file.name !== name)
}
function fileIcon(type) {
if (type?.startsWith('audio')) {
return FileAudioIcon
} else if (type?.startsWith('video')) {
return FileVideoIcon
}
return FileTextIcon
}
defineExpose({
showFileBrowser,
showWebLink,
webLink,
showCamera,
cameraImage,
captureImage,
uploadViaCamera,
switchCamera,
})
</script>