optimize elements by reducing the bundle size required

This commit is contained in:
dswbx
2025-01-14 14:10:19 +01:00
parent c7bd0a636b
commit 4ee0f74cdb
30 changed files with 373 additions and 281 deletions

View File

@@ -1,128 +0,0 @@
import type { ValueError } from "@sinclair/typebox/value";
import type { AppAuthOAuthStrategy, AppAuthSchema } from "auth/auth-schema";
import { type TSchema, Type, Value } from "core/utils";
import { Form, type Validator } from "json-schema-form-react";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { twMerge } from "tailwind-merge";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy";
import { SocialLink } from "ui/modules/auth/SocialLink";
export type LoginFormProps = Omit<ComponentPropsWithoutRef<"form">, "onSubmit" | "action"> & {
className?: string;
formData?: any;
action: "login" | "register";
method?: "POST" | "GET";
auth?: Partial<Pick<AppAuthSchema, "basepath" | "strategies">>;
buttonLabel?: string;
};
class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) {
return Value.Check(schema, data) ? [] : [...Value.Errors(schema, data)];
}
}
const validator = new TypeboxValidator();
const schema = Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
}),
password: Type.String({
minLength: 8 // @todo: this should be configurable
})
});
export function AuthForm({
formData,
className,
method = "POST",
action,
auth,
buttonLabel = action === "login" ? "Sign in" : "Sign up",
...props
}: LoginFormProps) {
const basepath = auth?.basepath ?? "/api/auth";
const password = {
action: `${basepath}/password/${action}`,
strategy: auth?.strategies?.password ?? ({ type: "password" } as const)
};
const oauth = transform(
auth?.strategies ?? {},
(result, value, key) => {
if (value.type !== "password") {
result[key] = value.config;
}
},
{}
) as Record<string, AppAuthOAuthStrategy>;
const has_oauth = Object.keys(oauth).length > 0;
return (
<div className="flex flex-col gap-4 w-full">
{has_oauth && (
<>
<div>
{Object.entries(oauth)?.map(([name, oauth], key) => (
<SocialLink
provider={name}
method={method}
basepath={basepath}
key={key}
action={action}
/>
))}
</div>
<Or />
</>
)}
<Form
method={method}
action={password.action}
{...props}
schema={schema}
validator={validator}
validationMode="change"
className={twMerge("flex flex-col gap-3 w-full", className)}
>
{({ errors, submitting }) => (
<>
<Group>
<Label htmlFor="email">Email address</Label>
<Input type="email" name="email" />
</Group>
<Group>
<Label htmlFor="password">Password</Label>
<Input type="password" name="password" />
</Group>
<Button
type="submit"
variant="primary"
size="large"
className="w-full mt-2 justify-center"
disabled={errors.length > 0 || submitting}
>
{buttonLabel}
</Button>
</>
)}
</Form>
</div>
);
}
const Or = () => (
<div className="w-full flex flex-row items-center">
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
<div className="mx-5">or</div>
<div className="relative flex grow">
<div className="h-px bg-primary/10 w-full absolute top-[50%] z-0" />
</div>
</div>
);

View File

