public commit

This commit is contained in:
dswbx
2024-11-16 12:01:47 +01:00
commit 90f80c4280
582 changed files with 49291 additions and 0 deletions

View File

@@ -0,0 +1,413 @@
import {
type ComponentPropsWithRef,
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;
showPlaceholder: boolean;
};
actions: {
uploadFileProgress: (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;
autoUpload?: boolean;
placeholder?: {
show?: boolean;
text?: string;
};
};
export function Dropzone({
getUploadInfo,
handleDelete,
initialItems = [],
maxItems,
autoUpload,
placeholder
}: DropzoneProps) {
const [files, setFiles] = useState<FileState[]>(initialItems);
const [uploading, setUploading] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => {
if (maxItems && files.length + newFiles.length > maxItems) {
alert("Max items reached");
return;
}
console.log("files", newFiles);
setFiles((prev) => {
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) => {
if (maxItems && files.length + items.length >= maxItems) {
// indicate that the drop is not allowed
return;
}
}
/*onOver: (items) =>
console.log(
"onOver",
items,
items.map((i) => [i.kind, i.type].join(":"))
)*/
});
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();
xhr.open(method, url, 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");
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);
}
break;
}
}
const openFileInput = () => inputRef.current?.click();
const showPlaceholder = Boolean(
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
);
const Component = DropzoneInner;
return (
<Component
wrapperRef={ref}
inputProps={{
ref: inputRef,
type: "file",
multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange
}}
state={{ files, isOver, showPlaceholder }}
actions={{ uploadFileProgress, deleteFile, openFileInput }}
dropzoneProps={{ maxItems, placeholder, autoUpload }}
/>
);
}
const DropzoneInner = ({
wrapperRef,
inputProps,
state: { files, isOver, showPlaceholder },
actions: { uploadFileProgress, deleteFile, openFileInput },
dropzoneProps: { placeholder }
}: DropzoneRenderProps) => {
return (
<div
ref={wrapperRef}
/*data-drag-over={"1"}*/
data-drag-over={isOver ? "1" : undefined}
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none"
>
<div className="hidden">
<input
{...inputProps}
/*ref={inputRef}
type="file"
multiple={!maxItems || maxItems > 1}
onChange={handleFileInputChange}*/
/>
</div>
<div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
{files.map((file, i) => (
<Preview
key={file.path}
file={file}
handleUpload={uploadFileProgress}
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>
);
};
const Wrapper = ({ file }: { file: FileState }) => {
if (file.type.startsWith("image/")) {
return <ImagePreview file={file} />;
}
if (file.type.startsWith("video/")) {
return <VideoPreview file={file} />;
}
return <FallbackPreview file={file} />;
};
const WrapperMemoized = 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"
)}
>
{/*{file.state}*/}
<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">
<WrapperMemoized file={file} />
</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 }: { file: FileState }) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <img className="max-w-full max-h-full" src={objectUrl} />;
};
const VideoPreview = ({ file }: { file: FileState }) => {
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
return <video src={objectUrl} />;
};
const FallbackPreview = ({ file }: { file: FileState }) => {
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
};

View File

@@ -0,0 +1,262 @@
/**
* 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

@@ -0,0 +1,79 @@
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;
};
const events = {
enter: ["dragenter", "dragover", "dragstart"],
leave: ["dragleave", "drop"],
};
const allEvents = [...events.enter, ...events.leave];
export function useDropzone({ onDropped, onOver }: 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;
}
}, []);
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

@@ -0,0 +1,31 @@
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));
}