gongxiangp2/components/work-review/WorkReviewImageDropzone.tsx

155 lines
5.6 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 Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
const MAX_FILE_COUNT = 60
const MAX_FILE_BYTES = 200 * 1024 * 1024
function fileKey(file: File) {
return `${file.name}-${file.size}-${file.lastModified}`
}
export function WorkReviewImageDropzone({
files,
onChange,
disabled,
}: {
files: File[]
onChange: (files: File[]) => void
disabled?: boolean
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const dragDepthRef = useRef(0)
const [dragging, setDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const previewUrls = useMemo(() => files.map((file) => URL.createObjectURL(file)), [files])
useEffect(() => () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
}, [previewUrls])
const addFiles = (selected: FileList | File[]) => {
if (disabled) return
const candidates = [...files, ...Array.from(selected)]
const deduplicated = candidates.filter((file, index, items) => (
items.findIndex((candidate) => fileKey(candidate) === fileKey(file)) === index
))
const invalid = deduplicated.find((file) => !file.type.startsWith('image/'))
if (invalid) {
setError(`文件「${invalid.name}」不是图片`)
return
}
const oversized = deduplicated.find((file) => file.size > MAX_FILE_BYTES)
if (oversized) {
setError(`图片「${oversized.name}」超过 200MB`)
return
}
if (deduplicated.length > MAX_FILE_COUNT) {
setError(`一次最多选择 ${MAX_FILE_COUNT} 张图片`)
return
}
setError(null)
onChange(deduplicated)
}
return (
<div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
disabled={disabled}
className="hidden"
onChange={(event) => {
addFiles(event.currentTarget.files || [])
event.currentTarget.value = ''
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current += 1
setDragging(true)
}}
onDragOver={(event) => {
event.preventDefault()
if (!disabled) event.dataTransfer.dropEffect = 'copy'
}}
onDragLeave={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
dragDepthRef.current = 0
setDragging(false)
addFiles(event.dataTransfer.files)
}}
className={`flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 py-5 text-center transition-all disabled:cursor-not-allowed disabled:opacity-60 ${
dragging
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
: 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]'
}`}
>
<span className={`flex h-9 w-9 items-center justify-center rounded-full text-[21px] ${
dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eaf2ff] text-[#0050cb]'
}`}>↑</span>
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">
{dragging ? '松开即可添加' : '拖拽图片到这里,或点击选择'}
</span>
<span className="mt-1 text-[11px] leading-5 text-[#7b8494]">
支持 PNG、JPG、GIF、WebP;长图会自动生成预览与切片
</span>
</button>
{error && <div className="mt-2 text-[12px] font-semibold text-[#b42318]" role="alert">{error}</div>}
{files.length > 0 && (
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{files.map((file, index) => (
<div key={fileKey(file)} className="group relative overflow-hidden rounded-lg border border-[#e1e7ef] bg-white">
<div className="relative aspect-[4/3]">
<Image
src={previewUrls[index]}
alt={file.name}
fill
unoptimized
className="object-cover"
/>
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
{index + 1}
</span>
<button
type="button"
disabled={disabled}
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/65 text-[18px] text-white opacity-90 transition-opacity hover:bg-[#b42318] disabled:cursor-not-allowed"
aria-label={`移除 ${file.name}`}
>
×
</button>
</div>
<div className="min-w-0 px-2.5 py-2">
<div className="truncate text-[11px] font-semibold text-[#344054]" title={file.name}>{file.name}</div>
<div className="mt-0.5 text-[10px] text-[#98a2b3]">
{file.size < 1024 * 1024
? `${Math.max(1, Math.round(file.size / 1024))} KB`
: `${(file.size / 1024 / 1024).toFixed(1)} MB`}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}