Files
userbase/app/src/ui/modals/media/MediaInfoModal.tsx
T
dswbx 55082e9d0e feat: improve media handling
added local range requests, fix mime type detection, improve uploading using FormData, correctly use mime type allow list, added previews for audio, pdf and text
2025-09-29 14:55:19 +02:00

260 lines
8.0 KiB
TypeScript

import type { ContextModalProps } from "@mantine/modals";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useEntityQuery } from "ui/client";
import { type FileState, Media } from "ui/elements";
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { TbCheck, TbCopy } from "react-icons/tb";
import { useClipboard } from "@mantine/hooks";
import { ButtonLink } from "ui/components/buttons/Button";
import { routes } from "ui/lib/routes";
import { useBkndMedia } from "ui/client/schema/media/use-bknd-media";
import { JsonViewer } from "ui";
export type MediaInfoModalProps = {
file: FileState;
};
export function MediaInfoModal({
context,
id,
innerProps: { file },
}: ContextModalProps<MediaInfoModalProps>) {
const {
config: { entity_name, basepath },
} = useBkndMedia();
const $q = useEntityQuery(entity_name as "media", undefined, {
where: {
path: file.path,
},
});
const close = () => context.closeModal(id);
const data = $q.data?.[0];
const origin = window.location.origin;
const entity = data?.reference ? data?.reference.split(".")[0] : undefined;
const entityUrl = entity
? "/data" + routes.data.entity.edit(entity, data?.entity_id!)
: undefined;
const mediaUrl = data?.path
? "/data" + routes.data.entity.edit(entity_name, data?.id!)
: undefined;
//const assetUrl = data?.path ? origin + basepath + "/file/" + data?.path : undefined;
return (
<div className="flex flex-col md:flex-row">
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
<FilePreview file={file} />
</div>
<div className="w-full md:!w-[300px] flex flex-col">
<Item title="ID" value={data?.id} copyValue={origin + mediaUrl} first>
{mediaUrl && (
<ButtonLink
href={mediaUrl!}
size="small"
className="py-1.5 px-2 !leading-none font-mono"
onClick={close}
>
#{String(data?.id)}
</ButtonLink>
)}
</Item>
<Item title="Path" value={data?.path} />
<Item title="Mime Type" value={data?.mime_type} />
<Item
title="Size"
value={data?.size && formatNumber.fileSize(data.size, 1)}
copyValue={data?.size}
/>
<Item title="Etag" value={data?.etag} />
<Item title="Entity" copyValue={origin + entityUrl}>
{entityUrl && (
<ButtonLink
href={entityUrl!}
size="small"
className="py-1.5 px-2 !leading-none font-mono"
onClick={close}
>
{data?.reference} #{data?.entity_id}
</ButtonLink>
)}
</Item>
<Item
title="Modified At"
value={data?.modified_at && datetimeStringLocal(data?.modified_at)}
copyValue={data?.modified_at}
/>
<Item title="Metadata" value={data?.metadata} copyable={false}>
{data?.metadata && (
<JsonViewer
json={data?.metadata}
expand={2}
showCopy
className="w-full text-sm bg-primary/2 pt-2.5 rounded-lg"
copyIconProps={{
className: "size-6 opacity-20 group-hover:opacity-100 transition-all",
}}
/>
)}
</Item>
</div>
</div>
);
}
const Item = ({
title,
children,
value,
first,
copyable = true,
copyValue,
}: {
title: string;
children?: ReactNode;
value?: any;
first?: boolean;
copyable?: boolean;
copyValue?: any;
}) => {
const cb = useClipboard();
const is_null = !children && (value === null || typeof value === "undefined");
const can_copy = copyable && !is_null && cb.copy !== undefined;
const _value = value
? typeof value === "object" && !is_null
? JSON.stringify(value)
: String(value)
: undefined;
return (
<div
className={twMerge(
"flex flex-col gap-1 py-3 pl-5 pr-3 group",
!first && "border-t border-muted",
)}
>
<div className="text-sm font-bold opacity-50">{autoFormatString(title)}</div>
<div className="flex flex-row gap-1 justify-between items-center">
{children ?? (
<div className={twMerge("font-mono truncate", is_null && "opacity-30")}>
{is_null ? "null" : _value}
</div>
)}
{can_copy && (
<IconButton
Icon={cb.copied ? TbCheck : TbCopy}
className={twMerge(
"size-6 opacity-20 group-hover:opacity-100 transition-all",
cb.copied && "text-success-foreground opacity-100",
)}
onClick={() => cb.copy(copyValue ? copyValue : value)}
/>
)}
</div>
</div>
);
};
const FilePreview = ({ file }: { file: FileState }) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
// @ts-ignore
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
}
if (file.type === "application/pdf") {
// use browser preview
return (
<iframe
title="PDF preview"
src={`${objectUrl}#view=fitH&zoom=page-width&toolbar=1`}
className="w-250 max-w-[80dvw] h-[80dvh]"
/>
);
}
if (
[
"text/plain",
"text/markdown",
"text/csv",
"text/tab-separated-values",
"application/json",
].includes(file.type)
) {
return <TextPreview file={file} />;
}
if (file.type.startsWith("audio/")) {
return (
<div className="p-5">
<audio src={objectUrl} controls />
</div>
);
}
return (
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
<span className="opacity-50 font-mono">No Preview Available</span>
</div>
);
};
const TextPreview = ({ file }: { file: FileState }) => {
const [text, setText] = useState("");
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
const maxBytes = 1024 * 256;
const useRange = file.size > maxBytes;
useEffect(() => {
let cancelled = false;
if (file) {
fetch(objectUrl, {
headers: useRange ? { Range: `bytes=0-${maxBytes - 1}` } : undefined,
})
.then((r) => r.text())
.then((t) => {
if (!cancelled) setText(t);
});
} else {
setText("");
}
return () => {
cancelled = true;
};
}, [file, useRange]);
return (
<pre className="text-sm font-mono whitespace-pre-wrap break-all overflow-y-scroll w-250 md:max-w-[80dvw] h-[60dvh] md:h-[80dvh] py-4 px-6 debug">
{text}
{useRange && (
<div className="mt-3 opacity-50 text-xs text-center">
Showing first {formatNumber.fileSize(maxBytes)}
</div>
)}
</pre>
);
};
MediaInfoModal.defaultTitle = undefined;
MediaInfoModal.modalProps = {
withCloseButton: false,
size: "auto",
//size: "90%",
centered: true,
styles: {
content: {
overflowY: "initial !important",
},
},
classNames: {
root: "bknd-admin w-full max-w-xl",
content: "overflow-hidden",
title: "font-bold !text-md",
body: "max-h-inherit !p-0",
},
};