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";
|
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).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"],
|
["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 });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MediaController } from "./api/MediaController";
|
|||||||
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||||
import { mediaFields } from "./media-entities";
|
import { mediaFields } from "./media-entities";
|
||||||
import * as MediaPermissions from "media/media-permissions";
|
import * as MediaPermissions from "media/media-permissions";
|
||||||
|
import * as DatabaseEvents from "data/events";
|
||||||
|
|
||||||
export type MediaFields = typeof AppMedia.mediaFields;
|
export type MediaFields = typeof AppMedia.mediaFields;
|
||||||
export type MediaFieldSchema = FieldSchema<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" },
|
{ 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() {
|
override getOverwritePaths() {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,22 +71,29 @@ export class Storage implements EmitsEvents {
|
|||||||
|
|
||||||
let info: FileUploadPayload = {
|
let info: FileUploadPayload = {
|
||||||
name,
|
name,
|
||||||
meta: {
|
meta: isFile(file)
|
||||||
size: 0,
|
? {
|
||||||
type: "application/octet-stream",
|
size: file.size,
|
||||||
},
|
type: file.type,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
size: 0,
|
||||||
|
type: "application/octet-stream",
|
||||||
|
},
|
||||||
etag: typeof result === "string" ? result : "",
|
etag: typeof result === "string" ? result : "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// normally only etag is returned
|
||||||
if (typeof result === "object") {
|
if (typeof result === "object") {
|
||||||
info = result;
|
info = result;
|
||||||
} else if (isFile(file)) {
|
|
||||||
info.meta.size = file.size;
|
|
||||||
info.meta.type = file.type;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to get better meta info
|
// 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);
|
const meta = await this.#adapter.getObjectMeta(name);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
throw new Error("Failed to get object meta");
|
throw new Error("Failed to get object meta");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const Q = {
|
|||||||
audio: ["ogg"],
|
audio: ["ogg"],
|
||||||
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"],
|
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"],
|
||||||
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
|
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"],
|
font: ["woff", "woff2", "ttf", "otf"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -169,9 +173,14 @@ export function Dropzone({
|
|||||||
|
|
||||||
return specs.every((spec) => {
|
return specs.every((spec) => {
|
||||||
if (spec.kind !== "file") {
|
if (spec.kind !== "file") {
|
||||||
|
console.log("not a file", spec.kind);
|
||||||
return false;
|
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",
|
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 +391,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 +422,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],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -424,6 +437,7 @@ export function Dropzone({
|
|||||||
type: "file",
|
type: "file",
|
||||||
multiple: !maxItems || maxItems > 1,
|
multiple: !maxItems || maxItems > 1,
|
||||||
onChange: handleFileInputChange,
|
onChange: handleFileInputChange,
|
||||||
|
accept: allowedMimeTypes?.join(","),
|
||||||
},
|
},
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
actions: {
|
actions: {
|
||||||
@@ -437,11 +451,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 (
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
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 { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||||
import { IconButton } from "ui/components/buttons/IconButton";
|
import { IconButton } from "ui/components/buttons/IconButton";
|
||||||
import { formatNumber } from "core/utils";
|
import { formatNumber } from "core/utils";
|
||||||
@@ -22,7 +37,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) => {
|
||||||
@@ -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 = {
|
export type PreviewComponentProps = {
|
||||||
file: ReducedFile;
|
file: ReducedFile;
|
||||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||||
@@ -159,9 +174,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>
|
||||||
@@ -271,6 +286,59 @@ const VideoPreview = ({
|
|||||||
return <video {...props} src={objectUrl} />;
|
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 }) => {
|
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,
|
: 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";
|
||||||
@@ -43,7 +43,7 @@ export function MediaInfoModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row">
|
<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} />
|
<FilePreview file={file} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:!w-[300px] flex flex-col">
|
<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 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 (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 (
|
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 +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.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