media: added more mime types, added mime type check on dropzone

This commit is contained in:
dswbx
2025-02-22 13:14:32 +01:00
parent 837b0a3d43
commit f801d3a556
8 changed files with 99 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import * as large from "../../src/media/storage/mime-types"; import * as large from "../../src/media/storage/mime-types";
import * as tiny from "../../src/media/storage/mime-types-tiny"; import * as tiny from "../../src/media/storage/mime-types-tiny";
import { getRandomizedFilename } from "../../src/media/utils";
describe("media/mime-types", () => { describe("media/mime-types", () => {
test("tiny resolves", () => { test("tiny resolves", () => {
@@ -36,19 +37,62 @@ describe("media/mime-types", () => {
}); });
test("isMimeType", () => { test("isMimeType", () => {
expect(tiny.isMimeType("image/jpeg")).toBe(true); const tests = [
expect(tiny.isMimeType("image/jpeg", ["image/png"])).toBe(true); ["image/avif", true],
expect(tiny.isMimeType("image/png", ["image/png"])).toBe(false); ["image/AVIF", true],
expect(tiny.isMimeType("image/png")).toBe(true); ["image/jpeg", true],
expect(tiny.isMimeType("whatever")).toBe(false); ["image/jpeg", true, ["image/png"]],
expect(tiny.isMimeType("text/tab-separated-values")).toBe(true); ["image/png", false, ["image/png"]],
["image/png", true],
["image/heif", true],
["image/heic", true],
["image/gif", true],
["whatever", false],
["text/tab-separated-values", true],
["application/zip", true]
];
for (const [mime, expected, exclude] of tests) {
expect(
tiny.isMimeType(mime, exclude as any),
`isMimeType(): ${mime} should be ${expected}`
).toBe(expected as any);
}
}); });
test("extension", () => { test("extension", () => {
expect(tiny.extension("image/png")).toBe("png"); const tests = [
expect(tiny.extension("image/jpeg")).toBe("jpeg"); ["image/avif", "avif"],
expect(tiny.extension("application/zip")).toBe("zip"); ["image/png", "png"],
expect(tiny.extension("text/tab-separated-values")).toBe("tsv"); ["image/PNG", "png"],
expect(tiny.extension("application/zip")).toBe("zip"); ["image/jpeg", "jpeg"],
["application/zip", "zip"],
["text/tab-separated-values", "tsv"],
["application/zip", "zip"]
];
for (const [mime, ext] of tests) {
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
}
});
test("getRandomizedFilename", () => {
const tests = [
["file.txt", "txt"],
["file.TXT", "txt"],
["image.jpg", "jpg"],
["image.avif", "avif"],
["image.heic", "heic"],
["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"]
];
for (const [filename, ext] of tests) {
expect(
getRandomizedFilename(filename).split(".").pop(),
`getRandomizedFilename(): ${filename} should end with ${ext}`
).toBe(ext);
}
}); });
}); });

View File

@@ -1,7 +1,7 @@
export const Q = { export const Q = {
video: ["mp4", "webm"], video: ["mp4", "webm"],
audio: ["ogg"], audio: ["ogg"],
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"], 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"], application: ["zip", "xml", "toml", "json", "json5"],
font: ["woff", "woff2", "ttf", "otf"] font: ["woff", "woff2", "ttf", "otf"]
@@ -76,7 +76,8 @@ export function guess(f: string): string {
} }
} }
export function isMimeType(mime: any, exclude: string[] = []) { export function isMimeType(_mime: any, exclude: string[] = []) {
const mime = _mime.toLowerCase();
if (exclude.includes(mime)) return false; if (exclude.includes(mime)) return false;
// try quick first // try quick first
@@ -96,7 +97,9 @@ export function isMimeType(mime: any, exclude: string[] = []) {
return false; return false;
} }
export function extension(mime: string) { export function extension(_mime: string) {
const mime = _mime.toLowerCase();
for (const [t, e] of Object.entries(Q)) { for (const [t, e] of Object.entries(Q)) {
for (const _e of e) { for (const _e of e) {
if (mime === `${t}/${_e}`) { if (mime === `${t}/${_e}`) {

View File

@@ -5,7 +5,7 @@ export function getExtensionFromName(filename: string): string | undefined {
if (!filename.includes(".")) return; if (!filename.includes(".")) return;
const parts = filename.split("."); const parts = filename.split(".");
return parts[parts.length - 1]; return parts[parts.length - 1]?.toLowerCase();
} }
export function getRandomizedFilename(file: File, length?: number): string; export function getRandomizedFilename(file: File, length?: number): string;

View File

@@ -49,6 +49,7 @@ export type DropzoneProps = {
initialItems?: FileState[]; initialItems?: FileState[];
flow?: "start" | "end"; flow?: "start" | "end";
maxItems?: number; maxItems?: number;
allowedMimeTypes?: string[];
overwrite?: boolean; overwrite?: boolean;
autoUpload?: boolean; autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void; onRejected?: (files: FileWithPath[]) => void;
@@ -75,6 +76,7 @@ export function Dropzone({
handleDelete, handleDelete,
initialItems = [], initialItems = [],
flow = "start", flow = "start",
allowedMimeTypes,
maxItems, maxItems,
overwrite, overwrite,
autoUpload, autoUpload,
@@ -109,8 +111,26 @@ export function Dropzone({
return added > remaining; return added > remaining;
} }
function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean {
const items = Array.isArray(i) ? i : [i];
const specs = items.map((item) => ({
kind: "kind" in item ? item.kind : "file",
type: item.type,
size: "size" in item ? item.size : 0
}));
return specs.every((spec) => {
if (spec.kind !== "file") {
return false;
}
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
});
}
const { isOver, handleFileInputChange, ref } = useDropzone({ const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => { onDropped: (newFiles: FileWithPath[]) => {
if (!isAllowed(newFiles)) return;
let to_drop = 0; let to_drop = 0;
const added = newFiles.length; const added = newFiles.length;
@@ -155,6 +175,11 @@ export function Dropzone({
} }
}, },
onOver: (items) => { onOver: (items) => {
if (!isAllowed(items)) {
setIsOverAccepted(false);
return;
}
const max_reached = isMaxReached(items.length); const max_reached = isMaxReached(items.length);
setIsOverAccepted(!max_reached); setIsOverAccepted(!max_reached);
}, },

View File

@@ -81,7 +81,9 @@ export function DropzoneContainer({
return api.media.deleteFile(file.path); return api.media.deleteFile(file.path);
}); });
const actualItems = (initialItems || $q.data || []) as MediaFieldSchema[]; const actualItems = (initialItems ??
(Array.isArray($q.data) ? $q.data : []) ??
[]) as MediaFieldSchema[];
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl }); const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
const key = id + JSON.stringify(_initialItems); const key = id + JSON.stringify(_initialItems);

View File

@@ -80,10 +80,8 @@ export interface FileWithPath extends File {
export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> { export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> {
if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) { if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) {
return getDataTransferFiles(evt.dataTransfer, evt.type); return getDataTransferFiles(evt.dataTransfer, evt.type);
// biome-ignore lint/style/noUselessElse: not useless
} else if (isChangeEvt(evt)) { } else if (isChangeEvt(evt)) {
return getInputFiles(evt); return getInputFiles(evt);
// biome-ignore lint/style/noUselessElse: not useless
} else if ( } else if (
Array.isArray(evt) && Array.isArray(evt) &&
evt.every((item) => "getFile" in item && typeof item.getFile === "function") evt.every((item) => "getFile" in item && typeof item.getFile === "function")

View File

@@ -3,13 +3,13 @@ import { type FileWithPath, fromEvent } from "./file-selector";
type DropzoneProps = { type DropzoneProps = {
onDropped: (files: FileWithPath[]) => void; onDropped: (files: FileWithPath[]) => void;
onOver?: (items: DataTransferItem[]) => void; onOver?: (items: DataTransferItem[], event: DragEvent) => void;
onLeave?: () => void; onLeave?: () => void;
}; };
const events = { const events = {
enter: ["dragenter", "dragover", "dragstart"], enter: ["dragenter", "dragover", "dragstart"] as const,
leave: ["dragleave", "drop"] leave: ["dragleave", "drop"] as const
}; };
const allEvents = [...events.enter, ...events.leave]; const allEvents = [...events.enter, ...events.leave];
@@ -24,10 +24,10 @@ export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
const toggleHighlight = useCallback(async (e: Event) => { const toggleHighlight = useCallback(async (e: DragEvent) => {
const _isOver = events.enter.includes(e.type); const _isOver = events.enter.includes(e.type as any);
if (onOver && _isOver !== isOver && !onOverCalled.current) { if (onOver && _isOver !== isOver && !onOverCalled.current) {
onOver((await fromEvent(e)) as DataTransferItem[]); onOver((await fromEvent(e)) as DataTransferItem[], e);
onOverCalled.current = true; onOverCalled.current = true;
} }

View File

@@ -6,6 +6,9 @@ export default function DropzoneElementTest() {
<Scrollable> <Scrollable>
<div className="flex flex-col w-full h-full p-4 gap-4"> <div className="flex flex-col w-full h-full p-4 gap-4">
<div> <div>
<b>Dropzone no auto avif only</b>
<Media.Dropzone autoUpload={false} allowedMimeTypes={["image/avif"]} />
<b>Dropzone User Avatar 1 (fully customized)</b> <b>Dropzone User Avatar 1 (fully customized)</b>
<Media.Dropzone <Media.Dropzone
entity={{ name: "users", id: 1, field: "avatar" }} entity={{ name: "users", id: 1, field: "avatar" }}