mirror of
https://github.com/keven1024/015.git
synced 2026-05-26 15:13:30 +00:00
feat(front): add FileIcon components for audio, video, image, and generic file previews with dynamic thumbnail extraction
This commit is contained in:
41
front/components/FileIcon/File.vue
Normal file
41
front/components/FileIcon/File.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
|
||||
import type { filePreview } from './Index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: File | filePreview
|
||||
}>()
|
||||
const fileIcon = computed(() => {
|
||||
const [baseType, type] = props?.file?.type?.split('/')
|
||||
// if (baseType === 'video') {
|
||||
// return LucideFileVideo
|
||||
// }
|
||||
if (baseType === 'audio') {
|
||||
return LucideFileAudio
|
||||
}
|
||||
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
|
||||
return LucideFileCode
|
||||
}
|
||||
if (
|
||||
[
|
||||
'pdf',
|
||||
'msword',
|
||||
'vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'vnd.ms-excel',
|
||||
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'vnd.ms-powerpoint',
|
||||
'vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
].includes(type ?? '')
|
||||
) {
|
||||
return LucideFileText
|
||||
}
|
||||
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type ?? '')) {
|
||||
return LucideFileArchive
|
||||
}
|
||||
return LucideFile
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="fileIcon" />
|
||||
</template>
|
||||
24
front/components/FileIcon/Image.vue
Normal file
24
front/components/FileIcon/Image.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { filePreview } from './Index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: File | filePreview
|
||||
}>()
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
|
||||
return URL.createObjectURL(props?.file)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img v-if="!!imageUrl" :src="imageUrl" />
|
||||
</template>
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { cx } from 'class-variance-authority'
|
||||
import FileIcon from './File.vue'
|
||||
import ImageIcon from './Image.vue'
|
||||
import VideoIcon from './Video.vue'
|
||||
export type filePreview = {
|
||||
type: string
|
||||
name: string
|
||||
@@ -17,56 +20,20 @@ const props = withDefaults(
|
||||
size: 'md',
|
||||
}
|
||||
)
|
||||
const imageUrl = computed(() => {
|
||||
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
|
||||
return URL.createObjectURL(props?.file)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const [baseType, type] = props?.file?.type?.split('/')
|
||||
if (baseType === 'video') {
|
||||
return LucideFileVideo
|
||||
}
|
||||
if (baseType === 'audio') {
|
||||
return LucideFileAudio
|
||||
}
|
||||
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
|
||||
return LucideFileCode
|
||||
}
|
||||
if (
|
||||
[
|
||||
'pdf',
|
||||
'msword',
|
||||
'vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'vnd.ms-excel',
|
||||
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'vnd.ms-powerpoint',
|
||||
'vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
].includes(type ?? '')
|
||||
) {
|
||||
return LucideFileText
|
||||
}
|
||||
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type ?? '')) {
|
||||
return LucideFileArchive
|
||||
}
|
||||
return LucideFile
|
||||
})
|
||||
const isImage = computed(() => props?.file?.type?.startsWith('image/'))
|
||||
const isVideo = computed(() => props?.file?.type?.startsWith('video/'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!!imageUrl" :class="cx('flex overflow-hidden', size === 'sm' && 'max-w-20 max-h-16', size === 'md' && 'max-w-30 max-h-20')">
|
||||
<img :src="imageUrl" class="block max-w-full max-h-full object-contain border border-black/20 rounded" />
|
||||
<div v-if="isImage || isVideo" :class="cx('flex overflow-hidden', size === 'sm' && 'max-w-20 max-h-16', size === 'md' && 'max-w-30 max-h-20')">
|
||||
<component
|
||||
:is="isImage ? ImageIcon : VideoIcon"
|
||||
:file="props?.file"
|
||||
class="block max-w-full max-h-full object-contain border border-black/20 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!imageUrl"
|
||||
v-else
|
||||
:class="
|
||||
cx(
|
||||
'flex justify-center items-center bg-white/80',
|
||||
@@ -76,6 +43,6 @@ const fileIcon = computed(() => {
|
||||
)
|
||||
"
|
||||
>
|
||||
<component :is="fileIcon" class="size-[62.5%]" />
|
||||
<component :is="FileIcon" :file="props?.file" class="size-[62.5%]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
75
front/components/FileIcon/Video.vue
Normal file
75
front/components/FileIcon/Video.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { filePreview } from './Index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: File | filePreview
|
||||
}>()
|
||||
|
||||
const { state: thumbnailUrl } = useAsyncState(async () => {
|
||||
if (props.file instanceof File && props.file.type.startsWith('video/')) {
|
||||
return await extractThumbnail(props.file)
|
||||
}
|
||||
return null
|
||||
}, null)
|
||||
|
||||
async function extractThumbnail(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video')
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
video.muted = true
|
||||
video.playsInline = true
|
||||
video.preload = 'metadata'
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
video.currentTime = video.duration * 0.1
|
||||
}
|
||||
|
||||
video.onseeked = async () => {
|
||||
try {
|
||||
// WebCodecs: capture a VideoFrame from the video element
|
||||
const frame = new VideoFrame(video)
|
||||
const bitmap = await createImageBitmap(frame)
|
||||
frame.close()
|
||||
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(bitmap, 0, 0)
|
||||
bitmap.close()
|
||||
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(e)
|
||||
}
|
||||
}
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(new Error('Video load failed'))
|
||||
}
|
||||
|
||||
video.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (!!thumbnailUrl.value) {
|
||||
URL.revokeObjectURL(thumbnailUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="thumbnailUrl" class="relative grayscale-50 overflow-hidden">
|
||||
<img :src="thumbnailUrl" class="object-contain block max-w-full max-h-full" />
|
||||
<LucidePlay class="size-[40%] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="h-16 aspect-video flex justify-center items-center bg-white/80 rounded-sm">
|
||||
<LucideVideo class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user