Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions app/(dashboard)/browser/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { ObjectPreviewModal } from "@/components/object/preview-modal"
import { useObject } from "@/hooks/use-object"
import { usePermissions } from "@/hooks/use-permissions"
import { isMissingBucketError } from "@/lib/bucket-access"
import { getAttachmentContentDisposition } from "@/lib/content-disposition"
import { downloadUrl } from "@/lib/export-file"
import { createObjectPreviewInfo } from "@/lib/object-preview"

interface BrowserContentProps {
bucketName: string
Expand All @@ -44,6 +47,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
const [refreshTrigger, setRefreshTrigger] = React.useState(0)
const [showPreview, setShowPreview] = React.useState(false)
const [previewObject, setPreviewObject] = React.useState<Record<string, unknown> | null>(null)
const [previewBucketName, setPreviewBucketName] = React.useState("")
const objectApi = useObject(bucketName)
const canUploadObjects = canCapability("objects.upload", { bucket: bucketName, prefix })

Expand Down Expand Up @@ -88,12 +92,39 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
async ({ key, data }: { key?: string; data?: Record<string, unknown> }) => {
if (!key) return
const info = data ?? (await objectApi.getObjectInfo(key))
setPreviewObject(info as Record<string, unknown>)
setPreviewObject(createObjectPreviewInfo(key, info))
setPreviewBucketName(bucketName)
setShowPreview(true)
},
[objectApi],
[objectApi, bucketName],
)

const previewObjectKey = typeof previewObject?.Key === "string" ? previewObject.Key : ""
const previewVersionId =
typeof previewObject?.SelectedVersionId === "string" ? previewObject.SelectedVersionId : undefined
const canDownloadPreview =
previewBucketName === bucketName &&
canCapability(previewVersionId ? "objects.version.download" : "objects.download", {
bucket: bucketName,
objectKey: previewObjectKey,
})
const downloadPreview = async () => {
if (!canDownloadPreview || !previewObjectKey) return
try {
const filename = previewObjectKey.split("/").pop() ?? ""
const url = await objectApi.getSignedUrl(
previewObjectKey,
3600,
getAttachmentContentDisposition(filename),
previewVersionId,
)
downloadUrl(url, filename)
message.success(t("Download ready"))
} catch (error) {
message.error((error as Error)?.message ?? t("Download Failed"))
}
}

