feat: file uploader
This commit is contained in:
parent
068edf8737
commit
3752d587b6
@ -3,6 +3,7 @@ import frappe
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import validate_email_address, split_emails, cstr
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
from frappe.core.api.file import get_max_file_size
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@ -107,3 +108,20 @@ def invite_by_email(emails: str, role: str):
|
||||
|
||||
for email in to_invite:
|
||||
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_file_uploader_defaults(doctype: str):
|
||||
max_number_of_files = None
|
||||
make_attachments_public = False
|
||||
if doctype:
|
||||
meta = frappe.get_meta(doctype)
|
||||
max_number_of_files = meta.get("max_attachments")
|
||||
make_attachments_public = meta.get("make_attachments_public")
|
||||
|
||||
return {
|
||||
'allowed_file_types': frappe.get_system_settings("allowed_file_extensions"),
|
||||
'max_file_size': get_max_file_size(),
|
||||
'max_number_of_files': max_number_of_files,
|
||||
'make_attachments_public': bool(make_attachments_public),
|
||||
}
|
||||
152
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
152
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Attach'),
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<FilesUploaderArea
|
||||
v-model="files"
|
||||
:doctype="doctype"
|
||||
:options="options"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-between">
|
||||
<div>
|
||||
<Button
|
||||
v-if="files.length"
|
||||
variant="subtle"
|
||||
:label="__('Remove all')"
|
||||
:disabled="fileUploadStarted"
|
||||
@click="removeAllFiles"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-if="isAllPrivate && files.length"
|
||||
variant="subtle"
|
||||
:label="__('Set all as public')"
|
||||
:disabled="fileUploadStarted"
|
||||
@click="setAllPublic"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="files.length"
|
||||
variant="subtle"
|
||||
:label="__('Set all as private')"
|
||||
:disabled="fileUploadStarted"
|
||||
@click="setAllPrivate"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:loading="fileUploadStarted"
|
||||
:disabled="!files.length"
|
||||
@click="attachFiles"
|
||||
:label="__('Attach')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
|
||||
import FilesUploadHandler from './filesUploaderHandler'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
folder: 'Home/Attachments',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const show = defineModel()
|
||||
const files = ref([])
|
||||
|
||||
const isAllPrivate = computed(() => files.value.every((a) => a.private))
|
||||
|
||||
function setAllPrivate() {
|
||||
files.value.forEach((file) => (file.private = true))
|
||||
}
|
||||
|
||||
function setAllPublic() {
|
||||
files.value.forEach((file) => (file.private = false))
|
||||
}
|
||||
|
||||
function removeAllFiles() {
|
||||
files.value = []
|
||||
}
|
||||
|
||||
function attachFiles() {
|
||||
files.value.forEach((file, i) => attachFile(file, i))
|
||||
}
|
||||
|
||||
const uploader = ref(null)
|
||||
const fileUploadStarted = ref(false)
|
||||
|
||||
function attachFile(file, i) {
|
||||
const args = {
|
||||
file: file?.fileObj || {},
|
||||
type: file.type,
|
||||
private: file.private,
|
||||
fileUrl: file.fileUrl,
|
||||
folder: props.options.folder,
|
||||
doctype: props.doctype,
|
||||
docname: props.docname,
|
||||
}
|
||||
|
||||
uploader.value = new FilesUploadHandler()
|
||||
|
||||
uploader.value.on('start', () => {
|
||||
file.uploading = true
|
||||
fileUploadStarted.value = true
|
||||
})
|
||||
uploader.value.on('progress', (data) => {
|
||||
file.uploaded = data.uploaded
|
||||
file.total = data.total
|
||||
})
|
||||
uploader.value.on('error', (error) => {
|
||||
file.uploading = false
|
||||
file.errorMessage = error || 'Error Uploading File'
|
||||
})
|
||||
uploader.value.on('finish', () => {
|
||||
file.uploading = false
|
||||
})
|
||||
|
||||
uploader.value
|
||||
.upload(file, args || {})
|
||||
.then(() => {
|
||||
if (i === files.value.length - 1) {
|
||||
files.value = []
|
||||
show.value = false
|
||||
fileUploadStarted.value = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
file.uploading = false
|
||||
let errorMessage = 'Error Uploading File'
|
||||
if (error?._server_messages) {
|
||||
errorMessage = JSON.parse(JSON.parse(error._server_messages)[0]).message
|
||||
} else if (error?.exc) {
|
||||
errorMessage = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error
|
||||
}
|
||||
file.errorMessage = errorMessage
|
||||
})
|
||||
}
|
||||
</script>
|
||||
321
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
321
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed min-h-64 text-gray-600"
|
||||
@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-gray-600">
|
||||
{{ __('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="captureImage" />
|
||||
<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 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<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-gray-600">
|
||||
<div class="text-base text-gray-800">
|
||||
{{ 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-gray-600"
|
||||
: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-green-500': 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>
|
||||
</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 } 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(true)
|
||||
const showFileBrowser = ref(true)
|
||||
|
||||
const allowMultiple = ref(props.options.allowMultiple == false ? false : true)
|
||||
const disableFileBrowser = ref(props.options.disableFileBrowser || false)
|
||||
const allowWebLink = ref(props.options.allowWebLink == false ? false : true)
|
||||
const allowTakePhoto = ref(props.options.allowTakePhoto || 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)
|
||||
}
|
||||
|
||||
function captureImage() {
|
||||
//
|
||||
}
|
||||
|
||||
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 convertSize(size) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let unitIndex = 0
|
||||
while (size > 1024) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function fileIcon(type) {
|
||||
if (type.startsWith('audio')) {
|
||||
return FileAudioIcon
|
||||
} else if (type.startsWith('video')) {
|
||||
return FileVideoIcon
|
||||
}
|
||||
return FileTextIcon
|
||||
}
|
||||
</script>
|
||||
129
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal file
129
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal file
@ -0,0 +1,129 @@
|
||||
interface UploadOptions {
|
||||
file?: File
|
||||
private?: boolean
|
||||
fileUrl?: string
|
||||
folder?: string
|
||||
doctype?: string
|
||||
docname?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
type EventListenerOption = 'start' | 'progress' | 'finish' | 'error'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
csrf_token?: string
|
||||
}
|
||||
}
|
||||
|
||||
class FilesUploadHandler {
|
||||
listeners: { [event: string]: Function[] }
|
||||
failed: boolean
|
||||
|
||||
constructor() {
|
||||
this.listeners = {}
|
||||
this.failed = false
|
||||
}
|
||||
|
||||
on(event: EventListenerOption, handler: Function) {
|
||||
this.listeners[event] = this.listeners[event] || []
|
||||
this.listeners[event].push(handler)
|
||||
}
|
||||
|
||||
trigger(event: string, data?: any) {
|
||||
let handlers = this.listeners[event] || []
|
||||
handlers.forEach((handler) => {
|
||||
handler.call(this, data)
|
||||
})
|
||||
}
|
||||
|
||||
upload(file: File | null, options: UploadOptions): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('loadstart', () => {
|
||||
this.trigger('start')
|
||||
})
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
this.trigger('progress', {
|
||||
uploaded: e.loaded,
|
||||
total: e.total,
|
||||
})
|
||||
}
|
||||
})
|
||||
xhr.upload.addEventListener('load', () => {
|
||||
this.trigger('finish')
|
||||
})
|
||||
xhr.addEventListener('error', () => {
|
||||
this.trigger('error')
|
||||
reject()
|
||||
})
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState == XMLHttpRequest.DONE) {
|
||||
let error: any = null
|
||||
if (xhr.status === 200) {
|
||||
let r: any = null
|
||||
try {
|
||||
r = JSON.parse(xhr.responseText)
|
||||
} catch (e) {
|
||||
r = xhr.responseText
|
||||
}
|
||||
let out = r.message || r
|
||||
resolve(out)
|
||||
} else if (xhr.status === 403) {
|
||||
error = JSON.parse(xhr.responseText)
|
||||
} else if (xhr.status === 413) {
|
||||
this.failed = true
|
||||
error = 'Size exceeds the maximum allowed file size.'
|
||||
} else {
|
||||
this.failed = true
|
||||
try {
|
||||
error = JSON.parse(xhr.responseText)
|
||||
} catch (e) {
|
||||
// pass
|
||||
}
|
||||
}
|
||||
if (error && error.exc) {
|
||||
console.error(JSON.parse(error.exc)[0])
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.open('POST', '/api/method/upload_file', true)
|
||||
xhr.setRequestHeader('Accept', 'application/json')
|
||||
|
||||
if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
|
||||
xhr.setRequestHeader('X-Frappe-CSRF-Token', window.csrf_token)
|
||||
}
|
||||
|
||||
let formData = new FormData()
|
||||
|
||||
if (options.file && file) {
|
||||
formData.append('file', options.file, file.name)
|
||||
}
|
||||
formData.append('is_private', options.private || false ? '1' : '0')
|
||||
formData.append('folder', options.folder || 'Home')
|
||||
|
||||
if (options.fileUrl) {
|
||||
formData.append('file_url', options.fileUrl)
|
||||
}
|
||||
|
||||
if (options.doctype) {
|
||||
formData.append('doctype', options.doctype)
|
||||
}
|
||||
|
||||
if (options.docname) {
|
||||
formData.append('docname', options.docname)
|
||||
}
|
||||
|
||||
if (options.type) {
|
||||
formData.append('type', options.type)
|
||||
}
|
||||
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default FilesUploadHandler
|
||||
20
frontend/src/components/Icons/FileAudioIcon.vue
Normal file
20
frontend/src/components/Icons/FileAudioIcon.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-file-audio-2"
|
||||
>
|
||||
<path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v2" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<circle cx="3" cy="17" r="1" />
|
||||
<path d="M2 17v-3a4 4 0 0 1 8 0v3" />
|
||||
<circle cx="9" cy="17" r="1" />
|
||||
</svg>
|
||||
</template>
|
||||
19
frontend/src/components/Icons/FileVideoIcon.vue
Normal file
19
frontend/src/components/Icons/FileVideoIcon.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-file-video-2"
|
||||
>
|
||||
<path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<rect width="8" height="6" x="2" y="12" rx="1" />
|
||||
<path d="m10 15.5 4 2.5v-6l-4 2.5" />
|
||||
</svg>
|
||||
</template>
|
||||
Loading…
x
Reference in New Issue
Block a user