gongxiangp2/components/upload/VideoPreviewImageUpload.tsx
2026-08-07 23:28:55 +08:00

259 lines
9.7 KiB
TypeScript
Raw 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 { ImagePlus, LoaderCircle, RefreshCw, Trash2 } from 'lucide-react'
import {
handleDropZoneKeyDown,
hasDragLeftDropZone,
prepareFileDrag,
validateImageFiles,
} from './drag-drop-utils'
import { assertVideoPreviewImageIsSafeToDecode } from './image-processor'
import { formatBytes } from './upload-utils'
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 className="space-y-2" aria-busy={isProcessing}>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<label htmlFor={inputId} className="text-sm font-medium text-gray-700 dark:text-gray-300">
视频预览图 <span className="font-normal text-gray-500 dark:text-gray-400">(选填)</span>
</label>
<p id={`${inputId}-help`} className="text-xs text-gray-500 dark:text-gray-400">
未填写时自动从视频截取
</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-col gap-3 rounded-apple border p-3 sm:flex-row sm:items-center ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-900/40'
} ${disabled ? 'opacity-60' : ''}`}
{...dropHandlers}
>
<div className={`relative shrink-0 overflow-hidden rounded-apple bg-gray-200 dark:bg-gray-700 ${compact ? 'h-20 w-32' : 'aspect-video w-full sm:w-44'}`}>
<Image
src={previewUrl}
alt="已选择的视频预览图"
fill
unoptimized
className="object-cover"
sizes={compact ? '128px' : '(max-width: 640px) 100vw, 176px'}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-800 dark:text-gray-200">
{fileName || '已选择预览图'}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{fileSize ? `${formatBytes(fileSize)} · ` : ''}手动图片将优先展示
</p>
</div>
<div className="flex shrink-0 items-center gap-1 self-end sm:self-center">
<button
type="button"
onClick={openFilePicker}
disabled={disabled || isProcessing}
className="inline-flex min-h-11 items-center gap-2 rounded-apple px-3 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-200 dark:hover:bg-gray-700 dark:focus-visible:ring-offset-gray-900"
>
{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-apple text-red-600 transition-colors hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-950/30 dark:focus-visible:ring-offset-gray-900"
aria-label="移除视频预览图"
title="移除视频预览图"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
) : (
<div
role="button"
tabIndex={disabled || isProcessing ? -1 : 0}
aria-disabled={disabled || isProcessing}
aria-describedby={`${inputId}-help`}
className={`flex min-h-24 items-center gap-3 rounded-apple border-2 border-dashed px-4 py-3 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900 ${
isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900/30 dark:hover:border-gray-500 dark:hover:bg-gray-900/60'
} ${disabled || isProcessing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
onClick={openFilePicker}
onKeyDown={(event) => handleDropZoneKeyDown(event, openFilePicker)}
{...dropHandlers}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-apple bg-white text-gray-600 shadow-sm dark:bg-gray-800 dark:text-gray-300">
{isProcessing ? <LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /> : <ImagePlus className="h-5 w-5" aria-hidden="true" />}
</span>
<span className="min-w-0">
<span className="block text-sm font-medium text-gray-800 dark:text-gray-200">
{isProcessing ? '正在处理预览图...' : '添加预览图'}
</span>
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">
JPG、PNG 或 WebP,最大 50MB,也可拖拽到这里
</span>
</span>
</div>
)}
</div>
)
}