259 lines
9.7 KiB
TypeScript
259 lines
9.7 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 { 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>
|
||
)
|
||
}
|