mirror of
https://github.com/rustfs/console.git
synced 2026-09-01 15:17:45 +08:00
Feat/preserve url path on refresh (#54)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Page } from "@/components/page"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
@@ -17,11 +17,14 @@ import { buildBucketPath } from "@/lib/bucket-path"
|
||||
interface BrowserContentProps {
|
||||
bucketName: string
|
||||
keyPath?: string
|
||||
preview?: boolean
|
||||
previewKey?: string
|
||||
}
|
||||
|
||||
export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps) {
|
||||
export function BrowserContent({ bucketName, keyPath = "", preview = false, previewKey = "" }: BrowserContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const message = useMessage()
|
||||
const { headBucket } = useBucket()
|
||||
|
||||
@@ -30,18 +33,29 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps
|
||||
|
||||
const [infoOpen, setInfoOpen] = React.useState(false)
|
||||
const [infoKey, setInfoKey] = React.useState<string | null>(null)
|
||||
const [autoPreview, setAutoPreview] = React.useState(false)
|
||||
const [uploadPickerOpen, setUploadPickerOpen] = React.useState(false)
|
||||
const [refreshTrigger, setRefreshTrigger] = React.useState(0)
|
||||
|
||||
// Handle initial preview params - set infoOpen and trigger auto-preview
|
||||
React.useEffect(() => {
|
||||
if (preview && previewKey) {
|
||||
setInfoKey(previewKey)
|
||||
setInfoOpen(true)
|
||||
setAutoPreview(true)
|
||||
}
|
||||
}, [preview, previewKey])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!bucketName) return
|
||||
headBucket(bucketName)
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
message.error(t("Bucket not found"))
|
||||
router.push("/browser")
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
router.push(`/browser?${params.toString()}`)
|
||||
})
|
||||
}, [bucketName, headBucket, message, router, t])
|
||||
}, [bucketName, headBucket, message, router, t, searchParams])
|
||||
|
||||
const bucketPath = React.useCallback((path?: string | string[]) => buildBucketPath(bucketName, path), [bucketName])
|
||||
|
||||
@@ -54,6 +68,34 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps
|
||||
setInfoOpen(true)
|
||||
}
|
||||
|
||||
const updatePreviewParams = (showPreview: boolean, key?: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (showPreview && key) {
|
||||
params.set("preview", "true")
|
||||
params.set("previewKey", key)
|
||||
} else {
|
||||
params.delete("preview")
|
||||
params.delete("previewKey")
|
||||
}
|
||||
router.replace(`/browser?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleInfoOpenChange = (open: boolean) => {
|
||||
setInfoOpen(open)
|
||||
if (!open) {
|
||||
setAutoPreview(false)
|
||||
updatePreviewParams(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewChange = (showPreview: boolean) => {
|
||||
if (showPreview && infoKey) {
|
||||
updatePreviewParams(true, infoKey)
|
||||
} else {
|
||||
updatePreviewParams(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshTrigger((n) => n + 1)
|
||||
}
|
||||
@@ -99,8 +141,10 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps
|
||||
bucketName={bucketName}
|
||||
objectKey={infoKey}
|
||||
open={infoOpen}
|
||||
onOpenChange={setInfoOpen}
|
||||
onOpenChange={handleInfoOpenChange}
|
||||
onRefresh={handleRefresh}
|
||||
autoPreview={autoPreview}
|
||||
onPreviewChange={handlePreviewChange}
|
||||
/>
|
||||
|
||||
<ObjectUploadPicker
|
||||
|
||||
@@ -118,7 +118,6 @@ function BrowserBucketsPage() {
|
||||
fetchId,
|
||||
buckets.map((bucket) => bucket.Name),
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
console.error("Failed to fetch buckets:", error)
|
||||
@@ -296,9 +295,11 @@ export default function BrowserPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const bucketName = searchParams.get("bucket") ?? ""
|
||||
const keyPath = searchParams.get("key") ?? ""
|
||||
const preview = searchParams.get("preview") === "true"
|
||||
const previewKey = searchParams.get("previewKey") ?? ""
|
||||
|
||||
if (bucketName) {
|
||||
return <BrowserContent bucketName={bucketName} keyPath={keyPath} />
|
||||
return <BrowserContent bucketName={bucketName} keyPath={keyPath} preview={preview} previewKey={previewKey} />
|
||||
}
|
||||
|
||||
return <BrowserBucketsPage />
|
||||
|
||||
@@ -32,49 +32,49 @@ export default function BucketSettingsPage() {
|
||||
</PageHeader>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full space-y-6">
|
||||
<TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="overview" className="px-4">
|
||||
{t("Overview")}
|
||||
</TabsTrigger>
|
||||
{canViewLifecycle && (
|
||||
<TabsTrigger value="lifecycle" className="px-4">
|
||||
{t("Lifecycle")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewReplication && (
|
||||
<TabsTrigger value="replication" className="px-4">
|
||||
{t("Replication")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewEvents && (
|
||||
<TabsTrigger value="events" className="px-4">
|
||||
{t("Events")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketInfo bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="overview" className="px-4">
|
||||
{t("Overview")}
|
||||
</TabsTrigger>
|
||||
{canViewLifecycle && (
|
||||
<TabsContent value="lifecycle" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketLifecycleTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
<TabsTrigger value="lifecycle" className="px-4">
|
||||
{t("Lifecycle")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{canViewReplication && (
|
||||
<TabsContent value="replication" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketReplicationTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
<TabsTrigger value="replication" className="px-4">
|
||||
{t("Replication")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{canViewEvents && (
|
||||
<TabsContent value="events" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketEventsTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
<TabsTrigger value="events" className="px-4">
|
||||
{t("Events")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</Tabs>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketInfo bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
|
||||
{canViewLifecycle && (
|
||||
<TabsContent value="lifecycle" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketLifecycleTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewReplication && (
|
||||
<TabsContent value="replication" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketReplicationTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewEvents && (
|
||||
<TabsContent value="events" className="space-y-4 outline-none">
|
||||
{bucketName ? <BucketEventsTab bucketName={bucketName} /> : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,9 +35,19 @@ interface ObjectInfoProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRefresh?: () => void
|
||||
autoPreview?: boolean
|
||||
onPreviewChange?: (showPreview: boolean) => void
|
||||
}
|
||||
|
||||
export function ObjectInfo({ bucketName, objectKey, open, onOpenChange }: ObjectInfoProps) {
|
||||
export function ObjectInfo({
|
||||
bucketName,
|
||||
objectKey,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRefresh,
|
||||
autoPreview = false,
|
||||
onPreviewChange,
|
||||
}: ObjectInfoProps) {
|
||||
const { t } = useTranslation()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -219,10 +229,25 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange }: Object
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-open preview once when autoPreview is true and object data is first loaded
|
||||
const hasAutoPreviewedRef = React.useRef(false)
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
hasAutoPreviewedRef.current = false
|
||||
return
|
||||
}
|
||||
if (autoPreview && object && !hasAutoPreviewedRef.current) {
|
||||
hasAutoPreviewedRef.current = true
|
||||
setShowPreview(true)
|
||||
}
|
||||
}, [autoPreview, object, open])
|
||||
|
||||
// Open preview dialog
|
||||
const openPreview = () => {
|
||||
if (!object) return
|
||||
setPreviewObject(object)
|
||||
setShowPreview(true)
|
||||
onPreviewChange?.(true)
|
||||
}
|
||||
|
||||
const handlePreviewVersion = async (versionId: string) => {
|
||||
@@ -648,7 +673,14 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange }: Object
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ObjectPreviewModal show={showPreview} onShowChange={setShowPreview} object={previewObject ?? object} />
|
||||
<ObjectPreviewModal
|
||||
show={showPreview}
|
||||
onShowChange={(show) => {
|
||||
setShowPreview(show)
|
||||
onPreviewChange?.(show)
|
||||
}}
|
||||
object={previewObject ?? object}
|
||||
/>
|
||||
<ObjectVersions
|
||||
bucketName={bucketName}
|
||||
objectKey={(object?.Key as string) ?? ""}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RiFileLine,
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
RiEyeLine,
|
||||
} from "@remixicon/react"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -263,10 +264,20 @@ export function ObjectList({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.type === "object" ? (
|
||||
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/browser?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(prefix)}&preview=true&previewKey=${encodeURIComponent(row.original.Key)}`}
|
||||
>
|
||||
<RiEyeLine className="size-4" />
|
||||
<span>{t("Preview")}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
|
||||
<RiDownloadCloud2Line className="size-4" />
|
||||
<span>{t("Download")}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
|
||||
<RiDeleteBin5Line className="size-4" />
|
||||
@@ -276,7 +287,7 @@ export function ObjectList({
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, bucket, bucketPath, onOpenInfo, displayKey, downloadFile],
|
||||
[t, displayKey, bucketPath, onOpenInfo, bucket, prefix, downloadFile],
|
||||
)
|
||||
|
||||
const { table, selectedRowIds } = useDataTable<ObjectRow>({
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
|
||||
const TEXT_MIMES = [
|
||||
"application/json",
|
||||
@@ -33,6 +34,7 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
|
||||
const { t } = useTranslation()
|
||||
const [textContent, setTextContent] = React.useState("")
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [isFormatted, setIsFormatted] = React.useState(true)
|
||||
|
||||
const contentType = object?.ContentType ?? ""
|
||||
const previewUrl = object?.SignedUrl ?? ""
|
||||
@@ -43,14 +45,26 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
|
||||
const isVideo = contentType.startsWith("video/")
|
||||
const isAudio = contentType.startsWith("audio/")
|
||||
const isPdf = contentType === "application/pdf" || objectKey.toLowerCase().endsWith(".pdf")
|
||||
const isJson = contentType === "application/json" || objectKey.toLowerCase().endsWith(".json")
|
||||
const isText =
|
||||
objectSize <= ALLOWED_SIZE &&
|
||||
(TEXT_MIMES.some((m) => contentType.startsWith(m)) ||
|
||||
TEXT_EXTENSIONS.some((ext) => objectKey.toLowerCase().endsWith(ext)))
|
||||
|
||||
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 && isText && previewUrl) {
|
||||
setLoading(true)
|
||||
setIsFormatted(true)
|
||||
fetch(previewUrl)
|
||||
.then((r) => r.text())
|
||||
.then(setTextContent)
|
||||
@@ -66,7 +80,7 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
|
||||
<Dialog open={show} onOpenChange={onShowChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-auto z-[1000]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Preview")}</DialogTitle>
|
||||
<DialogTitle className="flex items-start justify-between mr-6">{t("Preview")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="min-h-[300px] rounded-md border p-4 flex flex-col">
|
||||
{loading ? (
|
||||
@@ -81,7 +95,18 @@ export function ObjectPreviewModal({ show, onShowChange, object }: ObjectPreview
|
||||
)}
|
||||
{isPdf && <iframe src={previewUrl} className="h-[70vh] w-full" frameBorder={0} title="PDF preview" />}
|
||||
{isText && (
|
||||
<pre className="max-h-[70vh] overflow-auto whitespace-pre-wrap break-words">{textContent}</pre>
|
||||
<pre className="max-h-[70vh] relative overflow-auto whitespace-pre-wrap break-words">
|
||||
{getFormattedContent()}
|
||||
<div className="absolute right-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>
|
||||
</pre>
|
||||
)}
|
||||
{isVideo && (
|
||||
<video controls className="w-full">
|
||||
|
||||
Reference in New Issue
Block a user