@@ -1,41 +0,0 @@
import type { ReactNode } from "react";
import { useAuthStrategies } from "ui/client/schema/auth/use-auth";
import { Logo } from "ui/components/display/Logo";
import { Link } from "ui/components/wouter/Link";
import { AuthForm } from "ui/modules/auth/AuthForm";
export type AuthScreenProps = {
method?: "POST" | "GET";
action?: "login" | "register";
logo?: ReactNode;
intro?: ReactNode;
};
export function AuthScreen({ method = "POST", action = "login", logo, intro }: AuthScreenProps) {
const { strategies, basepath, loading } = useAuthStrategies();
return (
<div className="flex flex-1 flex-col select-none h-dvh w-dvw justify-center items-center bknd-admin">
{!loading && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{typeof logo !== "undefined" ? (
logo
) : (
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
)}
{typeof intro !== "undefined" ? (
intro
) : (
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}
<AuthForm auth={{ basepath, strategies }} method={method} action={action} />
</div>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import type { ReactNode } from "react";
import { Button } from "ui/components/buttons/Button";
import type { IconType } from "ui/components/buttons/IconButton";
export type SocialLinkProps = {
label?: string;
provider: string;
icon?: IconType;
action: "login" | "register";
method?: "GET" | "POST";
basepath?: string;
children?: ReactNode;
};
export function SocialLink({
label,
provider,
icon,
action,
method = "POST",
basepath = "/api/auth",
children
}: SocialLinkProps) {
return (
<form method={method} action={[basepath, name, action].join("/")} className="w-full">
<Button type="submit" size="large" variant="outline" className="justify-center w-full">
Continue with {label ?? ucFirstAllSnakeToPascalWithSpaces(provider)}
</Button>
{children}
</form>
);
}

View File

@@ -1,9 +0,0 @@
import { AuthForm } from "ui/modules/auth/AuthForm";
import { AuthScreen } from "ui/modules/auth/AuthScreen";
import { SocialLink } from "ui/modules/auth/SocialLink";
export const Auth = {
Screen: AuthScreen,
Form: AuthForm,
SocialLink: SocialLink
};

View File

@@ -1,494 +0,0 @@
import {
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type RefObject,
memo,
useEffect,
useRef,
useState
} from "react";
import { TbDots } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton";
import { Dropdown } from "ui/components/overlay/Dropdown";
import { type FileWithPath, useDropzone } from "./use-dropzone";
export type FileState = {
body: FileWithPath | string;
path: string;
name: string;
size: number;
type: string;
state: "pending" | "uploading" | "uploaded" | "failed" | "initial" | "deleting";
progress: number;
};
export type DropzoneRenderProps = {
wrapperRef: RefObject<HTMLDivElement>;
inputProps: ComponentPropsWithRef<"input">;
state: {
files: FileState[];
isOver: boolean;
isOverAccepted: boolean;
showPlaceholder: boolean;
};
actions: {
uploadFile: (file: FileState) => Promise<void>;
deleteFile: (file: FileState) => Promise<void>;
openFileInput: () => void;
};
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload">;
};
export type DropzoneProps = {
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
handleDelete: (file: FileState) => Promise<boolean>;
initialItems?: FileState[];
maxItems?: number;
overwrite?: boolean;
autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void;
onDeleted?: (file: FileState) => void;
onUploaded?: (file: FileState) => void;
placeholder?: {
show?: boolean;
text?: string;
};
children?: (props: DropzoneRenderProps) => JSX.Element;
};
export function Dropzone({
getUploadInfo,
handleDelete,
initialItems = [],
maxItems,
overwrite,
autoUpload,
placeholder,
onRejected,
onDeleted,
onUploaded,
children
}: DropzoneProps) {
const [files, setFiles] = useState<FileState[]>(initialItems);
const [uploading, setUploading] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [isOverAccepted, setIsOverAccepted] = useState(false);
function isMaxReached(added: number): boolean {
if (!maxItems) {
console.log("maxItems is undefined, never reached");
return false;
}
const current = files.length;
const remaining = maxItems - current;
console.log("isMaxReached", { added, current, remaining, maxItems, overwrite });
// if overwrite is set, but added is bigger than max items
if (overwrite) {
console.log("added > maxItems, stop?", added > maxItems);
return added > maxItems;
}
console.log("remaining > added, stop?", remaining > added);
// or remaining doesn't suffice, stop
return added > remaining;
}
const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => {
let to_drop = 0;
const added = newFiles.length;
if (maxItems) {
if (isMaxReached(added)) {
if (onRejected) {
onRejected(newFiles);
} else {
console.warn("maxItems reached");
}
return;
}
to_drop = added;
}
console.log("files", newFiles, { to_drop });
setFiles((prev) => {
// drop amount calculated
const _prev = prev.slice(to_drop);
// prep new files
const currentPaths = _prev.map((f) => f.path);
const filteredFiles: FileState[] = newFiles
.filter((f) => f.path && !currentPaths.includes(f.path))
.map((f) => ({
body: f,
path: f.path!,
name: f.name,
size: f.size,
type: f.type,
state: "pending",
progress: 0
}));
return [..._prev, ...filteredFiles];
});
if (autoUpload) {
setUploading(true);
}
},
onOver: (items) => {
const max_reached = isMaxReached(items.length);
setIsOverAccepted(!max_reached);
},
onLeave: () => {
setIsOverAccepted(false);
}
});
useEffect(() => {
console.log("files updated");
}, [files]);
useEffect(() => {
if (uploading) {
(async () => {
const pendingFiles = files.filter((f) => f.state === "pending");
if (pendingFiles.length === 0) {
setUploading(false);
return;
} else {
for (const file of pendingFiles) {
await uploadFileProgress(file);
}
}
})();
}
}, [uploading]);
function setFileState(path: string, state: FileState["state"], progress?: number) {
setFiles((prev) =>
prev.map((f) => {
//console.log("compare", f.path, path, f.path === path);
if (f.path === path) {
return {
...f,
state,
progress: progress ?? f.progress
};
}
return f;
})
);
}
function replaceFileState(prevPath: string, newState: Partial<FileState>) {
setFiles((prev) =>
prev.map((f) => {
if (f.path === prevPath) {
return {
...f,
...newState
};
}
return f;
})
);
}
function removeFileFromState(path: string) {
setFiles((prev) => prev.filter((f) => f.path !== path));
}
function uploadFileProgress(file: FileState) {
return new Promise<void>((resolve, reject) => {
if (!file.body) {
console.error("File has no body");
reject();
return;
} else if (file.state !== "pending") {
console.error("File is not pending");
reject();
return;
} else if (file.body instanceof File === false) {
console.error("File body is not a File instance");
reject();
return;
}
const { url, headers, method = "POST" } = getUploadInfo(file.body);
const formData = new FormData();
formData.append("file", file.body);
const xhr = new XMLHttpRequest();
const urlWithParams = new URL(url);
if (overwrite) {
urlWithParams.searchParams.append("overwrite", "1");
}
console.log("url", urlWithParams.toString());
//return;
xhr.open(method, urlWithParams.toString(), true);
if (headers) {
headers.forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
}
// Handle progress events
xhr.upload.addEventListener("progress", (event) => {
console.log("progress", event.loaded, event.total);
if (event.lengthComputable) {
setFileState(file.path, "uploading", event.loaded / event.total);
const percentComplete = (event.loaded / event.total) * 100;
console.log(`Progress: ${percentComplete.toFixed(2)}%`);
} else {
console.log(
"Unable to compute progress information since the total size is unknown"
);
}
});
xhr.onload = () => {
console.log("onload", file.path, xhr.status);
if (xhr.status === 200) {
//setFileState(file.path, "uploaded", 1);
console.log("Upload complete");
onUploaded?.(file);
try {
const response = JSON.parse(xhr.responseText);
console.log("Response:", file, response);
console.log("New state", response.state);
replaceFileState(file.path, {
...response.state,
progress: 1,
state: "uploaded"
});
} catch (e) {
setFileState(file.path, "uploaded", 1);
console.error("Error parsing response", e);
}
resolve();
} else {
setFileState(file.path, "failed", 1);
console.error("Upload failed with status: ", xhr.status);
reject();
}
};
xhr.onerror = () => {
console.error("Error during the upload process.");
};
xhr.onloadstart = () => {
setFileState(file.path, "uploading", 0);
console.log("loadstart");
};
xhr.setRequestHeader("Accept", "application/json");
xhr.send(formData);
});
}
async function deleteFile(file: FileState) {
console.log("deleteFile", file);
switch (file.state) {
case "uploaded":
case "initial":
if (window.confirm("Are you sure you want to delete this file?")) {
console.log('setting state to "deleting"', file);
setFileState(file.path, "deleting");
await handleDelete(file);
removeFileFromState(file.path);
onDeleted?.(file);
}
break;
}
}
const openFileInput = () => inputRef.current?.click();
const showPlaceholder = Boolean(
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
);
const renderProps: DropzoneRenderProps = {
wrapperRef: ref,
inputProps: {
ref: inputRef,
type: "file",
multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange
},
state: {
files,
isOver,
isOverAccepted,
showPlaceholder
},
actions: {
uploadFile: uploadFileProgress,
deleteFile,
openFileInput
},
dropzoneProps: {
maxItems,
placeholder,
autoUpload
}
};
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
}
const DropzoneInner = ({
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { uploadFile, deleteFile, openFileInput },
dropzoneProps: { placeholder }
}: DropzoneRenderProps) => {
return (
<div
ref={wrapperRef}
className={twMerge(
"dropzone w-full h-full align-start flex flex-col select-none",
isOver && isOverAccepted && "bg-green-200/10",
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed"
)}
>
<div className="hidden">
<input {...inputProps} />
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
{files.map((file) => (
<Preview
key={file.path}
file={file}
handleUpload={uploadFile}
handleDelete={deleteFile}
/>
))}
{showPlaceholder && (
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
)}
</div>
</div>
</div>
);
};
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
return (
<div
className="w-[49%] aspect-[1/0.9] md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
onClick={onClick}
>
<span className="">{text}</span>
</div>
);
};
export type PreviewComponentProps = {
file: FileState;
fallback?: (props: { file: FileState }) => JSX.Element;
className?: string;
onClick?: () => void;
onTouchStart?: () => void;
};
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
if (file.type.startsWith("image/")) {
return <ImagePreview {...props} file={file} />;
}
if (file.type.startsWith("video/")) {
return <VideoPreview {...props} file={file} />;
}
return fallback ? fallback({ file }) : null;
};
export const PreviewWrapperMemoized = memo(
Wrapper,
(prev, next) => prev.file.path === next.file.path
);
type PreviewProps = {
file: FileState;
handleUpload: (file: FileState) => Promise<void>;
handleDelete: (file: FileState) => Promise<void>;
};
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => {
const dropdownItems = [
["initial", "uploaded"].includes(file.state) && {
label: "Delete",
onClick: () => handleDelete(file)
},
["initial", "pending"].includes(file.state) && {
label: "Upload",
onClick: () => handleUpload(file)
}
];
return (
<div
className={twMerge(
"w-[49%] md:w-60 flex flex-col border border-muted relative",
file.state === "deleting" && "opacity-70"
)}
>
<div className="absolute top-2 right-2">
<Dropdown items={dropdownItems} position="bottom-end">
<IconButton Icon={TbDots} />
</Dropdown>
</div>
{file.state === "uploading" && (
<div className="absolute w-full top-0 left-0 right-0 h-1">
<div
className="bg-blue-600 h-1 transition-all duration-75"
style={{ width: (file.progress * 100).toFixed(0) + "%" }}
/>
</div>
)}
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
<PreviewWrapperMemoized
file={file}
fallback={FallbackPreview}
className="max-w-full max-h-full"
/>
</div>
<div className="flex flex-col px-1.5 py-1">
<p className="truncate">{file.name}</p>
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
<span className="truncate">{file.type}</span>
<span>{(file.size / 1024).toFixed(1)} KB</span>
</div>
</div>
</div>
);
};
const ImagePreview = ({
file,
...props
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <img {...props} src={objectUrl} />;
};
const VideoPreview = ({
file,
...props
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <video {...props} src={objectUrl} />;
};
const FallbackPreview = ({ file }: { file: FileState }) => {
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
};

View File

@@ -1,98 +0,0 @@
import type { RepoQuery } from "data";
import type { MediaFieldSchema } from "media/AppMedia";
import type { TAppMediaConfig } from "media/media-schema";
import { useId } from "react";
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
import { useEvent } from "ui/hooks/use-event";
import {
Dropzone,
type DropzoneProps,
type DropzoneRenderProps,
type FileState
} from "ui/modules/media/components/dropzone/Dropzone";
import { mediaItemsToFileStates } from "ui/modules/media/helper";
export type DropzoneContainerProps = {
children?: (props: DropzoneRenderProps) => JSX.Element;
initialItems?: MediaFieldSchema[];
entity?: {
name: string;
id: number;
field: string;
};
query?: Partial<RepoQuery>;
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
Partial<DropzoneProps>;
export function DropzoneContainer({
initialItems,
basepath = "/api/media",
storage = {},
entity_name = "media",
entity,
query,
...props
}: DropzoneContainerProps) {
const id = useId();
const baseUrl = useBaseUrl();
const api = useApi();
const invalidate = useInvalidate();
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
const $q = useEntityQuery(
entity_name as "media",
undefined,
{
...query,
limit,
where: entity
? {
reference: `${entity.name}.${entity.field}`,
entity_id: entity.id,
...query?.where
}
: query?.where
},
{ enabled: !initialItems }
);
const getUploadInfo = useEvent((file) => {
const url = entity
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
: api.media.getFileUploadUrl(file);
return {
url,
headers: api.media.getUploadHeaders(),
method: "POST"
};
});
const refresh = useEvent(async () => {
if (entity) {
invalidate((api) => api.data.readOne(entity.name, entity.id));
}
await $q.mutate();
});
const handleDelete = useEvent(async (file: FileState) => {
return api.media.deleteFile(file.path);
});
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
const key = id + JSON.stringify(_initialItems);
return (
<Dropzone
key={id + key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
onUploaded={refresh}
onDeleted={refresh}
autoUpload
initialItems={_initialItems}
{...props}
/>
);
}

View File

@@ -1,262 +0,0 @@
/**
* From https://github.com/react-dropzone/file-selector
* slightly adjusted
* MIT License (2020 Roland Groza)
*/
import { MIME_TYPES } from "media";
const FILES_TO_IGNORE = [
// Thumbnail cache files for macOS and Windows
".DS_Store", // macOs
"Thumbs.db" // Windows
];
export function toFileWithPath(file: FileWithPath, path?: string): FileWithPath {
const f = withMimeType(file);
if (typeof f.path !== "string") {
// on electron, path is already set to the absolute path
const { webkitRelativePath } = file;
Object.defineProperty(f, "path", {
value:
typeof path === "string"
? path
: // If <input webkitdirectory> is set,
// the File will have a {webkitRelativePath} property
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/webkitdirectory
typeof webkitRelativePath === "string" && webkitRelativePath.length > 0
? webkitRelativePath
: file.name,
writable: false,
configurable: false,
enumerable: true
});
}
return f;
}
export interface FileWithPath extends File {
readonly path?: string;
}
function withMimeType(file: FileWithPath) {
const { name } = file;
const hasExtension = name && name.lastIndexOf(".") !== -1;
console.log("withMimeType", name, hasExtension);
if (hasExtension && !file.type) {
const ext = name.split(".").pop()!.toLowerCase();
const type = MIME_TYPES.get(ext);
console.log("withMimeType:in", ext, type);
if (type) {
Object.defineProperty(file, "type", {
value: type,
writable: false,
configurable: false,
enumerable: true
});
}
}
return file;
}
export interface FileWithPath extends File {
readonly path?: string;
}
/**
* Convert a DragEvent's DataTrasfer object to a list of File objects
* NOTE: If some of the items are folders,
* everything will be flattened and placed in the same list but the paths will be kept as a {path} property.
*
* EXPERIMENTAL: A list of https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle objects can also be passed as an arg
* and a list of File objects will be returned.
*
* @param evt
*/
export async function fromEvent(evt: Event | any): Promise<(FileWithPath | DataTransferItem)[]> {
if (isObject<DragEvent>(evt) && isDataTransfer(evt.dataTransfer)) {
return getDataTransferFiles(evt.dataTransfer, evt.type);
// biome-ignore lint/style/noUselessElse: not useless
} else if (isChangeEvt(evt)) {
return getInputFiles(evt);
// biome-ignore lint/style/noUselessElse: not useless
} else if (
Array.isArray(evt) &&
evt.every((item) => "getFile" in item && typeof item.getFile === "function")
) {
return getFsHandleFiles(evt);
}
return [];
}
function isDataTransfer(value: any): value is DataTransfer {
return isObject(value);
}
function isChangeEvt(value: any): value is Event {
return isObject<Event>(value) && isObject(value.target);
}
function isObject<T>(v: any): v is T {
return typeof v === "object" && v !== null;
}
function getInputFiles(evt: Event) {
return fromList<FileWithPath>((evt.target as HTMLInputElement).files).map((file) =>
toFileWithPath(file)
);
}
// Ee expect each handle to be https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileHandle
async function getFsHandleFiles(handles: any[]) {
const files = await Promise.all(handles.map((h) => h.getFile()));
return files.map((file) => toFileWithPath(file));
}
async function getDataTransferFiles(dt: DataTransfer, type: string) {
// IE11 does not support dataTransfer.items
// See https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items#Browser_compatibility
if (dt.items) {
const items = fromList<DataTransferItem>(dt.items).filter((item) => item.kind === "file");
// According to https://html.spec.whatwg.org/multipage/dnd.html#dndevents,
// only 'dragstart' and 'drop' has access to the data (source node)
if (type !== "drop") {
return items;
}
const files = await Promise.all(items.map(toFilePromises));
return noIgnoredFiles(flatten<FileWithPath>(files));
}
return noIgnoredFiles(fromList<FileWithPath>(dt.files).map((file) => toFileWithPath(file)));
}
function noIgnoredFiles(files: FileWithPath[]) {
return files.filter((file) => FILES_TO_IGNORE.indexOf(file.name) === -1);
}
// IE11 does not support Array.from()
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#Browser_compatibility
// https://developer.mozilla.org/en-US/docs/Web/API/FileList
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItemList
function fromList<T>(items: DataTransferItemList | FileList | null): T[] {
if (items === null) {
return [];
}
const files: any[] = [];
// tslint:disable: prefer-for-of
for (let i = 0; i < items.length; i++) {
const file = items[i];
files.push(file);
}
return files;
}
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem
function toFilePromises(item: DataTransferItem) {
if (typeof item.webkitGetAsEntry !== "function") {
return fromDataTransferItem(item);
}
const entry = item.webkitGetAsEntry();
// Safari supports dropping an image node from a different window and can be retrieved using
// the DataTransferItem.getAsFile() API
// NOTE: FileSystemEntry.file() throws if trying to get the file
if (entry?.isDirectory) {
return fromDirEntry(entry) as any;
}
return fromDataTransferItem(item);
}
function flatten<T>(items: any[]): T[] {
return items.reduce(
(acc, files) => [
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
...acc,
...(Array.isArray(files) ? flatten(files) : [files])
],
[]
);
}
function fromDataTransferItem(item: DataTransferItem) {
const file = item.getAsFile();
if (!file) {
return Promise.reject(`${item} is not a File`);
}
const fwp = toFileWithPath(file);
return Promise.resolve(fwp);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemEntry
async function fromEntry(entry: any) {
return entry.isDirectory ? fromDirEntry(entry) : fromFileEntry(entry);
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry
function fromDirEntry(entry: any) {
const reader = entry.createReader();
return new Promise<FileArray[]>((resolve, reject) => {
const entries: Promise<FileValue[]>[] = [];
function readEntries() {
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryEntry/createReader
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryReader/readEntries
reader.readEntries(
async (batch: any[]) => {
if (!batch.length) {
// Done reading directory
try {
const files = await Promise.all(entries);
resolve(files);
} catch (err) {
reject(err);
}
} else {
const items = Promise.all(batch.map(fromEntry));
entries.push(items);
// Continue reading
readEntries();
}
},
(err: any) => {
reject(err);
}
);
}
readEntries();
});
}
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileEntry
async function fromFileEntry(entry: any) {
return new Promise<FileWithPath>((resolve, reject) => {
entry.file(
(file: FileWithPath) => {
const fwp = toFileWithPath(file, entry.fullPath);
resolve(fwp);
},
(err: any) => {
reject(err);
}
);
});
}
// Infinite type recursion
// https://github.com/Microsoft/TypeScript/issues/3496#issuecomment-128553540
interface FileArray extends Array<FileValue> {}
type FileValue = FileWithPath | FileArray[];

View File

@@ -1,82 +0,0 @@
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { type FileWithPath, fromEvent } from "./file-selector";
type DropzoneProps = {
onDropped: (files: FileWithPath[]) => void;
onOver?: (items: DataTransferItem[]) => void;
onLeave?: () => void;
};
const events = {
enter: ["dragenter", "dragover", "dragstart"],
leave: ["dragleave", "drop"]
};
const allEvents = [...events.enter, ...events.leave];
export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
const [isOver, setIsOver] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const onOverCalled = useRef(false);
// Prevent default behavior (Prevent file from being opened)
const preventDefaults = useCallback((e: Event) => {
e.preventDefault();
e.stopPropagation();
}, []);
const toggleHighlight = useCallback(async (e: Event) => {
const _isOver = events.enter.includes(e.type);
if (onOver && _isOver !== isOver && !onOverCalled.current) {
onOver((await fromEvent(e)) as DataTransferItem[]);
onOverCalled.current = true;
}
setIsOver(_isOver);
if (_isOver === false && onOverCalled.current) {
onOverCalled.current = false;
onLeave?.();
}
}, []);
const handleDrop = useCallback(
async (e: DragEvent) => {
const files = await fromEvent(e);
onDropped?.(files as any);
onOverCalled.current = false;
},
[onDropped]
);
const handleFileInputChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
const files = await fromEvent(e);
onDropped?.(files as any);
},
[onDropped]
);
useEffect(() => {
const el: HTMLDivElement = ref.current!;
allEvents.forEach((eventName) => {
el.addEventListener(eventName, preventDefaults);
el.addEventListener(eventName, toggleHighlight);
});
// Handle dropped files
el.addEventListener("drop", handleDrop);
return () => {
allEvents.forEach((eventName) => {
el.removeEventListener(eventName, preventDefaults);
el.removeEventListener(eventName, toggleHighlight);
});
el.removeEventListener("drop", handleDrop);
};
}, []);
return { ref, isOver, fromEvent, onDropped, handleFileInputChange };
}
export type { FileWithPath };

View File

@@ -1,31 +0,0 @@
import type { MediaFieldSchema } from "media/AppMedia";
import type { FileState } from "./components/dropzone/Dropzone";
export function mediaItemToFileState(
item: MediaFieldSchema,
options: {
overrides?: Partial<FileState>;
baseUrl?: string;
} = { overrides: {}, baseUrl: "" }
): FileState {
return {
body: `${options.baseUrl}/api/media/file/${item.path}`,
path: item.path,
name: item.path,
size: item.size ?? 0,
type: item.mime_type ?? "",
state: "uploaded",
progress: 0,
...options.overrides
};
}
export function mediaItemsToFileStates(
items: MediaFieldSchema[],
options: {
overrides?: Partial<FileState>;
baseUrl?: string;
} = { overrides: {}, baseUrl: "" }
): FileState[] {
return items.map((item) => mediaItemToFileState(item, options));
}