feat: implement drag-and-drop file upload feature in photo library

- Added PhotoLibraryDropUpload component to handle file drag-and-drop uploads.
- Integrated file type validation to support images and specific video formats.
- Implemented user feedback through modals and toasts for unsupported file types.
- Updated localization files to include new strings for drag-and-drop functionality in English and Chinese.
- Modified PhotoLibraryTab to include the new upload component.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-14 15:37:30 +08:00
parent 9229ad63dd
commit 0da12eb301
4 changed files with 261 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
import { Modal } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { UploadCloud } from 'lucide-react'
import { m } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useShallow } from 'zustand/shallow'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { usePhotoLibraryStore } from './PhotoLibraryProvider'
import { PhotoUploadConfirmModal } from './PhotoUploadConfirmModal'
import type { PhotoUploadRequestOptions } from './upload.types'
const photoLibraryDndKeys = {
title: 'photos.library.dnd.title',
description: 'photos.library.dnd.description',
unsupported: 'photos.library.dnd.unsupported',
} as const satisfies Record<string, I18nKeys>
function isAcceptedPhotoAsset(file: File) {
if (file.type.startsWith('image/')) return true
if (file.type === 'video/quicktime') return true
const name = file.name.toLowerCase()
return name.endsWith('.heic') || name.endsWith('.heif') || name.endsWith('.hif') || name.endsWith('.mov')
}
export function PhotoLibraryDropUpload() {
const { t } = useTranslation()
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const [isOverlayMounted, setIsOverlayMounted] = useState(false)
const [isOverlayVisible, setIsOverlayVisible] = useState(false)
const isDraggingFilesRef = useRef(false)
const latestRef = useRef<{
availableTags: string[]
uploadAssets: (files: FileList, options?: PhotoUploadRequestOptions) => Promise<void>
} | null>(null)
const overlayUnmountTimerRef = useRef<number | null>(null)
useEffect(() => {
isDraggingFilesRef.current = isDraggingFiles
}, [isDraggingFiles])
useEffect(() => {
if (overlayUnmountTimerRef.current) {
window.clearTimeout(overlayUnmountTimerRef.current)
overlayUnmountTimerRef.current = null
}
if (isDraggingFiles) {
setIsOverlayMounted(true)
requestAnimationFrame(() => {
setIsOverlayVisible(true)
})
return
}
setIsOverlayVisible(false)
overlayUnmountTimerRef.current = window.setTimeout(() => {
setIsOverlayMounted(false)
overlayUnmountTimerRef.current = null
}, 180)
}, [isDraggingFiles])
useEffect(() => {
return () => {
if (overlayUnmountTimerRef.current) {
window.clearTimeout(overlayUnmountTimerRef.current)
overlayUnmountTimerRef.current = null
}
}
}, [])
const { availableTags, uploadAssets } = usePhotoLibraryStore(
useShallow((state) => ({
availableTags: state.availableTags,
uploadAssets: state.uploadAssets,
})),
)
useEffect(() => {
latestRef.current = {
availableTags,
uploadAssets,
}
}, [availableTags, uploadAssets])
useEffect(() => {
const hasFileDrag = (event: DragEvent) => {
const types = event.dataTransfer?.types
if (!types) return false
return Array.from(types).includes('Files')
}
const resetDraggingState = () => {
setIsDraggingFiles(false)
}
const handleDragEnter = (event: DragEvent) => {
if (!hasFileDrag(event)) return
if (isDraggingFilesRef.current) return
setIsDraggingFiles(true)
}
const handleDragLeave = (_event: DragEvent) => {
if (!isDraggingFilesRef.current) return
const event = _event as DragEvent & { relatedTarget?: EventTarget | null }
const isLeavingWindow = event.relatedTarget == null
if (!isLeavingWindow) return
resetDraggingState()
}
const handleDragOver = (event: DragEvent) => {
if (!hasFileDrag(event)) return
event.preventDefault()
if (!isDraggingFilesRef.current) {
setIsDraggingFiles(true)
}
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'
}
}
const handleDrop = (event: DragEvent) => {
if (!hasFileDrag(event)) return
event.preventDefault()
resetDraggingState()
const files = event.dataTransfer?.files
if (!files || files.length === 0) return
const latest = latestRef.current
if (!latest) return
const selectedFiles = Array.from(files).filter((file) => isAcceptedPhotoAsset(file))
if (selectedFiles.length === 0) {
toast.error(t(photoLibraryDndKeys.unsupported))
return
}
Modal.present(
PhotoUploadConfirmModal,
{
files: selectedFiles,
availableTags: latest.availableTags,
onUpload: latest.uploadAssets,
},
{
dismissOnOutsideClick: false,
},
)
}
window.addEventListener('dragenter', handleDragEnter, true)
window.addEventListener('dragleave', handleDragLeave, true)
window.addEventListener('dragover', handleDragOver, true)
window.addEventListener('drop', handleDrop, true)
window.addEventListener('dragend', resetDraggingState, true)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
resetDraggingState()
}
const handleBlur = () => {
resetDraggingState()
}
const handleVisibilityChange = () => {
if (!document.hidden) return
resetDraggingState()
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('blur', handleBlur)
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
window.removeEventListener('dragenter', handleDragEnter, true)
window.removeEventListener('dragleave', handleDragLeave, true)
window.removeEventListener('dragover', handleDragOver, true)
window.removeEventListener('drop', handleDrop, true)
window.removeEventListener('dragend', resetDraggingState, true)
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('blur', handleBlur)
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [t])
if (!isOverlayMounted) {
return null
}
return createPortal(
<m.div
initial={false}
animate={{
opacity: isOverlayVisible ? 1 : 0,
}}
transition={Spring.presets.smooth}
className="fixed inset-0 z-50 pointer-events-none bg-background/80 backdrop-blur-sm"
>
<m.div
initial={false}
animate={{
opacity: isOverlayVisible ? 1 : 0,
y: isOverlayVisible ? 0 : 12,
scale: isOverlayVisible ? 1 : 0.98,
}}
transition={Spring.presets.smooth}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<LinearBorderPanel>
<div className="relative w-[min(480px,90vw)] overflow-hidden bg-material-ultra-thick">
{/* Decorative background layer */}
<div className="pointer-events-none absolute inset-0 opacity-50">
<div className="absolute -inset-32 blur-3xl bg-linear-to-br from-accent/25 via-transparent to-transparent" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),transparent_60%)]" />
</div>
{/* Content */}
<div className="relative p-6">
<div className="flex items-start gap-4">
<m.div
initial={false}
animate={{
scale: isOverlayVisible ? 1 : 0.9,
}}
transition={Spring.presets.snappy}
className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-accent/40 bg-accent/15 text-accent transition-all duration-200"
>
<UploadCloud className="size-5" strokeWidth={2} />
</m.div>
<div className="min-w-0 flex-1 space-y-1.5 pt-0.5">
<div className="text-sm font-medium leading-tight">{t(photoLibraryDndKeys.title)}</div>
<div className="text-xs text-text-secondary leading-relaxed">
{t(photoLibraryDndKeys.description)}
</div>
</div>
</div>
</div>
</div>
</LinearBorderPanel>
</m.div>
</m.div>,
document.body,
)
}