const clearPreviewQueryParams = React.useCallback(() => {
if (!searchParams.has("preview") && !searchParams.has("previewKey")) return

Expand Down Expand Up @@ -206,7 +237,12 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
canUpload={canUploadObjects}
/>

<ObjectPreviewModal show={showPreview} onShowChange={handlePreviewModalChange} object={previewObject} />
<ObjectPreviewModal
show={showPreview}
onShowChange={handlePreviewModalChange}
onDownload={canDownloadPreview ? downloadPreview : undefined}
object={previewObject}
/>
</Page>
)
}
3 changes: 2 additions & 1 deletion components/object/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useMessage } from "@/lib/feedback/message"
import { useDialog } from "@/lib/feedback/dialog"
import { downloadUrl } from "@/lib/export-file"
import { getAttachmentContentDisposition } from "@/lib/content-disposition"
import { createObjectPreviewInfo } from "@/lib/object-preview"
import { normalizeDateToIso } from "@/lib/safe-date"
import { formatDateTimeWithOffset } from "@/lib/functions"
import {
Expand Down Expand Up @@ -306,7 +307,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
{ expiresIn: 3600 },
),
])
const data = { ...head, SignedUrl: signed }
const data = createObjectPreviewInfo(key, { ...head, SignedUrl: signed }, version)
setShowVersions(false)
onPreview({ key, data })
} catch (err) {
Expand Down
96 changes: 42 additions & 54 deletions components/object/preview-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,24 @@ import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from "@remixicon/
import { PdfViewer } from "@/components/object/pdf-viewer"
import { ParquetViewer } from "@/components/object/parquet-viewer"
import { TiffViewer } from "@/components/object/tiff-viewer"
import { getObjectPreviewMode, normalizePreviewContentType } from "@/lib/object-preview"
import {
getObjectPreviewMode,
isSafeTextPreview,
MAX_TEXT_PREVIEW_BYTES,
normalizePreviewContentType,
} from "@/lib/object-preview"
import { useObjectPreviewText } from "@/hooks/use-object-preview-text"
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

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
onDownload?: () => Promise<void>
object: {
ContentType?: string
ContentLength?: number
Expand All @@ -51,13 +45,6 @@ type FullscreenElement = HTMLElement & {
webkitRequestFullscreen?: () => Promise<void> | void
}

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")
Expand Down Expand Up @@ -109,10 +96,8 @@ function requestFullscreen(element: FullscreenElement): Promise<void> {
return Promise.resolve()
}

export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreviewModalProps) {
export function ObjectPreviewModal({ show, onShowChange, onDownload, object }: ObjectPreviewModalProps) {
const { t } = useTranslation()
const [textContent, setTextContent] = React.useState("")
const [loading, setLoading] = React.useState(false)
const [audioLoadError, setAudioLoadError] = React.useState(false)
const [videoLoadError, setVideoLoadError] = React.useState(false)
const [isFormatted, setIsFormatted] = React.useState(true)
Expand All @@ -139,17 +124,32 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
const canRenderPdf = hasPreviewUrl && isPdfPreview(normalizedContentType)
const canRenderParquet = hasPreviewUrl && isParquetPreview(normalizedContentType, objectKey)
const canRenderTiff = hasPreviewUrl && isTiffPreview(objectKey)
const previewMode = getObjectPreviewMode({
const initialPreviewMode = getObjectPreviewMode({
hasPreviewUrl,
contentType,
objectSize,
canRenderText,
canRenderImage,
canRenderPdf,
canRenderParquet,
canRenderTiff,
})
const { status: textStatus, content: textContent } = useObjectPreviewText({
show,
mode: initialPreviewMode,
url: previewUrl,
})
const previewMode =
textStatus === "error"
? "error"
: textStatus === "unsupported"
? "download"
: initialPreviewMode === "detect-text" && textStatus === "ready"
? "text"
: initialPreviewMode
const loading = textStatus === "loading"
const isImageMode = previewMode === "image"
const isCompactPreview = previewMode === "audio" || previewMode === "download"
const isCompactPreview = previewMode === "audio" || previewMode === "download" || previewMode === "error"

const getFormattedContent = () => {
if (!isJson || !isFormatted) return textContent
Expand All @@ -162,30 +162,8 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
}

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])
setIsFormatted(true)
}, [show, previewUrl])

React.useEffect(() => {
setAudioLoadError(false)
Expand Down Expand Up @@ -295,7 +273,7 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
if (loading) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
<Spinner className="size-8 text-muted-foreground" aria-label={t("Loading…")} />
</div>
)
}
Expand Down Expand Up @@ -440,12 +418,22 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
case "tiff":
return <TiffViewer url={previewUrl} objectKey={objectKey} />
case "download":
case "error":
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",
})}
<p className="text-center" role={previewMode === "error" ? "alert" : undefined}>
{previewMode === "error"
? t("Preview unavailable")
: objectSize > MAX_TEXT_PREVIEW_BYTES
? t("File is too large to preview, please download to view")
: t("Cannot Preview", { contentType: contentType || "unknown" })}
</p>
{onDownload && (
<Button variant="outline" onClick={onDownload}>
{t("Download")}
</Button>
)}
</div>
)
}
Expand Down
47 changes: 47 additions & 0 deletions hooks/use-object-preview-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client"

import { useEffect, useState } from "react"
import { readObjectPreviewText, type ObjectPreviewMode } from "@/lib/object-preview"

interface PreviewTextOptions {
show: boolean
mode: ObjectPreviewMode
url: string
}

interface PreviewTextResult {
url: string
mode: ObjectPreviewMode
status: "ready" | "unsupported" | "error"
content: string
}

export function useObjectPreviewText({ show, mode, url }: PreviewTextOptions) {
const [result, setResult] = useState<PreviewTextResult | null>(null)
const shouldLoad = show && Boolean(url) && (mode === "text" || mode === "detect-text")

useEffect(() => {
setResult(null)
if (!shouldLoad) return

const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return mode === "detect-text" ? readObjectPreviewText(response) : response.text()
})
.then((content) => {
if (controller.signal.aborted) return
setResult({ url, mode, status: content === null ? "unsupported" : "ready", content: content ?? "" })
})
.catch(() => {
if (!controller.signal.aborted) setResult({ url, mode, status: "error", content: "" })
})

return () => controller.abort()
}, [shouldLoad, mode, url])

if (!shouldLoad) return { status: "idle" as const, content: "" }
if (result?.url !== url || result.mode !== mode) return { status: "loading" as const, content: "" }
return { status: result.status, content: result.content }
}
3 changes: 2 additions & 1 deletion hooks/use-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ export function useObject(bucket: string) {
)

const getSignedUrlFn = useCallback(
async (key: string, expiresIn = 3600, responseContentDisposition?: string) => {
async (key: string, expiresIn = 3600, responseContentDisposition?: string, versionId?: string) => {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentDisposition: responseContentDisposition,
VersionId: versionId === "00000000-0000-0000-0000-000000000000" ? undefined : versionId,
})
return getSignedUrl(client, command, { expiresIn })
},
Expand Down
Loading