Files
next-ai-draw-io/components/file-preview-list.tsx

188 lines
7.9 KiB
TypeScript
Raw Normal View History

"use client"
import { FileCode, FileText, Loader2, X } from "lucide-react"
import Image from "next/image"
import { useEffect, useRef, useState } from "react"
import { isPdfFile, isTextFile } from "@/lib/pdf-utils"
function formatCharCount(count: number): string {
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}k`
}
return String(count)
}
interface FilePreviewListProps {
files: File[]
onRemoveFile: (fileToRemove: File) => void
pdfData?: Map<
File,
{ text: string; charCount: number; isExtracting: boolean }
>
}
export function FilePreviewList({
files,
onRemoveFile,
pdfData = new Map(),
}: FilePreviewListProps) {
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [imageUrls, setImageUrls] = useState<Map<File, string>>(new Map())
const imageUrlsRef = useRef<Map<File, string>>(new Map())
2025-11-10 09:17:11 +09:00
// Create and cleanup object URLs when files change
useEffect(() => {
const currentUrls = imageUrlsRef.current
const newUrls = new Map<File, string>()
files.forEach((file) => {
if (file.type.startsWith("image/")) {
// Reuse existing URL if file is already tracked
const existingUrl = currentUrls.get(file)
if (existingUrl) {
newUrls.set(file, existingUrl)
} else {
newUrls.set(file, URL.createObjectURL(file))
}
}
})
// Revoke URLs for files that are no longer in the list
currentUrls.forEach((url, file) => {
if (!newUrls.has(file)) {
URL.revokeObjectURL(url)
}
})
imageUrlsRef.current = newUrls
setImageUrls(newUrls)
}, [files])
// Cleanup all URLs on unmount only
useEffect(() => {
return () => {
imageUrlsRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
// Clear the ref so StrictMode remount creates fresh URLs
imageUrlsRef.current = new Map()
}
}, [])
// Clear selected image if its URL was revoked
useEffect(() => {
if (
selectedImage &&
!Array.from(imageUrls.values()).includes(selectedImage)
) {
setSelectedImage(null)
}
}, [imageUrls, selectedImage])
if (files.length === 0) return null
return (
2025-11-10 09:17:11 +09:00
<>
<div className="flex flex-wrap gap-2 mt-2 p-2 bg-muted/50 rounded-md">
{files.map((file, index) => {
const imageUrl = imageUrls.get(file) || null
const pdfInfo = pdfData.get(file)
2025-11-10 09:17:11 +09:00
return (
<div key={file.name + index} className="relative group">
<div
className={`w-20 h-20 border rounded-md overflow-hidden bg-muted ${
file.type.startsWith("image/") && imageUrl
? "cursor-pointer"
: ""
}`}
onClick={() =>
file.type.startsWith("image/") &&
imageUrl &&
setSelectedImage(imageUrl)
}
2025-11-10 09:17:11 +09:00
>
{file.type.startsWith("image/") && imageUrl ? (
2025-11-10 09:17:11 +09:00
<Image
src={imageUrl}
2025-11-10 09:17:11 +09:00
alt={file.name}
width={80}
height={80}
className="object-cover w-full h-full"
unoptimized
2025-11-10 09:17:11 +09:00
/>
) : isPdfFile(file) || isTextFile(file) ? (
<div className="flex flex-col items-center justify-center h-full p-1">
{pdfInfo?.isExtracting ? (
<Loader2 className="h-6 w-6 text-blue-500 mb-1 animate-spin" />
) : isPdfFile(file) ? (
<FileText className="h-6 w-6 text-red-500 mb-1" />
) : (
<FileCode className="h-6 w-6 text-blue-500 mb-1" />
)}
<span className="text-xs text-center truncate w-full px-1">
{file.name.length > 10
? `${file.name.slice(0, 7)}...`
: file.name}
</span>
{pdfInfo?.isExtracting ? (
<span className="text-[10px] text-muted-foreground">
Reading...
</span>
) : pdfInfo?.charCount ? (
<span className="text-[10px] text-green-600 font-medium">
{formatCharCount(
pdfInfo.charCount,
)}{" "}
chars
</span>
) : null}
</div>
2025-11-10 09:17:11 +09:00
) : (
<div className="flex items-center justify-center h-full text-xs text-center p-1">
{file.name}
</div>
)}
</div>
2025-11-10 09:17:11 +09:00
<button
type="button"
onClick={() => onRemoveFile(file)}
className="absolute -top-2 -right-2 bg-destructive rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove file"
>
<X className="h-3 w-3" />
</button>
</div>
)
2025-11-10 09:17:11 +09:00
})}
</div>
{/* Image Modal/Lightbox */}
{selectedImage && (
<div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<button
className="absolute top-4 right-4 z-10 bg-white rounded-full p-2 hover:bg-gray-200 transition-colors"
2025-11-10 09:17:11 +09:00
onClick={() => setSelectedImage(null)}
aria-label="Close"
>
2025-11-10 09:17:11 +09:00
<X className="h-6 w-6" />
</button>
<div className="relative w-auto h-auto max-w-[90vw] max-h-[90vh]">
2025-11-10 09:17:11 +09:00
<Image
src={selectedImage}
alt="Full size preview of uploaded diagram or image"
width={1200}
height={900}
className="object-contain max-w-full max-h-[90vh] w-auto h-auto"
2025-11-10 09:17:11 +09:00
onClick={(e) => e.stopPropagation()}
unoptimized
2025-11-10 09:17:11 +09:00
/>
</div>
</div>
2025-11-10 09:17:11 +09:00
)}
</>
)
}