gongxiangp2/components/work-review/WorkReviewImageDropzone.tsx

162 lines
5.9 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, useId, 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,
label = '图片',
}: {
files: File[]
onChange: (files: File[]) => void
disabled?: boolean
label?: string
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const hintId = useId()
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}
aria-label={`选择${label}`}
aria-describedby={hintId}
className="hidden"
onChange={(event) => {
addFiles(event.currentTarget.files || [])
event.currentTarget.value = ''
}}
/>
<button
type="button"
disabled={disabled}
aria-label={`上传${label}`}
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)
}}
aria-describedby={hintId}
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-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 ${
dragging
? '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-sm font-bold text-[#0050cb]">
{dragging ? '松开即可添加' : files.length > 0 ? '继续添加图片' : '添加图片(可多选 / 拖拽)'}
</span>
<span id={hintId} className="mt-1 text-sm leading-5 text-[#667085]">
JPG / PNG / GIF / WebP,单张 ≤ 200MB
</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-contain"
/>
<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-1 top-1 flex h-11 w-11 items-center justify-center rounded-full bg-black/65 text-[18px] text-white hover:bg-[#b42318] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] disabled:cursor-not-allowed"
aria-label={`移除 ${file.name}`}
>
×
</button>
</div>
<div className="min-w-0 px-2.5 py-2">
<div className="truncate text-sm font-semibold text-[#344054]" title={file.name}>{file.name}</div>
<div className="mt-0.5 text-sm text-[#667085]">
{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>
)
}