gongxiangp2/components/upload/VideoPreviewImageUpload.tsx

246 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}