246 lines
8.2 KiB
TypeScript
246 lines
8.2 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useId, useRef, useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { useGSAP } from '@gsap/react'
|
||
import gsap from 'gsap'
|
||
import { LoaderCircle, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||
|
||
import {
|
||
hasDragLeftDropZone,
|
||
prepareFileDrag,
|
||
validateImageFiles,
|
||
} from './drag-drop-utils'
|
||
import { assertVideoPreviewImageIsSafeToDecode } from './image-processor'
|
||
import { formatBytes } from './upload-utils'
|
||
import { dropZoneActiveClass, dropZoneIdleClass, fieldHintClass, fieldLabelClass, uploadAreaButtonClass, uploadAreaEmptyButtonClass } from './upload-field-styles'
|
||
|
||
const VIDEO_PREVIEW_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'] as const
|
||
const VIDEO_PREVIEW_MAX_BYTES = 50 * 1024 * 1024
|
||
|
||
gsap.registerPlugin(useGSAP)
|
||
|
||
interface VideoPreviewImageUploadProps {
|
||
previewUrl?: string | null
|
||
fileName?: string
|
||
fileSize?: number
|
||
disabled?: boolean
|
||
compact?: boolean
|
||
onFileSelect: (file: File) => void | Promise<void>
|
||
onRemove: () => void
|
||
onValidationError: (message: string) => void
|
||
onProcessingChange?: (processing: boolean) => void
|
||
}
|
||
|
||
export default function VideoPreviewImageUpload({
|
||
previewUrl,
|
||
fileName,
|
||
fileSize,
|
||
disabled = false,
|
||
compact = false,
|
||
onFileSelect,
|
||
onRemove,
|
||
onValidationError,
|
||
onProcessingChange,
|
||
}: VideoPreviewImageUploadProps) {
|
||
const generatedId = useId()
|
||
const inputId = `video-preview-${generatedId.replace(/:/g, '')}`
|
||
const previewPanelRef = useRef<HTMLDivElement>(null)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
const isMountedRef = useRef(true)
|
||
const processingRef = useRef(false)
|
||
const [isDragging, setIsDragging] = useState(false)
|
||
const [isProcessing, setIsProcessing] = useState(false)
|
||
|
||
useEffect(() => {
|
||
isMountedRef.current = true
|
||
return () => {
|
||
isMountedRef.current = false
|
||
}
|
||
}, [])
|
||
|
||
useGSAP(() => {
|
||
if (
|
||
!previewUrl
|
||
|| !previewPanelRef.current
|
||
|| typeof window.matchMedia !== 'function'
|
||
) return
|
||
|
||
const motion = gsap.matchMedia()
|
||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||
gsap.fromTo(
|
||
previewPanelRef.current,
|
||
{ autoAlpha: 0.72, scale: 0.985, y: 4 },
|
||
{
|
||
autoAlpha: 1,
|
||
scale: 1,
|
||
y: 0,
|
||
duration: 0.22,
|
||
ease: 'power2.out',
|
||
overwrite: 'auto',
|
||
clearProps: 'transform,opacity,visibility',
|
||
},
|
||
)
|
||
})
|
||
|
||
return () => motion.revert()
|
||
}, { dependencies: [previewUrl], revertOnUpdate: true })
|
||
|
||
const openFilePicker = () => {
|
||
if (!disabled && !isProcessing) inputRef.current?.click()
|
||
}
|
||
|
||
const processFiles = async (files: FileList | File[]) => {
|
||
if (disabled || processingRef.current) {
|
||
onValidationError('当前操作尚未完成,请稍候再更换视频预览图')
|
||
return
|
||
}
|
||
|
||
const { acceptedFiles, errors } = validateImageFiles(files, {
|
||
maxBytes: VIDEO_PREVIEW_MAX_BYTES,
|
||
maxCount: 1,
|
||
allowedExtensions: VIDEO_PREVIEW_EXTENSIONS,
|
||
})
|
||
onValidationError(errors.join(';'))
|
||
|
||
const file = acceptedFiles[0]
|
||
if (!file) return
|
||
|
||
processingRef.current = true
|
||
setIsProcessing(true)
|
||
onProcessingChange?.(true)
|
||
try {
|
||
await assertVideoPreviewImageIsSafeToDecode(file)
|
||
await onFileSelect(file)
|
||
} catch (error) {
|
||
console.error('[VideoPreviewImageUpload] 预览图处理失败:', error)
|
||
onValidationError(error instanceof Error ? error.message : '视频预览图处理失败,请重试或更换图片')
|
||
} finally {
|
||
processingRef.current = false
|
||
if (isMountedRef.current) {
|
||
setIsProcessing(false)
|
||
onProcessingChange?.(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
void processFiles(event.currentTarget.files || [])
|
||
event.currentTarget.value = ''
|
||
}
|
||
|
||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||
prepareFileDrag(event, disabled || isProcessing)
|
||
setIsDragging(false)
|
||
void processFiles(event.dataTransfer.files)
|
||
}
|
||
|
||
const dropHandlers = {
|
||
onDragEnter: (event: React.DragEvent<HTMLDivElement>) => {
|
||
prepareFileDrag(event, disabled || isProcessing)
|
||
if (!disabled && !isProcessing) setIsDragging(true)
|
||
},
|
||
onDragLeave: (event: React.DragEvent<HTMLDivElement>) => {
|
||
event.preventDefault()
|
||
event.stopPropagation()
|
||
if (hasDragLeftDropZone(event)) setIsDragging(false)
|
||
},
|
||
onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
|
||
prepareFileDrag(event, disabled || isProcessing)
|
||
},
|
||
onDrop: handleDrop,
|
||
}
|
||
|
||
return (
|
||
<div aria-busy={isProcessing}>
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||
<label htmlFor={inputId} className={fieldLabelClass}>
|
||
视频预览图 <span className="font-normal text-[#6b7075]">(选填)</span>
|
||
</label>
|
||
<p id={`${inputId}-help`} className={fieldHintClass}>
|
||
未填写时自动从视频截取
|
||
</p>
|
||
</div>
|
||
|
||
<input
|
||
ref={inputRef}
|
||
id={inputId}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||
onChange={handleFileChange}
|
||
className="hidden"
|
||
disabled={disabled || isProcessing}
|
||
aria-describedby={`${inputId}-help`}
|
||
/>
|
||
|
||
{previewUrl ? (
|
||
<div
|
||
ref={previewPanelRef}
|
||
className={`flex flex-wrap items-center gap-3 p-3 ${isDragging ? dropZoneActiveClass : dropZoneIdleClass} ${disabled ? 'opacity-60' : ''}`}
|
||
{...dropHandlers}
|
||
>
|
||
<div className={`relative shrink-0 overflow-hidden rounded-[8px] bg-[#edf0f2] ${compact ? 'h-16 w-28' : 'h-20 w-32'}`}>
|
||
<Image
|
||
src={previewUrl}
|
||
alt="已选择的视频预览图"
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
sizes={compact ? '112px' : '128px'}
|
||
/>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[#1a1c1d]">
|
||
{fileName || '已选择预览图'}
|
||
</p>
|
||
<p className={fieldHintClass}>
|
||
{fileSize ? `${formatBytes(fileSize)} · ` : ''}手动图片将优先展示
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={openFilePicker}
|
||
disabled={disabled || isProcessing}
|
||
className={uploadAreaButtonClass}
|
||
>
|
||
{isProcessing ? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="h-4 w-4" aria-hidden="true" />}
|
||
更换
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
disabled={disabled || isProcessing}
|
||
className="inline-flex h-11 w-11 items-center justify-center rounded-[8px] text-[#b42318] transition-colors hover:bg-[#fff1f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||
aria-label="移除视频预览图"
|
||
title="移除视频预览图"
|
||
>
|
||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div
|
||
aria-disabled={disabled || isProcessing}
|
||
className={`p-3 ${isDragging ? dropZoneActiveClass : dropZoneIdleClass} ${disabled || isProcessing ? 'cursor-not-allowed opacity-60' : ''}`}
|
||
{...dropHandlers}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={openFilePicker}
|
||
disabled={disabled || isProcessing}
|
||
aria-describedby={`${inputId}-help`}
|
||
className={uploadAreaEmptyButtonClass}
|
||
>
|
||
{isProcessing ? <LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /> : <Plus className="h-5 w-5" aria-hidden="true" />}
|
||
{isProcessing ? '正在处理图片...' : '添加预览图'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{!previewUrl && <p className={`mt-1.5 ${fieldHintClass}`}>JPG、PNG、WebP · 最大 50MB · 可拖入</p>}
|
||
</div>
|
||
)
|
||
}
|