View File

@@ -1,3 +1,4 @@
import { PhotoLibraryDropUpload } from '../library/PhotoLibraryDropUpload'
import { PhotoLibraryGrid } from '../library/PhotoLibraryGrid'
import { PhotoLibraryProvider } from '../library/PhotoLibraryProvider'
import { PhotoPageScaffold } from '../PhotoPageScaffold'
@@ -6,6 +7,7 @@ export function PhotoLibraryTab() {
return (
<PhotoLibraryProvider isActive>
<PhotoPageScaffold activeTab="library">
<PhotoLibraryDropUpload />
<PhotoLibraryGrid />
</PhotoPageScaffold>
</PhotoLibraryProvider>

View File

@@ -256,6 +256,10 @@
"photos.library.delete.option.description": "When checked, the remote originals and thumbnails will be removed as well.",
"photos.library.delete.option.title": "Also delete storage files",
"photos.library.delete.title": "Delete this asset?",
"photos.library.dnd.description": "Release to review and start uploading.",
"photos.library.dnd.disabled": "Uploading is in progress. Please try again later.",
"photos.library.dnd.title": "Drop files to upload",
"photos.library.dnd.unsupported": "No supported files were detected.",
"photos.library.empty.description": "Use the upload button to add new photos to your library.",
"photos.library.empty.title": "No photos yet",
"photos.library.exif.empty": "No EXIF data is available for this asset.",

View File

@@ -205,6 +205,10 @@
"photos.library.delete.option.description": "勾选后会删除远程原件与缩略图。",
"photos.library.delete.option.title": "同时删除存储文件",
"photos.library.delete.title": "确定要删除该素材?",
"photos.library.dnd.description": "松开后进入上传流程。",
"photos.library.dnd.disabled": "正在上传,请稍后再试。",
"photos.library.dnd.title": "拖放文件以上传",
"photos.library.dnd.unsupported": "未检测到可上传的文件。",
"photos.library.empty.description": "使用上传按钮将照片添加到图库。",
"photos.library.empty.title": "还没有照片",
"photos.library.exif.empty": "该素材没有可用的 EXIF 数据。",