File browser
Object details
A side sheet with a preview and the details of one file in a bucket.
Shows the path, size, type, last modified date, ETag and metadata of one object, read when the sheet opens. Images, video and audio get a preview through a signed link.
Installation
npx shadcn@latest add @runqora/object-detailsUsage
import { ObjectDetails } from "@/components/storage/object-details";
import { getDownloadUrl, getObject } from "@/lib/storage/actions";
<ObjectDetails
objectKey="images/office.svg"
onOpenChange={setOpen}
open={open}
source={{ getDownloadUrl, getObject }}
/>;Leave out getDownloadUrl to show details without previews.
Props
Prop
Type
Source
"use client";
import { type ReactNode, useEffect, useState } from "react";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
formatBytes,
getObjectName,
type StorageObjectDetails,
type StorageSource,
} from "@/lib/storage/types";
export interface ObjectDetailsProps {
/** Key of the object to show. Its details load each time the sheet opens. */
objectKey: string;
onOpenChange: (open: boolean) => void;
open: boolean;
/** Reads the details, and with `getDownloadUrl`, previews images, video and audio. */
source: Pick<StorageSource, "getDownloadUrl" | "getObject">;
}
type State =
| {
details: StorageObjectDetails;
previewUrl: string | null;
status: "ready";
}
| { error: string; status: "failed" }
| { status: "loading" };
const PREVIEW_TYPES = ["image/", "video/", "audio/"];
function canPreview(contentType: string | null): boolean {
return PREVIEW_TYPES.some((type) => contentType?.startsWith(type));
}
const dateFormat = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
async function load(
source: ObjectDetailsProps["source"],
key: string
): Promise<State> {
if (!source.getObject) {
return { error: "Details are not available.", status: "failed" };
}
const result = await source.getObject(key);
if (!result.ok) {
return { error: result.error, status: "failed" };
}
let previewUrl: string | null = null;
if (source.getDownloadUrl && canPreview(result.data.contentType)) {
const link = await source.getDownloadUrl(key, { download: false });
previewUrl = link.ok ? link.data : null;
}
return { details: result.data, previewUrl, status: "ready" };
}
function Detail({ label, children }: { children: ReactNode; label: string }) {
return (
<div>
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="mt-0.5 text-sm break-all">{children}</dd>
</div>
);
}
function Preview({
contentType,
name,
url,
}: {
contentType: string;
name: string;
url: string;
}) {
const className = "max-h-64 w-full rounded-lg border bg-muted object-contain";
if (contentType.startsWith("video/")) {
return (
// biome-ignore lint/a11y/useMediaCaption: files in a bucket rarely come with captions.
<video className={className} controls preload="metadata" src={url} />
);
}
if (contentType.startsWith("audio/")) {
return (
// biome-ignore lint/a11y/useMediaCaption: files in a bucket rarely come with captions.
<audio className="w-full" controls preload="metadata" src={url} />
);
}
return (
// biome-ignore lint/performance/noImgElement: a signed URL that expires can't go through an image optimizer.
<img
alt={`Preview of ${name}`}
className="bg-muted mx-auto h-auto max-h-64 w-auto max-w-full rounded-lg border object-contain"
height={256}
src={url}
width={384}
/>
);
}
function Body({ state }: { state: State }) {
if (state.status === "loading") {
return (
<div aria-busy="true" className="flex flex-col gap-3">
<span className="sr-only">Loading details</span>
<Skeleton className="h-40 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
);
}
if (state.status === "failed") {
return (
<p className="text-destructive text-sm" role="alert">
{state.error}
</p>
);
}
const { details, previewUrl } = state;
const metadata = Object.entries(details.metadata);
return (
<div className="flex flex-col gap-6">
{previewUrl && details.contentType ? (
<Preview
contentType={details.contentType}
name={getObjectName(details.key)}
url={previewUrl}
/>
) : null}
<dl className="flex flex-col gap-3">
<Detail label="Path">
<span className="font-mono text-xs">{details.key}</span>
</Detail>
<Detail label="Size">{formatBytes(details.size)}</Detail>
<Detail label="Type">{details.contentType ?? "Unknown"}</Detail>
<Detail label="Last modified">
{details.lastModified
? dateFormat.format(new Date(details.lastModified))
: "Unknown"}
</Detail>
{details.etag ? (
<Detail label="ETag">
<span className="font-mono text-xs">{details.etag}</span>
</Detail>
) : null}
</dl>
{metadata.length > 0 ? (
<section>
<h3 className="text-sm font-medium">Metadata</h3>
<dl className="mt-2 flex flex-col gap-3">
{metadata.map(([name, value]) => (
<Detail key={name} label={name}>
{value}
</Detail>
))}
</dl>
</section>
) : null}
</div>
);
}
/** A side sheet with a preview and everything known about one object. */
export function ObjectDetails({
objectKey,
onOpenChange,
open,
source,
}: ObjectDetailsProps) {
const [state, setState] = useState<State>({ status: "loading" });
const { getDownloadUrl, getObject } = source;
useEffect(() => {
if (!open) {
return;
}
let current = true;
setState({ status: "loading" });
load({ getDownloadUrl, getObject }, objectKey).then((next) => {
if (current) {
setState(next);
}
});
return () => {
current = false;
};
}, [getDownloadUrl, getObject, objectKey, open]);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle className="break-all">
{getObjectName(objectKey)}
</SheetTitle>
<SheetDescription>File details</SheetDescription>
</SheetHeader>
<div className="px-4 pb-6">
<Body state={state} />
</div>
</SheetContent>
</Sheet>
);
}