mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Merge pull request #276 from bknd-io/feat/media-field-improvements
feat: improve media handling
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";
|
||||
|
||||
beforeAll(() => {
|
||||
disableConsoleLog();
|
||||
//disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -94,4 +94,38 @@ describe("MediaController", () => {
|
||||
expect(res.status).toBe(413);
|
||||
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).toStartWith("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).toStartWith("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"],
|
||||
["application/zip", "zip"],
|
||||
["application/pdf", "pdf"],
|
||||
["audio/mpeg", "mp3"],
|
||||
] as const;
|
||||
|
||||
for (const [mime, ext] of tests) {
|
||||
@@ -90,6 +91,8 @@ describe("media/mime-types", () => {
|
||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||
["file.pdf", "pdf"],
|
||||
["file.mp3", "mp3"],
|
||||
["robots.txt", "txt"],
|
||||
] as const;
|
||||
|
||||
for (const [filename, ext] of tests) {
|
||||
@@ -127,5 +130,13 @@ describe("media/mime-types", () => {
|
||||
).split(".");
|
||||
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 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 () => {
|
||||
|
||||
@@ -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> {
|
||||
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);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
"Content-Length": content.length.toString(),
|
||||
},
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
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) {
|
||||
// Handle file reading errors
|
||||
return new Response("", { status: 404 });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MediaController } from "./api/MediaController";
|
||||
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||
import { mediaFields } from "./media-entities";
|
||||
import * as MediaPermissions from "media/media-permissions";
|
||||
import * as DatabaseEvents from "data/events";
|
||||
|
||||
export type MediaFields = typeof AppMedia.mediaFields;
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
@@ -139,6 +140,30 @@ export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
},
|
||||
{ mode: "sync", id: "delete-data-media" },
|
||||
);
|
||||
|
||||
emgr.onEvent(
|
||||
DatabaseEvents.MutatorDeleteAfter,
|
||||
async (e) => {
|
||||
const { entity, data } = e.params;
|
||||
const fields = entity.fields.filter((f) => f.type === "media");
|
||||
if (fields.length > 0) {
|
||||
const references = fields.map((f) => `${entity.name}.${f.name}`);
|
||||
$console.log("App:storage:file cleaning up", {
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
const { data: deleted } = await em.mutator(media).deleteWhere({
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
for (const file of deleted) {
|
||||
await this.storage.deleteFile(file.path);
|
||||
}
|
||||
$console.log("App:storage:file cleaned up files:", deleted.length);
|
||||
}
|
||||
},
|
||||
{ mode: "async", id: "delete-data-media-after" },
|
||||
);
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
|
||||
@@ -43,6 +43,10 @@ export class MediaField<
|
||||
return this.config.max_items;
|
||||
}
|
||||
|
||||
getAllowedMimeTypes(): string[] | undefined {
|
||||
return this.config.mime_types;
|
||||
}
|
||||
|
||||
getMinItems(): number | undefined {
|
||||
return this.config.min_items;
|
||||
}
|
||||
|
||||
@@ -71,22 +71,29 @@ export class Storage implements EmitsEvents {
|
||||
|
||||
let info: FileUploadPayload = {
|
||||
name,
|
||||
meta: {
|
||||
size: 0,
|
||||
type: "application/octet-stream",
|
||||
},
|
||||
meta: isFile(file)
|
||||
? {
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}
|
||||
: {
|
||||
size: 0,
|
||||
type: "application/octet-stream",
|
||||
},
|
||||
etag: typeof result === "string" ? result : "",
|
||||
};
|
||||
|
||||
// normally only etag is returned
|
||||
if (typeof result === "object") {
|
||||
info = result;
|
||||
} else if (isFile(file)) {
|
||||
info.meta.size = file.size;
|
||||
info.meta.type = file.type;
|
||||
}
|
||||
|
||||
// try to get better meta info
|
||||
if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) {
|
||||
if (
|
||||
!info.meta.type ||
|
||||
["application/octet-stream", "application/json"].includes(info.meta.type) ||
|
||||
!info.meta.size
|
||||
) {
|
||||
const meta = await this.#adapter.getObjectMeta(name);
|
||||
if (!meta) {
|
||||
throw new Error("Failed to get object meta");
|
||||
|
||||
@@ -11,12 +11,14 @@ export async function adapterTestSuite(
|
||||
retries?: number;
|
||||
retryTimeout?: number;
|
||||
skipExistsAfterDelete?: boolean;
|
||||
testRange?: boolean;
|
||||
},
|
||||
) {
|
||||
const { test, expect } = testRunner;
|
||||
const options = {
|
||||
retries: opts?.retries ?? 1,
|
||||
retryTimeout: opts?.retryTimeout ?? 1000,
|
||||
testRange: opts?.testRange ?? true,
|
||||
};
|
||||
|
||||
let objects = 0;
|
||||
@@ -53,9 +55,34 @@ export async function adapterTestSuite(
|
||||
await test("gets an object", async () => {
|
||||
const res = await adapter.getObject(filename, new Headers());
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
// @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 () => {
|
||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||
type: file.type, // image/png
|
||||
|
||||
@@ -3,7 +3,7 @@ export const Q = {
|
||||
audio: ["ogg"],
|
||||
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"],
|
||||
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
|
||||
application: ["zip", "xml", "toml", "json", "json5", "pdf"],
|
||||
application: ["zip", "toml", "json", "json5", "pdf", "xml"],
|
||||
font: ["woff", "woff2", "ttf", "otf"],
|
||||
} as const;
|
||||
|
||||
@@ -15,10 +15,12 @@ const c = {
|
||||
a: (w = "octet-stream") => `application/${w}`,
|
||||
i: (w) => `image/${w}`,
|
||||
v: (w) => `video/${w}`,
|
||||
au: (w) => `audio/${w}`,
|
||||
} as const;
|
||||
export const M = new Map<string, string>([
|
||||
["7z", c.z],
|
||||
["7zip", c.z],
|
||||
["txt", c.t()],
|
||||
["ai", c.a("postscript")],
|
||||
["apk", c.a("vnd.android.package-archive")],
|
||||
["doc", c.a("msword")],
|
||||
@@ -32,12 +34,12 @@ export const M = new Map<string, string>([
|
||||
["jpg", c.i("jpeg")],
|
||||
["js", c.t("javascript")],
|
||||
["log", c.t()],
|
||||
["m3u", c.t()],
|
||||
["m3u", c.au("x-mpegurl")],
|
||||
["m3u8", c.a("vnd.apple.mpegurl")],
|
||||
["manifest", c.t("cache-manifest")],
|
||||
["md", c.t("markdown")],
|
||||
["mkv", c.v("x-matroska")],
|
||||
["mp3", c.a("mpeg")],
|
||||
["mp3", c.au("mpeg")],
|
||||
["mobi", c.a("x-mobipocket-ebook")],
|
||||
["ppt", c.a("powerpoint")],
|
||||
["pptx", `${c.vnd}.presentationml.presentation`],
|
||||
@@ -46,11 +48,10 @@ export const M = new Map<string, string>([
|
||||
["tif", c.i("tiff")],
|
||||
["tsv", c.t("tab-separated-values")],
|
||||
["tgz", c.a("x-tar")],
|
||||
["txt", c.t()],
|
||||
["text", c.t()],
|
||||
["vcd", c.a("x-cdlink")],
|
||||
["vcs", c.t("x-vcalendar")],
|
||||
["wav", c.a("x-wav")],
|
||||
["wav", c.au("vnd.wav")],
|
||||
["webmanifest", c.a("manifest+json")],
|
||||
["xls", c.a("vnd.ms-excel")],
|
||||
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -169,9 +173,14 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.log("not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
console.log("not allowed mimetype", spec.type);
|
||||
return allowedMimeTypes.includes(spec.type);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,7 +368,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 +391,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 +422,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],
|
||||
);
|
||||
|
||||
@@ -424,6 +437,7 @@ export function Dropzone({
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
accept: allowedMimeTypes?.join(","),
|
||||
},
|
||||
showPlaceholder,
|
||||
actions: {
|
||||
@@ -437,11 +451,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 (
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import {
|
||||
TbDots,
|
||||
TbExternalLink,
|
||||
TbFileTypeCsv,
|
||||
TbFileText,
|
||||
TbJson,
|
||||
TbFileTypePdf,
|
||||
TbMarkdown,
|
||||
TbMusic,
|
||||
TbTrash,
|
||||
TbUpload,
|
||||
TbFileTypeTxt,
|
||||
TbFileTypeXml,
|
||||
TbZip,
|
||||
TbFileTypeSql,
|
||||
} from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
@@ -22,7 +37,7 @@ export const DropzoneInner = ({
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
@@ -85,7 +100,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
|
||||
type ReducedFile = Omit<FileState, "state" | "progress">;
|
||||
export type PreviewComponentProps = {
|
||||
file: ReducedFile;
|
||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||
@@ -159,9 +174,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>
|
||||
@@ -271,6 +286,59 @@ const VideoPreview = ({
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const Previews = [
|
||||
{
|
||||
mime: "text/plain",
|
||||
Icon: TbFileTypeTxt,
|
||||
},
|
||||
{
|
||||
mime: "text/csv",
|
||||
Icon: TbFileTypeCsv,
|
||||
},
|
||||
{
|
||||
mime: /(text|application)\/xml/,
|
||||
Icon: TbFileTypeXml,
|
||||
},
|
||||
{
|
||||
mime: "text/markdown",
|
||||
Icon: TbMarkdown,
|
||||
},
|
||||
{
|
||||
mime: /^text\/.*$/,
|
||||
Icon: TbFileText,
|
||||
},
|
||||
{
|
||||
mime: "application/json",
|
||||
Icon: TbJson,
|
||||
},
|
||||
{
|
||||
mime: "application/pdf",
|
||||
Icon: TbFileTypePdf,
|
||||
},
|
||||
{
|
||||
mime: /^audio\/.*$/,
|
||||
Icon: TbMusic,
|
||||
},
|
||||
{
|
||||
mime: "application/zip",
|
||||
Icon: TbZip,
|
||||
},
|
||||
{
|
||||
mime: "application/sql",
|
||||
Icon: TbFileTypeSql,
|
||||
},
|
||||
];
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
const previewIcon = Previews.find((p) =>
|
||||
p.mime instanceof RegExp ? p.mime.test(file.type) : p.mime === file.type,
|
||||
);
|
||||
if (previewIcon) {
|
||||
return <previewIcon.Icon className="size-10 text-gray-400" />;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-primary/50 text-center font-mono leading-none max-w-[90%] truncate">
|
||||
{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";
|
||||
@@ -43,7 +43,7 @@ export function MediaInfoModal({
|
||||
|
||||
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">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-50">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
<div className="w-full md:!w-[300px] flex flex-col">
|
||||
@@ -156,12 +156,38 @@ const Item = ({
|
||||
);
|
||||
};
|
||||
|
||||
const textFormats = [/^text\/.*$/, /application\/(x\-)?(json|json|yaml|javascript|xml|rtf|sql)/];
|
||||
|
||||
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 (textFormats.some((f) => f.test(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 +195,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">
|
||||
{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