mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +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:
BIN
app/__test__/_assets/test.mp3
Normal file
BIN
app/__test__/_assets/test.mp3
Normal file
Binary file not shown.
1
app/__test__/_assets/test.txt
Normal file
1
app/__test__/_assets/test.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
|
|||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
disableConsoleLog();
|
//disableConsoleLog();
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
});
|
});
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -94,4 +94,38 @@ describe("MediaController", () => {
|
|||||||
expect(res.status).toBe(413);
|
expect(res.status).toBe(413);
|
||||||
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("audio files", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
const file = Bun.file(`${assetsPath}/test.mp3`);
|
||||||
|
const name = makeName("mp3");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.data.mime_type).toBe("audio/mpeg");
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text files", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
const file = Bun.file(`${assetsPath}/test.txt`);
|
||||||
|
const name = makeName("txt");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.data.mime_type).toBe("text/plain");
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe("media/mime-types", () => {
|
|||||||
["text/tab-separated-values", "tsv"],
|
["text/tab-separated-values", "tsv"],
|
||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
["application/pdf", "pdf"],
|
["application/pdf", "pdf"],
|
||||||
|
["audio/mpeg", "mp3"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [mime, ext] of tests) {
|
for (const [mime, ext] of tests) {
|
||||||
@@ -90,6 +91,8 @@ describe("media/mime-types", () => {
|
|||||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||||
["file.pdf", "pdf"],
|
["file.pdf", "pdf"],
|
||||||
|
["file.mp3", "mp3"],
|
||||||
|
["robots.txt", "txt"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [filename, ext] of tests) {
|
for (const [filename, ext] of tests) {
|
||||||
@@ -127,5 +130,13 @@ describe("media/mime-types", () => {
|
|||||||
).split(".");
|
).split(".");
|
||||||
expect(ext).toBe("what");
|
expect(ext).toBe("what");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// txt
|
||||||
|
const [, ext] = getRandomizedFilename(
|
||||||
|
new File([""], "file.txt", { type: "text/plain" }),
|
||||||
|
).split(".");
|
||||||
|
expect(ext).toBe("txt");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ describe("StorageR2Adapter", async () => {
|
|||||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||||
|
|
||||||
await adapterTestSuite(viTestRunner, adapter, file);
|
// miniflare doesn't support range requests
|
||||||
|
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseRangeHeader(
|
||||||
|
rangeHeader: string,
|
||||||
|
fileSize: number,
|
||||||
|
): { start: number; end: number } | null {
|
||||||
|
// Parse "bytes=start-end" format
|
||||||
|
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, startStr, endStr] = match;
|
||||||
|
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
||||||
|
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
||||||
|
|
||||||
|
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
|
||||||
|
if (!startStr && endStr) {
|
||||||
|
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
||||||
|
end = fileSize - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate range
|
||||||
|
if (start < 0 || end >= fileSize || start > end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(`${this.config.path}/${key}`);
|
const filePath = `${this.config.path}/${key}`;
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
const fileSize = stats.size;
|
||||||
const mimeType = guessMimeType(key);
|
const mimeType = guessMimeType(key);
|
||||||
|
|
||||||
return new Response(content, {
|
const responseHeaders = new Headers({
|
||||||
status: 200,
|
"Accept-Ranges": "bytes",
|
||||||
headers: {
|
"Content-Type": mimeType || "application/octet-stream",
|
||||||
"Content-Type": mimeType || "application/octet-stream",
|
|
||||||
"Content-Length": content.length.toString(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rangeHeader = headers.get("range");
|
||||||
|
|
||||||
|
if (rangeHeader) {
|
||||||
|
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
// Invalid range - return 416 Range Not Satisfiable
|
||||||
|
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
||||||
|
return new Response("", {
|
||||||
|
status: 416,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = range;
|
||||||
|
const content = await readFile(filePath, { encoding: null });
|
||||||
|
const chunk = content.slice(start, end + 1);
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||||
|
responseHeaders.set("Content-Length", chunk.length.toString());
|
||||||
|
|
||||||
|
return new Response(chunk, {
|
||||||
|
status: 206, // Partial Content
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal request - return entire file
|
||||||
|
const content = await readFile(filePath);
|
||||||
|
responseHeaders.set("Content-Length", content.length.toString());
|
||||||
|
|
||||||
|
return new Response(content, {
|
||||||
|
status: 200,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle file reading errors
|
// Handle file reading errors
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export class MediaField<
|
|||||||
return this.config.max_items;
|
return this.config.max_items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllowedMimeTypes(): string[] | undefined {
|
||||||
|
return this.config.mime_types;
|
||||||
|
}
|
||||||
|
|
||||||
getMinItems(): number | undefined {
|
getMinItems(): number | undefined {
|
||||||
return this.config.min_items;
|
return this.config.min_items;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ export async function adapterTestSuite(
|
|||||||
retries?: number;
|
retries?: number;
|
||||||
retryTimeout?: number;
|
retryTimeout?: number;
|
||||||
skipExistsAfterDelete?: boolean;
|
skipExistsAfterDelete?: boolean;
|
||||||
|
testRange?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { test, expect } = testRunner;
|
const { test, expect } = testRunner;
|
||||||
const options = {
|
const options = {
|
||||||
retries: opts?.retries ?? 1,
|
retries: opts?.retries ?? 1,
|
||||||
retryTimeout: opts?.retryTimeout ?? 1000,
|
retryTimeout: opts?.retryTimeout ?? 1000,
|
||||||
|
testRange: opts?.testRange ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let objects = 0;
|
let objects = 0;
|
||||||
@@ -53,9 +55,34 @@ export async function adapterTestSuite(
|
|||||||
await test("gets an object", async () => {
|
await test("gets an object", async () => {
|
||||||
const res = await adapter.getObject(filename, new Headers());
|
const res = await adapter.getObject(filename, new Headers());
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||||
// @todo: check the content
|
// @todo: check the content
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.testRange) {
|
||||||
|
await test("handles range request - partial content", async () => {
|
||||||
|
const headers = new Headers({ Range: "bytes=0-99" });
|
||||||
|
const res = await adapter.getObject(filename, headers);
|
||||||
|
expect(res.status).toBe(206); // Partial Content
|
||||||
|
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
|
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("handles range request - suffix range", async () => {
|
||||||
|
const headers = new Headers({ Range: "bytes=-100" });
|
||||||
|
const res = await adapter.getObject(filename, headers);
|
||||||
|
expect(res.status).toBe(206); // Partial Content
|
||||||
|
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("handles invalid range request", async () => {
|
||||||
|
const headers = new Headers({ Range: "bytes=invalid" });
|
||||||
|
const res = await adapter.getObject(filename, headers);
|
||||||
|
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||||
|
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await test("gets object meta", async () => {
|
await test("gets object meta", async () => {
|
||||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||||
type: file.type, // image/png
|
type: file.type, // image/png
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ const c = {
|
|||||||
a: (w = "octet-stream") => `application/${w}`,
|
a: (w = "octet-stream") => `application/${w}`,
|
||||||
i: (w) => `image/${w}`,
|
i: (w) => `image/${w}`,
|
||||||
v: (w) => `video/${w}`,
|
v: (w) => `video/${w}`,
|
||||||
|
au: (w) => `audio/${w}`,
|
||||||
} as const;
|
} as const;
|
||||||
export const M = new Map<string, string>([
|
export const M = new Map<string, string>([
|
||||||
["7z", c.z],
|
["7z", c.z],
|
||||||
["7zip", c.z],
|
["7zip", c.z],
|
||||||
|
["txt", c.t()],
|
||||||
["ai", c.a("postscript")],
|
["ai", c.a("postscript")],
|
||||||
["apk", c.a("vnd.android.package-archive")],
|
["apk", c.a("vnd.android.package-archive")],
|
||||||
["doc", c.a("msword")],
|
["doc", c.a("msword")],
|
||||||
@@ -32,12 +34,12 @@ export const M = new Map<string, string>([
|
|||||||
["jpg", c.i("jpeg")],
|
["jpg", c.i("jpeg")],
|
||||||
["js", c.t("javascript")],
|
["js", c.t("javascript")],
|
||||||
["log", c.t()],
|
["log", c.t()],
|
||||||
["m3u", c.t()],
|
["m3u", c.au("x-mpegurl")],
|
||||||
["m3u8", c.a("vnd.apple.mpegurl")],
|
["m3u8", c.a("vnd.apple.mpegurl")],
|
||||||
["manifest", c.t("cache-manifest")],
|
["manifest", c.t("cache-manifest")],
|
||||||
["md", c.t("markdown")],
|
["md", c.t("markdown")],
|
||||||
["mkv", c.v("x-matroska")],
|
["mkv", c.v("x-matroska")],
|
||||||
["mp3", c.a("mpeg")],
|
["mp3", c.au("mpeg")],
|
||||||
["mobi", c.a("x-mobipocket-ebook")],
|
["mobi", c.a("x-mobipocket-ebook")],
|
||||||
["ppt", c.a("powerpoint")],
|
["ppt", c.a("powerpoint")],
|
||||||
["pptx", `${c.vnd}.presentationml.presentation`],
|
["pptx", `${c.vnd}.presentationml.presentation`],
|
||||||
@@ -46,11 +48,10 @@ export const M = new Map<string, string>([
|
|||||||
["tif", c.i("tiff")],
|
["tif", c.i("tiff")],
|
||||||
["tsv", c.t("tab-separated-values")],
|
["tsv", c.t("tab-separated-values")],
|
||||||
["tgz", c.a("x-tar")],
|
["tgz", c.a("x-tar")],
|
||||||
["txt", c.t()],
|
|
||||||
["text", c.t()],
|
["text", c.t()],
|
||||||
["vcd", c.a("x-cdlink")],
|
["vcd", c.a("x-cdlink")],
|
||||||
["vcs", c.t("x-vcalendar")],
|
["vcs", c.t("x-vcalendar")],
|
||||||
["wav", c.a("x-wav")],
|
["wav", c.au("vnd.wav")],
|
||||||
["webmanifest", c.a("manifest+json")],
|
["webmanifest", c.a("manifest+json")],
|
||||||
["xls", c.a("vnd.ms-excel")],
|
["xls", c.a("vnd.ms-excel")],
|
||||||
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export type DropzoneRenderProps = {
|
|||||||
showPlaceholder: boolean;
|
showPlaceholder: boolean;
|
||||||
onClick?: (file: { path: string }) => void;
|
onClick?: (file: { path: string }) => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
dropzoneProps: Pick<
|
||||||
|
DropzoneProps,
|
||||||
|
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropzoneProps = {
|
export type DropzoneProps = {
|
||||||
@@ -151,6 +154,7 @@ export function Dropzone({
|
|||||||
const setIsOver = useStore(store, (state) => state.setIsOver);
|
const setIsOver = useStore(store, (state) => state.setIsOver);
|
||||||
const uploading = useStore(store, (state) => state.uploading);
|
const uploading = useStore(store, (state) => state.uploading);
|
||||||
const setFileState = useStore(store, (state) => state.setFileState);
|
const setFileState = useStore(store, (state) => state.setFileState);
|
||||||
|
const overrideFile = useStore(store, (state) => state.overrideFile);
|
||||||
const removeFile = useStore(store, (state) => state.removeFile);
|
const removeFile = useStore(store, (state) => state.removeFile);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -359,7 +363,7 @@ export function Dropzone({
|
|||||||
state: "uploaded",
|
state: "uploaded",
|
||||||
};
|
};
|
||||||
|
|
||||||
setFileState(file.path, newState.state);
|
overrideFile(file.path, newState);
|
||||||
resolve({ ...response, ...file, ...newState });
|
resolve({ ...response, ...file, ...newState });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFileState(file.path, "uploaded", 1);
|
setFileState(file.path, "uploaded", 1);
|
||||||
@@ -382,7 +386,9 @@ export function Dropzone({
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.setRequestHeader("Accept", "application/json");
|
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 openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||||
const showPlaceholder = useMemo(
|
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],
|
[placeholder, maxItems, files.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -437,11 +445,12 @@ export function Dropzone({
|
|||||||
placeholder,
|
placeholder,
|
||||||
autoUpload,
|
autoUpload,
|
||||||
flow,
|
flow,
|
||||||
|
allowedMimeTypes,
|
||||||
},
|
},
|
||||||
onClick,
|
onClick,
|
||||||
footer,
|
footer,
|
||||||
}),
|
}),
|
||||||
[maxItems, flow, placeholder, autoUpload, footer],
|
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
|
||||||
) as unknown as DropzoneRenderProps;
|
) as unknown as DropzoneRenderProps;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const DropzoneInner = ({
|
|||||||
inputProps,
|
inputProps,
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
actions: { uploadFile, deleteFile, openFileInput },
|
actions: { uploadFile, deleteFile, openFileInput },
|
||||||
dropzoneProps: { placeholder, flow },
|
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||||
onClick,
|
onClick,
|
||||||
footer,
|
footer,
|
||||||
}: DropzoneRenderProps) => {
|
}: DropzoneRenderProps) => {
|
||||||
@@ -52,7 +52,7 @@ export const DropzoneInner = ({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="hidden">
|
<div className="hidden">
|
||||||
<input {...inputProps} />
|
<input {...inputProps} accept={allowedMimeTypes?.join(",")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
<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>
|
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||||
<StateIndicator file={file} />
|
<StateIndicator file={file} />
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,5 +272,7 @@ const VideoPreview = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
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,
|
: 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 { ContextModalProps } from "@mantine/modals";
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import { useEntityQuery } from "ui/client";
|
import { useEntityQuery } from "ui/client";
|
||||||
import { type FileState, Media } from "ui/elements";
|
import { type FileState, Media } from "ui/elements";
|
||||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||||
@@ -157,11 +157,43 @@ const Item = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FilePreview = ({ file }: { file: FileState }) => {
|
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/")) {
|
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
|
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 (
|
return (
|
||||||
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
|
<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>
|
<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.defaultTitle = undefined;
|
||||||
MediaInfoModal.modalProps = {
|
MediaInfoModal.modalProps = {
|
||||||
withCloseButton: false,
|
withCloseButton: false,
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ function EntityMediaFormField({
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
const maxLimit = 50;
|
||||||
|
const maxItems = field.getMaxItems();
|
||||||
|
|
||||||
const value = useStore(formApi.store, (state) => {
|
const value = useStore(formApi.store, (state) => {
|
||||||
const val = state.values[field.name];
|
const val = state.values[field.name];
|
||||||
@@ -260,8 +262,9 @@ function EntityMediaFormField({
|
|||||||
<FieldLabel field={field} />
|
<FieldLabel field={field} />
|
||||||
<Media.Dropzone
|
<Media.Dropzone
|
||||||
key={key}
|
key={key}
|
||||||
maxItems={field.getMaxItems()}
|
maxItems={maxItems}
|
||||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
allowedMimeTypes={field.getAllowedMimeTypes()}
|
||||||
|
/* initialItems={value} @todo: test if better be omitted, so it fetches */
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
entity={{
|
entity={{
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
@@ -270,6 +273,7 @@ function EntityMediaFormField({
|
|||||||
}}
|
}}
|
||||||
query={{
|
query={{
|
||||||
sort: "-id",
|
sort: "-id",
|
||||||
|
limit: maxItems && maxItems > maxLimit ? maxLimit : maxItems,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
|
|||||||
Reference in New Issue
Block a user