Files
rustfs-console/components/object/info.tsx
T

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)}
/>
</>
)
}