Files
rustfs-console/components/object/preview-modal.tsx
T

420 lines
15 KiB
TypeScript

"use client"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Spinner } from "@/components/ui/spinner"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { RiFullscreenExitLine, RiFullscreenLine } from "@remixicon/react"
import { PdfViewer } from "@/components/object/pdf-viewer"
import { ParquetViewer } from "@/components/object/parquet-viewer"
import { TiffViewer } from "@/components/object/tiff-viewer"
import Image from "next/image"
const SAFE_TEXT_MIMES = [
"application/json",
"application/jsonl",
"application/ndjson",
"application/x-ndjson",
"application/xml",
"text/plain",
"text/xml",
"text/csv",
"text/markdown",
]
const SAFE_TEXT_EXTENSIONS = [".txt", ".json", ".jsonl", ".ndjson", ".xml", ".csv", ".md", ".yml", ".yaml"]
const SAFE_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", ".tiff"]
const ALLOWED_SIZE = 1024 * 1024 * 2 // 2MB
type PreviewMode = "text" | "image" | "pdf" | "parquet" | "sandbox" | "download" | "tiff"
const PARQUET_MIMES = ["application/vnd.apache.parquet", "application/x-parquet", "application/parquet"]
const PARQUET_EXTENSIONS = [".parquet", ".pq"]
interface ObjectPreviewModalProps {
show: boolean
onShowChange: (show: boolean) => void
object: {
ContentType?: string
ContentLength?: number
Key?: string
SignedUrl?: string
} | null
}
type FullscreenDocument = Document & {
webkitExitFullscreen?: () => Promise<void> | void
webkitFullscreenElement?: Element | null
}
type FullscreenElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void
}
function normalizeContentType(contentType: string) {
return contentType.split(";")[0]?.trim().toLowerCase() ?? ""
}
function isSafeTextPreview(contentType: string, objectKey: string, objectSize: number) {
if (objectSize > ALLOWED_SIZE) return false
if (SAFE_TEXT_MIMES.includes(contentType)) return true
const keyLower = objectKey.toLowerCase()
return SAFE_TEXT_EXTENSIONS.some((ext) => keyLower.endsWith(ext))
}
function isImagePreview(contentType: string, objectKey: string) {
const keyLower = objectKey.toLowerCase()
const isSvg = contentType === "image/svg+xml" || keyLower.endsWith(".svg")
if (isSvg) return false
if (contentType.startsWith("image/")) return true
return SAFE_IMAGE_EXTENSIONS.some((ext) => keyLower.endsWith(ext))
}
function getPreviewMode(hasPreviewUrl: boolean, canRenderText: boolean, canRenderImage: boolean): PreviewMode {
if (!hasPreviewUrl) return "download"
if (canRenderImage) return "image"
return canRenderText ? "text" : "sandbox"
}
function isPdfPreview(contentType: string) {
return contentType === "application/pdf"
}
function isParquetPreview(contentType: string, objectKey: string) {
const keyLower = objectKey.toLowerCase()
if (PARQUET_MIMES.includes(contentType)) return true
return PARQUET_EXTENSIONS.some((ext) => keyLower.endsWith(ext))
}
function isTiffPreview(objectKey: string) {
const keyLower = objectKey.toLowerCase()
return keyLower.endsWith(".tif") || keyLower.endsWith(".tiff")
}
function getFullscreenElement(doc: FullscreenDocument): Element | null {
return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null
}
function exitFullscreen(doc: FullscreenDocument): Promise<void> {
if (typeof doc.exitFullscreen === "function") {
return Promise.resolve(doc.exitFullscreen()).then(() => undefined)
}
if (typeof doc.webkitExitFullscreen === "function") {
return Promise.resolve(doc.webkitExitFullscreen()).then(() => undefined)
}
return Promise.resolve()
}
function requestFullscreen(element: FullscreenElement): Promise<void> {
if (typeof element.requestFullscreen === "function") {
return Promise.resolve(element.requestFullscreen()).then(() => undefined)
}
if (typeof element.webkitRequestFullscreen === "function") {
return Promise.resolve(element.webkitRequestFullscreen()).then(() => undefined)
}
return Promise.resolve()
}
export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreviewModalProps) {
const { t } = useTranslation()
const [textContent, setTextContent] = React.useState("")
const [loading, setLoading] = React.useState(false)
const [isFormatted, setIsFormatted] = React.useState(true)
const [imageNaturalSize, setImageNaturalSize] = React.useState<{ width: number; height: number } | null>(null)
const [imageFitScale, setImageFitScale] = React.useState(1)
const [imageLayoutReady, setImageLayoutReady] = React.useState(false)
const [isImageFullscreen, setIsImageFullscreen] = React.useState(false)
const imagePreviewRef = React.useRef<HTMLDivElement | null>(null)
const imageViewportRef = React.useRef<HTMLDivElement | null>(null)
const imageSizeCacheRef = React.useRef<Record<string, { width: number; height: number }>>({})
const contentType = object?.ContentType ?? ""
const previewUrl = object?.SignedUrl ?? ""
const hasPreviewUrl = Boolean(previewUrl)
const objectSize = Number(object?.ContentLength ?? 0)
const objectKey = object?.Key ?? ""
const objectKeyLower = objectKey.toLowerCase()
const normalizedContentType = normalizeContentType(contentType)
const isJson = normalizedContentType === "application/json" || objectKeyLower.endsWith(".json")
const canRenderText = hasPreviewUrl && isSafeTextPreview(normalizedContentType, objectKey, objectSize)
const canRenderImage = hasPreviewUrl && isImagePreview(normalizedContentType, objectKey)
const canRenderPdf = hasPreviewUrl && isPdfPreview(normalizedContentType)
const canRenderParquet = hasPreviewUrl && isParquetPreview(normalizedContentType, objectKey)
const canRenderTiff = hasPreviewUrl && isTiffPreview(objectKey)
const previewMode: PreviewMode = canRenderParquet
? "parquet"
: canRenderPdf
? "pdf"
: canRenderTiff
? "tiff"
: getPreviewMode(hasPreviewUrl, canRenderText, canRenderImage)
const isImageMode = previewMode === "image"
const isSelfScrollMode = isImageMode || previewMode === "parquet" || previewMode === "tiff"
const getFormattedContent = () => {
if (!isJson || !isFormatted) return textContent
try {
const parsed = JSON.parse(textContent)
return JSON.stringify(parsed, null, 2)
} catch {
return textContent
}
}
React.useEffect(() => {
if (show && previewMode === "text" && previewUrl) {
const controller = new AbortController()
setLoading(true)
setIsFormatted(true)
setTextContent("")
fetch(previewUrl, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
})
.then(setTextContent)
.catch((error: unknown) => {
if ((error as Error)?.name !== "AbortError") setTextContent(t("Preview unavailable"))
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false)
})
return () => controller.abort()
} else if (!show) {
setTextContent("")
setLoading(false)
}
}, [show, previewMode, previewUrl, t])
React.useEffect(() => {
const cachedSize = previewUrl ? imageSizeCacheRef.current[previewUrl] : undefined
setImageNaturalSize(cachedSize ?? null)
setImageFitScale(1)
setImageLayoutReady(false)
}, [previewUrl])
React.useEffect(() => {
const handleFullscreenChange = () => {
setIsImageFullscreen(getFullscreenElement(document as FullscreenDocument) === imagePreviewRef.current)
}
document.addEventListener("fullscreenchange", handleFullscreenChange)
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange)
}
}, [])
React.useEffect(() => {
const fullscreenDocument = document as FullscreenDocument
if (!show && getFullscreenElement(fullscreenDocument) === imagePreviewRef.current) {
void exitFullscreen(fullscreenDocument).catch(() => {})
}
if (!show) {
setIsImageFullscreen(false)
}
}, [show])
const centerImageViewport = React.useCallback(() => {
const viewport = imageViewportRef.current
if (!viewport) return
const left = Math.max((viewport.scrollWidth - viewport.clientWidth) / 2, 0)
const top = Math.max((viewport.scrollHeight - viewport.clientHeight) / 2, 0)
viewport.scrollTo({ left, top })
}, [])
const updateImageFitScale = React.useCallback(() => {
if (!imageNaturalSize) return
const viewport = imageViewportRef.current
if (!viewport) return
const widthScale = viewport.clientWidth / imageNaturalSize.width
setImageFitScale(Math.min(widthScale, 1))
setImageLayoutReady(true)
}, [imageNaturalSize])
const toggleImageFullscreen = React.useCallback(() => {
const fullscreenDocument = document as FullscreenDocument
const container = imagePreviewRef.current as FullscreenElement | null
if (!container) return
if (getFullscreenElement(fullscreenDocument) === container) {
void exitFullscreen(fullscreenDocument).catch(() => {})
return
}
if (getFullscreenElement(fullscreenDocument)) {
void exitFullscreen(fullscreenDocument).catch(() => {})
return
}
void requestFullscreen(container).catch(() => {})
}, [])
React.useLayoutEffect(() => {
if (!show || !isImageMode || !imageNaturalSize) return
updateImageFitScale()
}, [show, isImageMode, isImageFullscreen, imageNaturalSize, updateImageFitScale])
React.useLayoutEffect(() => {
if (!show || !isImageMode || !imageNaturalSize) return
centerImageViewport()
}, [show, isImageMode, isImageFullscreen, imageNaturalSize, imageFitScale, centerImageViewport])
React.useEffect(() => {
if (!show || !isImageMode || !imageNaturalSize) return
window.addEventListener("resize", updateImageFitScale)
return () => {
window.removeEventListener("resize", updateImageFitScale)
}
}, [show, isImageMode, imageNaturalSize, updateImageFitScale])
const renderPreview = () => {
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
</div>
)
}
switch (previewMode) {
case "text":
return (
<div className="relative flex-1 overflow-auto">
<pre className="whitespace-pre-wrap break-words pe-16">{getFormattedContent()}</pre>
<div className="absolute end-0 top-0">
{isJson && (
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setIsFormatted(!isFormatted)}>
{isFormatted ? t("Raw") : t("Formatted")}
</Button>
</div>
)}
</div>
</div>
)
case "image":
return (
<div
ref={imagePreviewRef}
className={`relative flex overflow-hidden ${isImageFullscreen ? "h-screen w-screen bg-black" : "flex-1 min-h-0 bg-muted/20"}`}
>
<div className="absolute end-2 top-2 z-10 flex items-center gap-1 border bg-background/90 p-1 backdrop-blur-xs">
<Button
variant="outline"
size="icon-xs"
type="button"
onClick={toggleImageFullscreen}
aria-label={isImageFullscreen ? t("Exit Fullscreen") : t("Fullscreen")}
title={isImageFullscreen ? t("Exit Fullscreen") : t("Fullscreen")}
>
{isImageFullscreen ? (
<RiFullscreenExitLine className="size-3" aria-hidden />
) : (
<RiFullscreenLine className="size-3" aria-hidden />
)}
</Button>
</div>
<div ref={imageViewportRef} className="h-full w-full overflow-auto p-2">
<div className="flex min-h-full min-w-full items-center justify-center">
<div
className={cn(
"relative flex shrink-0 items-center justify-center transition-opacity",
imageLayoutReady ? "opacity-100" : "opacity-0",
)}
style={
imageNaturalSize
? {
width: `${imageNaturalSize.width * imageFitScale}px`,
height: `${imageNaturalSize.height * imageFitScale}px`,
}
: { width: "100%", height: "100%" }
}
>
<Image
src={previewUrl}
alt={objectKey || t("Preview")}
fill
sizes="100vw"
unoptimized
referrerPolicy="no-referrer"
className="cursor-zoom-in object-contain"
onLoad={(event) => {
const nextSize = {
width: event.currentTarget.naturalWidth || event.currentTarget.clientWidth,
height: event.currentTarget.naturalHeight || event.currentTarget.clientHeight,
}
if (previewUrl) {
imageSizeCacheRef.current[previewUrl] = nextSize
}
setImageNaturalSize((prev) => {
if (prev && prev.width === nextSize.width && prev.height === nextSize.height) {
return prev
}
return nextSize
})
}}
onClick={() => {
if (!isImageFullscreen) {
toggleImageFullscreen()
}
}}
/>
</div>
</div>
</div>
</div>
)
case "sandbox":
return (
<iframe src={previewUrl} className="h-[70vh] w-full" frameBorder={0} title="Sandbox preview" sandbox="" />
)
case "pdf":
return <PdfViewer url={previewUrl} />
case "parquet":
return <ParquetViewer url={previewUrl} sizeBytes={objectSize} />
case "tiff":
return <TiffViewer url={previewUrl} objectKey={objectKey} />
case "download":
default:
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-sm text-muted-foreground">
{t("Cannot Preview", {
contentType: contentType || "unknown",
})}
</div>
)
}
}
return (
<Dialog open={show} onOpenChange={onShowChange}>
<DialogContent
className={cn("z-[1000] max-h-[85vh] sm:max-w-4xl", isSelfScrollMode ? "overflow-hidden" : "overflow-auto")}
>
<DialogHeader>
<DialogTitle className="flex items-start justify-between me-6">{t("Preview")}</DialogTitle>
</DialogHeader>
<div
className={cn(
"min-h-[300px] min-w-0 border p-4",
isSelfScrollMode ? "flex max-h-[70vh] flex-col overflow-hidden" : "flex flex-col",
)}
>
{renderPreview()}
</div>
</DialogContent>
</Dialog>
)
}