mirror of
https://github.com/rustfs/console.git
synced 2026-10-31 03:52:11 +08:00
845 lines
34 KiB
TypeScript
845 lines
34 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
import { RiDownloadLine, RiEyeLine, RiPriceTag3Line, RiFileList2Line, RiLockLine, RiCloseLine } from "@remixicon/react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Item, ItemContent } from "@/components/ui/item"
|
|
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { CopyInput } from "@/components/copy-input"
|
|
import { DateTimePicker } from "@/components/datetime-picker"
|
|
import { useObject } from "@/hooks/use-object"
|
|
import { usePermissions } from "@/hooks/use-permissions"
|
|
import { useMessage } from "@/lib/feedback/message"
|
|
import { useDialog } from "@/lib/feedback/dialog"
|
|
import { exportFile } from "@/lib/export-file"
|
|
import { getContentType } from "@/lib/mime-types"
|
|
import { normalizeDateToIso } from "@/lib/safe-date"
|
|
import {
|
|
getDefaultObjectRetentionDate,
|
|
getMinimumObjectRetentionDate,
|
|
isObjectLegalHoldEnabled,
|
|
isObjectRetentionDateInFuture,
|
|
shouldShowObjectRetentionAction,
|
|
toObjectRetentionRequestValue,
|
|
} from "@/lib/object-lock.js"
|
|
import { ObjectVersions } from "@/components/object/versions"
|
|
import { GetObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
|
import { useS3 } from "@/contexts/s3-context"
|
|
import { CopyButton } from "@/components/copy-button"
|
|
|
|
const ONE_WEEK_SECONDS = 7 * 24 * 60 * 60
|
|
|
|
interface ObjectInfoProps {
|
|
bucketName: string
|
|
objectKey: string | null
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onRefresh?: () => void
|
|
onPreview: (params: { key?: string; data?: Record<string, unknown> }) => void
|
|
}
|
|
|
|
export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPreview }: ObjectInfoProps) {
|
|
const { t } = useTranslation()
|
|
const message = useMessage()
|
|
const dialog = useDialog()
|
|
const objectApi = useObject(bucketName)
|
|
const { canCapability } = usePermissions()
|
|
const client = useS3()
|
|
|
|
const [object, setObject] = React.useState<Record<string, unknown> | null>(null)
|
|
const [tags, setTags] = React.useState<Array<{ Key: string; Value: string }>>([])
|
|
const [lockStatus, setLockStatus] = React.useState(false)
|
|
const [retention, setRetention] = React.useState("")
|
|
const [retainUntilDate, setRetainUntilDate] = React.useState("")
|
|
const [retentionMode, setRetentionMode] = React.useState<"COMPLIANCE" | "GOVERNANCE">("GOVERNANCE")
|
|
const [minRetentionDate, setMinRetentionDate] = React.useState(() => getMinimumObjectRetentionDate())
|
|
const [signedUrl, setSignedUrl] = React.useState("")
|
|
const [showTagView, setShowTagView] = React.useState(false)
|
|
const [showRetentionView, setShowRetentionView] = React.useState(false)
|
|
const [showVersions, setShowVersions] = React.useState(false)
|
|
const [tagFormValue, setTagFormValue] = React.useState({
|
|
Key: "",
|
|
Value: "",
|
|
})
|
|
const [expirationDays, setExpirationDays] = React.useState(0)
|
|
const [expirationHours, setExpirationHours] = React.useState(2)
|
|
const [expirationMinutes, setExpirationMinutes] = React.useState(0)
|
|
const [expirationError, setExpirationError] = React.useState("")
|
|
const [totalExpirationSeconds, setTotalExpirationSeconds] = React.useState(0)
|
|
const [isExpirationValid, setIsExpirationValid] = React.useState(false)
|
|
const [isGeneratingUrl, setIsGeneratingUrl] = React.useState(false)
|
|
const [isUpdatingRetention, setIsUpdatingRetention] = React.useState(false)
|
|
const retentionDialogContentRef = React.useRef<HTMLDivElement>(null)
|
|
const resolvedObjectKey = React.useMemo(() => String(object?.Key ?? objectKey ?? ""), [object?.Key, objectKey])
|
|
const objectPermissionContext = React.useMemo(
|
|
() => ({
|
|
bucket: bucketName,
|
|
objectKey: resolvedObjectKey || undefined,
|
|
}),
|
|
[bucketName, resolvedObjectKey],
|
|
)
|
|
const canDownloadObject = canCapability("objects.download", objectPermissionContext)
|
|
const canPreviewObject = canCapability("objects.preview", objectPermissionContext)
|
|
const canViewObjectTags = canCapability("objects.tag.view", objectPermissionContext)
|
|
const canEditObjectTags = canCapability("objects.tag.edit", objectPermissionContext)
|
|
const canViewVersions = canCapability("objects.version.view", objectPermissionContext)
|
|
const canEditLegalHold = canCapability("objects.legalHold.edit", objectPermissionContext)
|
|
const canEditRetention = canCapability("objects.retention.edit", objectPermissionContext)
|
|
const canShareObject = canCapability("objects.share", objectPermissionContext)
|
|
const showRetentionAction = shouldShowObjectRetentionAction({
|
|
canEditRetention,
|
|
legalHoldEnabled: lockStatus,
|
|
})
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
if (seconds === 0) return ""
|
|
const days = Math.floor(seconds / (24 * 60 * 60))
|
|
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60))
|
|
const minutes = Math.floor((seconds % (60 * 60)) / 60)
|
|
const parts: string[] = []
|
|
if (days > 0) parts.push(`${days} ${t("Days")}`)
|
|
if (hours > 0) parts.push(`${hours} ${t("Hours")}`)
|
|
if (minutes > 0) parts.push(`${minutes} ${t("Minutes")}`)
|
|
return parts.join(" ")
|
|
}
|
|
|
|
const validateExpiration = React.useCallback((): boolean => {
|
|
setExpirationError("")
|
|
const days = Number(expirationDays) || 0
|
|
const hours = Number(expirationHours) || 0
|
|
const minutes = Number(expirationMinutes) || 0
|
|
|
|
if (isNaN(days) || isNaN(hours) || isNaN(minutes)) return false
|
|
if (minutes < 0 || minutes > 59) {
|
|
setExpirationError(t("Minutes must be between 0 and 59"))
|
|
return false
|
|
}
|
|
if (hours < 0) {
|
|
setExpirationError(t("Hours must be between 0 and 23"))
|
|
return false
|
|
}
|
|
if (days > 0 && hours > 23) {
|
|
setExpirationError(t("Hours must be between 0 and 23"))
|
|
return false
|
|
}
|
|
if (days === 0 && hours > 24) {
|
|
setExpirationError(t("Hours must be between 0 and 24 when days is 0"))
|
|
return false
|
|
}
|
|
if (days < 0 || days > 7) {
|
|
setExpirationError(t("Days must be between 0 and 7"))
|
|
return false
|
|
}
|
|
|
|
let finalDays = days
|
|
let finalHours = hours
|
|
if (hours === 24 && days === 0) {
|
|
finalDays = 1
|
|
finalHours = 0
|
|
}
|
|
const totalSeconds = finalDays * 24 * 60 * 60 + finalHours * 60 * 60 + minutes * 60
|
|
if (totalSeconds > ONE_WEEK_SECONDS) {
|
|
setExpirationError(t("Total duration cannot exceed 7 days"))
|
|
return false
|
|
}
|
|
if (totalSeconds === 0) {
|
|
setTotalExpirationSeconds(0)
|
|
return false
|
|
}
|
|
|
|
setTotalExpirationSeconds(totalSeconds)
|
|
return true
|
|
}, [expirationDays, expirationHours, expirationMinutes, t])
|
|
|
|
React.useEffect(() => {
|
|
setIsExpirationValid(validateExpiration())
|
|
}, [expirationDays, expirationHours, expirationMinutes, validateExpiration])
|
|
|
|
const loadObjectInfo = React.useCallback(
|
|
async (key: string) => {
|
|
const info = await objectApi.getObjectInfo(key)
|
|
setObject(info as Record<string, unknown>)
|
|
setLockStatus(isObjectLegalHoldEnabled(info))
|
|
setExpirationDays(0)
|
|
setExpirationHours(2)
|
|
setExpirationMinutes(0)
|
|
setSignedUrl("")
|
|
setExpirationError("")
|
|
setTotalExpirationSeconds(2 * 60 * 60)
|
|
setIsExpirationValid(true)
|
|
setIsGeneratingUrl(false)
|
|
},
|
|
[objectApi],
|
|
)
|
|
|
|
const fetchTags = React.useCallback(
|
|
async (key: string) => {
|
|
try {
|
|
const response = await objectApi.getObjectTags(key)
|
|
setTags(response)
|
|
} catch {
|
|
setTags([])
|
|
}
|
|
},
|
|
[objectApi],
|
|
)
|
|
|
|
const fetchRetention = React.useCallback(
|
|
async (key: string) => {
|
|
try {
|
|
const response = await objectApi.getObjectRetention(key)
|
|
setRetention(response.Mode ?? "")
|
|
setRetainUntilDate(response.RetainUntilDate ?? "")
|
|
setRetentionMode(response.Mode === "COMPLIANCE" ? "COMPLIANCE" : "GOVERNANCE")
|
|
} catch {
|
|
setRetention("")
|
|
setRetainUntilDate("")
|
|
}
|
|
},
|
|
[objectApi],
|
|
)
|
|
|
|
const fetchLegalHold = React.useCallback(
|
|
async (key: string) => {
|
|
try {
|
|
const response = await objectApi.getObjectLegalHold(key)
|
|
if (response.Status) {
|
|
setLockStatus(isObjectLegalHoldEnabled(response))
|
|
}
|
|
} catch {
|
|
// Keep the HeadObject fallback state when a server does not support the dedicated legal hold API.
|
|
}
|
|
},
|
|
[objectApi],
|
|
)
|
|
|
|
const loadObjectInfoRef = React.useRef(loadObjectInfo)
|
|
const fetchTagsRef = React.useRef(fetchTags)
|
|
const fetchRetentionRef = React.useRef(fetchRetention)
|
|
const fetchLegalHoldRef = React.useRef(fetchLegalHold)
|
|
|
|
React.useEffect(() => {
|
|
loadObjectInfoRef.current = loadObjectInfo
|
|
fetchTagsRef.current = fetchTags
|
|
fetchRetentionRef.current = fetchRetention
|
|
fetchLegalHoldRef.current = fetchLegalHold
|
|
}, [loadObjectInfo, fetchTags, fetchRetention, fetchLegalHold])
|
|
|
|
React.useEffect(() => {
|
|
if (open && objectKey) {
|
|
const key = objectKey
|
|
loadObjectInfoRef
|
|
.current(key)
|
|
.then(() =>
|
|
Promise.all([fetchTagsRef.current(key), fetchRetentionRef.current(key), fetchLegalHoldRef.current(key)]),
|
|
)
|
|
.catch(() => {
|
|
message.error(t("Failed to fetch object info"))
|
|
setObject(null)
|
|
})
|
|
} else {
|
|
setObject(null)
|
|
}
|
|
}, [open, objectKey, message, t])
|
|
|
|
const download = async () => {
|
|
if (!canDownloadObject || !object?.Key) return
|
|
try {
|
|
const url = await objectApi.getSignedUrl(object.Key as string)
|
|
const response = await fetch(url)
|
|
const filename = (object.Key as string).split("/").pop() ?? ""
|
|
const headers: Record<string, string> = {
|
|
"content-type": getContentType(response.headers, filename),
|
|
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
|
|
}
|
|
const blob = await response.blob()
|
|
exportFile({ headers, data: blob }, filename)
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Download Failed"))
|
|
}
|
|
}
|
|
|
|
const handlePreviewVersion = async (versionId: string) => {
|
|
if (!resolvedObjectKey) return
|
|
try {
|
|
const key = resolvedObjectKey
|
|
const version = versionId === "00000000-0000-0000-0000-000000000000" ? undefined : versionId
|
|
const [head, signed] = await Promise.all([
|
|
client.send(
|
|
new HeadObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: key,
|
|
VersionId: version,
|
|
}),
|
|
),
|
|
getSignedUrl(
|
|
client,
|
|
new GetObjectCommand({
|
|
Bucket: bucketName,
|
|
Key: key,
|
|
VersionId: version,
|
|
}),
|
|
{ expiresIn: 3600 },
|
|
),
|
|
])
|
|
const data = { ...head, SignedUrl: signed }
|
|
setShowVersions(false)
|
|
onPreview({ key, data })
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Failed to fetch versions"))
|
|
}
|
|
}
|
|
const toggleLegalHold = async () => {
|
|
if (!canEditLegalHold || !object?.Key) return
|
|
try {
|
|
await objectApi.setLegalHold(object.Key as string, !lockStatus)
|
|
setLockStatus(!lockStatus)
|
|
message.success(t("Update Success"))
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Update Failed"))
|
|
}
|
|
}
|
|
|
|
const generateTemporaryUrl = async () => {
|
|
if (!canShareObject || !object?.Key || isGeneratingUrl) return
|
|
if (!validateExpiration()) {
|
|
message.error(expirationError || t("Please enter a valid expiration time"))
|
|
return
|
|
}
|
|
setIsGeneratingUrl(true)
|
|
try {
|
|
const url = await objectApi.getSignedUrl(object.Key as string, totalExpirationSeconds)
|
|
setSignedUrl(url)
|
|
message.success(t("URL generated successfully"))
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Failed to generate URL"))
|
|
} finally {
|
|
setIsGeneratingUrl(false)
|
|
}
|
|
}
|
|
|
|
const submitTagForm = async () => {
|
|
if (!canEditObjectTags) return
|
|
if (!resolvedObjectKey) {
|
|
message.error(t("Failed to fetch object info"))
|
|
return
|
|
}
|
|
|
|
const nextTag = {
|
|
Key: tagFormValue.Key.trim(),
|
|
Value: tagFormValue.Value.trim(),
|
|
}
|
|
if (!nextTag.Key || !nextTag.Value) {
|
|
message.error(t("Please fill in the correct format"))
|
|
return
|
|
}
|
|
try {
|
|
const nextTags = [...tags, nextTag]
|
|
await objectApi.putObjectTags(resolvedObjectKey, nextTags)
|
|
setTags(nextTags)
|
|
setTagFormValue({ Key: "", Value: "" })
|
|
message.success(t("Create Success"))
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Create Failed"))
|
|
}
|
|
}
|
|
|
|
const confirmDeleteTag = (tagKey: string) => {
|
|
dialog.warning({
|
|
title: t("Delete Tag"),
|
|
content: t("Are you sure you want to delete this tag?"),
|
|
positiveText: t("Confirm"),
|
|
negativeText: t("Cancel"),
|
|
onPositiveClick: () => removeTag(tagKey),
|
|
})
|
|
}
|
|
|
|
const removeTag = async (tagKey: string) => {
|
|
if (!canEditObjectTags || !resolvedObjectKey) return
|
|
try {
|
|
const nextTags = tags.filter((tag) => tag.Key !== tagKey)
|
|
await objectApi.putObjectTags(resolvedObjectKey, nextTags)
|
|
setTags(nextTags)
|
|
message.success(t("Delete Success"))
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Delete Failed"))
|
|
}
|
|
}
|
|
|
|
const submitRetention = async () => {
|
|
if (isUpdatingRetention) return
|
|
if (!resolvedObjectKey) {
|
|
message.error(t("Failed to fetch object info"))
|
|
return
|
|
}
|
|
if (!canEditRetention) {
|
|
message.error(t("Update Failed"))
|
|
return
|
|
}
|
|
if (!isObjectRetentionDateInFuture(retainUntilDate)) {
|
|
message.error(t("The retain until date must be in the future"))
|
|
return
|
|
}
|
|
|
|
setIsUpdatingRetention(true)
|
|
try {
|
|
await objectApi.putObjectRetention(resolvedObjectKey, {
|
|
Mode: retentionMode,
|
|
RetainUntilDate: toObjectRetentionRequestValue(retainUntilDate),
|
|
})
|
|
message.success(t("Update Success"))
|
|
setShowRetentionView(false)
|
|
fetchRetention(resolvedObjectKey)
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Update Failed"))
|
|
} finally {
|
|
setIsUpdatingRetention(false)
|
|
}
|
|
}
|
|
|
|
const resetRetention = async () => {
|
|
if (isUpdatingRetention) return
|
|
if (!resolvedObjectKey) {
|
|
message.error(t("Failed to fetch object info"))
|
|
return
|
|
}
|
|
if (!canEditRetention) {
|
|
message.error(t("Update Failed"))
|
|
return
|
|
}
|
|
|
|
setIsUpdatingRetention(true)
|
|
try {
|
|
await objectApi.putObjectRetention(resolvedObjectKey, {
|
|
Mode: "GOVERNANCE",
|
|
})
|
|
message.success(t("Update Success"))
|
|
fetchRetention(resolvedObjectKey)
|
|
} catch (err) {
|
|
message.error((err as Error)?.message ?? t("Update Failed"))
|
|
} finally {
|
|
setIsUpdatingRetention(false)
|
|
}
|
|
}
|
|
|
|
const lastModified = normalizeDateToIso((object?.LastModified as string | Date | undefined) ?? undefined)
|
|
|
|
return (
|
|
<>
|
|
<Drawer open={open} onOpenChange={onOpenChange} swipeDirection="right">
|
|
<DrawerContent className="overflow-y-auto overflow-x-hidden data-[swipe-direction=right]:w-[92vw] data-[swipe-direction=right]:sm:max-w-2xl">
|
|
<DrawerHeader>
|
|
<DrawerTitle>{t("Object Details")}</DrawerTitle>
|
|
<DrawerDescription className="break-all">{resolvedObjectKey || bucketName}</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="min-w-0 space-y-4 p-4 overflow-hidden">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{canDownloadObject ? (
|
|
<Button variant="outline" size="sm" onClick={download}>
|
|
<RiDownloadLine className="size-4" aria-hidden />
|
|
{t("Download")}
|
|
</Button>
|
|
) : null}
|
|
{canPreviewObject ? (
|
|
<Button variant="outline" size="sm" onClick={() => onPreview({ key: String(object?.Key ?? "") })}>
|
|
<RiEyeLine className="size-4" aria-hidden />
|
|
{t("Preview")}
|
|
</Button>
|
|
) : null}
|
|
{canViewObjectTags || canEditObjectTags ? (
|
|
<Button variant="outline" size="sm" onClick={() => setShowTagView(true)}>
|
|
<RiPriceTag3Line className="size-4" aria-hidden />
|
|
{t("Set Tags")}
|
|
</Button>
|
|
) : null}
|
|
{canViewVersions ? (
|
|
<Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
|
|
<RiFileList2Line className="size-4" aria-hidden />
|
|
{t("Versions")}
|
|
</Button>
|
|
) : null}
|
|
{showRetentionAction ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setMinRetentionDate(getMinimumObjectRetentionDate())
|
|
if (!retainUntilDate || !isObjectRetentionDateInFuture(retainUntilDate)) {
|
|
setRetainUntilDate(getDefaultObjectRetentionDate())
|
|
}
|
|
setShowRetentionView(true)
|
|
}}
|
|
>
|
|
<RiLockLine className="size-4" aria-hidden />
|
|
{t("Retention")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<Item variant="outline" className="flex-col items-stretch gap-4">
|
|
<Tabs defaultValue="info" className="w-full">
|
|
<TabsList className="mb-2 inline-flex h-9 items-center justify-center rounded-none bg-muted p-1 text-muted-foreground">
|
|
<TabsTrigger value="info" className="px-4">
|
|
{t("Info")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="metadata" className="px-4">
|
|
{t("Metadata")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent
|
|
value="info"
|
|
className="space-y-4 outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
|
>
|
|
<ItemContent className="min-w-0 space-y-3 text-sm overflow-hidden">
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Object Name")}</span>
|
|
<span className="flex-1 truncate text-end" title={String(object?.Key ?? "")}>
|
|
{String(object?.Key ?? "")}
|
|
</span>
|
|
<CopyButton value={String(object?.Key ?? "")} iconOnly />
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Object Size")}</span>
|
|
<span className="max-w-[60%] truncate text-end" title={String(object?.ContentLength ?? "-")}>
|
|
{String(object?.ContentLength ?? "-")}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Object Type")}</span>
|
|
<span className="max-w-[60%] truncate text-end" title={String(object?.ContentType ?? "")}>
|
|
{String(object?.ContentType ?? "-")}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">ETag</span>
|
|
<span className="max-w-[60%] truncate text-end" title={String(object?.ETag ?? "-")}>
|
|
{String(object?.ETag ?? "-")}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Last Modified Time")}</span>
|
|
<span className="max-w-[60%] truncate text-end" title={lastModified || "-"}>
|
|
{lastModified || "-"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<label htmlFor="object-legal-hold" className="font-medium text-muted-foreground">
|
|
{t("Legal Hold")}
|
|
</label>
|
|
<Switch
|
|
id="object-legal-hold"
|
|
checked={lockStatus}
|
|
onCheckedChange={toggleLegalHold}
|
|
disabled={!canEditLegalHold}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<span className="font-medium text-muted-foreground">
|
|
{t("Retention")}
|
|
{t("Policy")}
|
|
</span>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="truncate" title={retention}>
|
|
{retention}
|
|
</span>
|
|
{retainUntilDate && (
|
|
<span className="text-xs text-muted-foreground truncate" title={retainUntilDate}>
|
|
{retainUntilDate}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ItemContent>
|
|
</TabsContent>
|
|
<TabsContent
|
|
value="metadata"
|
|
className="space-y-4 outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
|
>
|
|
<ItemContent className="min-w-0 space-y-3 text-sm overflow-hidden">
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Content Type")}</span>
|
|
<span className="max-w-[60%] truncate text-end" title={String(object?.ContentType ?? "")}>
|
|
{String(object?.ContentType ?? "")}
|
|
</span>
|
|
</div>
|
|
{object &&
|
|
Object.entries(object?.Metadata ?? {}).map(([key, value]) => (
|
|
<React.Fragment key={key}>
|
|
<div className="flex items-center justify-between gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t(key)}</span>
|
|
<span className="max-w-[60%] truncate text-end" title={String(key)}>
|
|
{String(value)}
|
|
</span>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
</ItemContent>
|
|
</TabsContent>
|
|
</Tabs>
|
|
<ItemContent className="min-w-0 space-y-3 text-sm overflow-hidden">
|
|
<div className="border-t pt-3 flex flex-col gap-3 min-w-0">
|
|
<span className="font-medium text-muted-foreground">{t("Temporary URL Expiration")}</span>
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Input
|
|
name="temporary-url-expiration-days"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={0}
|
|
max={7}
|
|
aria-label={t("Days")}
|
|
placeholder={t("Days")}
|
|
className="w-14 shrink-0"
|
|
value={expirationDays}
|
|
onChange={(e) => setExpirationDays(Number(e.target.value) || 0)}
|
|
/>
|
|
<span className="text-sm text-muted-foreground whitespace-nowrap">{t("Days")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Input
|
|
name="temporary-url-expiration-hours"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={0}
|
|
max={expirationDays > 0 ? 23 : 24}
|
|
aria-label={t("Hours")}
|
|
placeholder={t("Hours")}
|
|
className="w-14 shrink-0"
|
|
value={expirationHours}
|
|
onChange={(e) => setExpirationHours(Number(e.target.value) || 0)}
|
|
/>
|
|
<span className="text-sm text-muted-foreground whitespace-nowrap">{t("Hours")}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<Input
|
|
name="temporary-url-expiration-minutes"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={0}
|
|
max={59}
|
|
aria-label={t("Minutes")}
|
|
placeholder={t("Minutes")}
|
|
className="w-14 shrink-0"
|
|
value={expirationMinutes}
|
|
onChange={(e) => setExpirationMinutes(Number(e.target.value) || 0)}
|
|
/>
|
|
<span className="text-sm text-muted-foreground whitespace-nowrap">{t("Minutes")}</span>
|
|
</div>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="shrink-0"
|
|
disabled={!canShareObject || !object?.Key || !isExpirationValid || isGeneratingUrl}
|
|
onClick={generateTemporaryUrl}
|
|
>
|
|
{isGeneratingUrl ? <Spinner className="size-4" /> : null}
|
|
<span>{t("Generate URL")}</span>
|
|
</Button>
|
|
</div>
|
|
{expirationError && <div className="text-xs text-destructive">{expirationError}</div>}
|
|
{totalExpirationSeconds > 0 && (
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("Total Duration")}: {formatDuration(totalExpirationSeconds)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2 min-w-0 w-full">
|
|
<CopyInput value={signedUrl} readonly copyIcon className="min-w-0 flex-1" />
|
|
{signedUrl && (
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{t("This link will expire when your session ends or at the specified time.")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ItemContent>
|
|
</Item>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Dialog open={showTagView} onOpenChange={setShowTagView} disablePointerDismissal>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("Set Tags")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{tags.map((tag) => (
|
|
<Badge
|
|
key={tag.Key}
|
|
variant="secondary"
|
|
className="h-auto max-w-full justify-start gap-1 pe-1 whitespace-normal"
|
|
>
|
|
<span className="min-w-0 break-all" title={`${tag.Key}: ${tag.Value}`}>
|
|
{tag.Key}: {tag.Value}
|
|
</span>
|
|
{canEditObjectTags ? (
|
|
<button
|
|
type="button"
|
|
aria-label={t("Delete Tag")}
|
|
className="inline-flex size-6 shrink-0 items-center justify-center transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50"
|
|
onClick={() => confirmDeleteTag(tag.Key)}
|
|
>
|
|
<RiCloseLine className="size-4" aria-hidden />
|
|
</button>
|
|
) : null}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<form
|
|
className="space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
void submitTagForm()
|
|
}}
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Field>
|
|
<FieldLabel htmlFor="object-tag-key">{t("Tag Key")}</FieldLabel>
|
|
<FieldContent>
|
|
<Input
|
|
id="object-tag-key"
|
|
name="object-tag-key"
|
|
value={tagFormValue.Key}
|
|
onChange={(e) => setTagFormValue((v) => ({ ...v, Key: e.target.value }))}
|
|
placeholder={t("Tag Key Placeholder")}
|
|
disabled={!canEditObjectTags}
|
|
/>
|
|
</FieldContent>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel htmlFor="object-tag-value">{t("Tag Value")}</FieldLabel>
|
|
<FieldContent>
|
|
<Input
|
|
id="object-tag-value"
|
|
name="object-tag-value"
|
|
value={tagFormValue.Value}
|
|
onChange={(e) =>
|
|
setTagFormValue((v) => ({
|
|
...v,
|
|
Value: e.target.value,
|
|
}))
|
|
}
|
|
placeholder={t("Tag Value Placeholder")}
|
|
disabled={!canEditObjectTags}
|
|
/>
|
|
</FieldContent>
|
|
</Field>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
onClick={() => void submitTagForm()}
|
|
disabled={!canEditObjectTags || !resolvedObjectKey}
|
|
>
|
|
{t("Add")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={showRetentionView} onOpenChange={setShowRetentionView} disablePointerDismissal>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<div ref={retentionDialogContentRef} className="contents">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("Retention")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-3">
|
|
<Field>
|
|
<FieldLabel>{t("Retention Mode")}</FieldLabel>
|
|
<FieldContent>
|
|
<RadioGroup
|
|
value={retentionMode}
|
|
onValueChange={(v) => setRetentionMode(v as "COMPLIANCE" | "GOVERNANCE")}
|
|
className="grid gap-2 sm:grid-cols-2"
|
|
>
|
|
{[
|
|
{ label: t("COMPLIANCE"), value: "COMPLIANCE" },
|
|
{ label: t("GOVERNANCE"), value: "GOVERNANCE" },
|
|
].map((opt) => (
|
|
<label
|
|
key={opt.value}
|
|
htmlFor={`object-retention-mode-${opt.value.toLowerCase()}`}
|
|
className="flex items-start gap-3 border border-border/50 p-3 cursor-pointer"
|
|
>
|
|
<RadioGroupItem
|
|
id={`object-retention-mode-${opt.value.toLowerCase()}`}
|
|
value={opt.value}
|
|
className="mt-0.5"
|
|
/>
|
|
<span className="text-sm font-medium">{opt.label}</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
</FieldContent>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>{t("Retention RetainUntilDate")}</FieldLabel>
|
|
<FieldContent>
|
|
<DateTimePicker
|
|
value={retainUntilDate}
|
|
onChange={(value) => setRetainUntilDate(value ?? "")}
|
|
placeholder={t("Retention RetainUntilDate")}
|
|
min={minRetentionDate}
|
|
disabled={!canEditRetention || isUpdatingRetention}
|
|
portalContainer={retentionDialogContentRef}
|
|
/>
|
|
</FieldContent>
|
|
</Field>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={resetRetention}
|
|
disabled={!canEditRetention || isUpdatingRetention}
|
|
>
|
|
{t("Reset")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
onClick={() => void submitRetention()}
|
|
disabled={!canEditRetention || isUpdatingRetention}
|
|
>
|
|
{isUpdatingRetention ? <Spinner className="size-4" /> : null}
|
|
{t("Confirm")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowRetentionView(false)}
|
|
disabled={isUpdatingRetention}
|
|
>
|
|
{t("Cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ObjectVersions
|
|
bucketName={bucketName}
|
|
objectKey={resolvedObjectKey}
|
|
visible={showVersions}
|
|
onClose={() => setShowVersions(false)}
|
|
onPreview={handlePreviewVersion}
|
|
onRefreshParent={() => objectKey && loadObjectInfo(objectKey)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|