mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
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
This commit is contained in:
@@ -42,7 +42,10 @@ export type DropzoneRenderProps = {
|
||||
showPlaceholder: boolean;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
dropzoneProps: Pick<
|
||||
DropzoneProps,
|
||||
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
@@ -151,6 +154,7 @@ export function Dropzone({
|
||||
const setIsOver = useStore(store, (state) => state.setIsOver);
|
||||
const uploading = useStore(store, (state) => state.uploading);
|
||||
const setFileState = useStore(store, (state) => state.setFileState);
|
||||
const overrideFile = useStore(store, (state) => state.overrideFile);
|
||||
const removeFile = useStore(store, (state) => state.removeFile);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -359,7 +363,7 @@ export function Dropzone({
|
||||
state: "uploaded",
|
||||
};
|
||||
|
||||
setFileState(file.path, newState.state);
|
||||
overrideFile(file.path, newState);
|
||||
resolve({ ...response, ...file, ...newState });
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
@@ -382,7 +386,9 @@ export function Dropzone({
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.send(file.body);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,7 +417,9 @@ export function Dropzone({
|
||||
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||
const showPlaceholder = useMemo(
|
||||
() =>
|
||||
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||
Boolean(
|
||||
placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)),
|
||||
),
|
||||
[placeholder, maxItems, files.length],
|
||||
);
|
||||
|
||||
@@ -437,11 +445,12 @@ export function Dropzone({
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
allowedMimeTypes,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
}),
|
||||
[maxItems, flow, placeholder, autoUpload, footer],
|
||||
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
|
||||
) as unknown as DropzoneRenderProps;
|
||||
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,7 @@ export const DropzoneInner = ({
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
@@ -52,7 +52,7 @@ export const DropzoneInner = ({
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
<input {...inputProps} />
|
||||
<input {...inputProps} accept={allowedMimeTypes?.join(",")} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||
@@ -159,9 +159,9 @@ const Preview = memo(
|
||||
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||
<StateIndicator file={file} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<div className="flex flex-row justify-between text-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
<span className="whitespace-nowrap">{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,5 +272,7 @@ const VideoPreview = ({
|
||||
};
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
return (
|
||||
<div className="text-xs text-primary/50 text-center font-mono leading-none">{file.type}</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export const createDropzoneStore = () => {
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
overrideFile: (path: string, newState: Partial<FileState>) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) => (f.path === path ? { ...f, ...newState } : f)),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import type { ReactNode } from "react";
|
||||
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";
|
||||
@@ -157,11 +157,43 @@ const Item = ({
|
||||
};
|
||||
|
||||
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>
|
||||
@@ -169,6 +201,44 @@ const FilePreview = ({ file }: { file: FileState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -240,6 +240,8 @@ function EntityMediaFormField({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
const maxLimit = 50;
|
||||
const maxItems = field.getMaxItems();
|
||||
|
||||
const value = useStore(formApi.store, (state) => {
|
||||
const val = state.values[field.name];
|
||||
@@ -260,8 +262,9 @@ function EntityMediaFormField({
|
||||
<FieldLabel field={field} />
|
||||
<Media.Dropzone
|
||||
key={key}
|
||||
maxItems={field.getMaxItems()}
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
maxItems={maxItems}
|
||||
allowedMimeTypes={field.getAllowedMimeTypes()}
|
||||
/* initialItems={value} @todo: test if better be omitted, so it fetches */
|
||||
onClick={onClick}
|
||||
entity={{
|
||||
name: entity.name,
|
||||
@@ -270,6 +273,7 @@ function EntityMediaFormField({
|
||||
}}
|
||||
query={{
|
||||
sort: "-id",
|
||||
limit: maxItems && maxItems > maxLimit ? maxLimit : maxItems,
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
|
||||
Reference in New Issue
Block a user