fix multi-file upload

This commit is contained in:
rizky 2024-08-09 22:52:51 -07:00
parent 2122f74cc3
commit b802de4505
8 changed files with 391 additions and 57 deletions

View File

@ -1,12 +1,38 @@
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
export const FilePreview = ({ url }: { url: string }) => { export const FilePreview = ({
url,
variant,
}: {
url: string;
variant?: "thumb";
}) => {
const file = getFileName(url); const file = getFileName(url);
if (typeof file === "string")
return (
<div
className={cx(
css`
border-radius: 3px;
padding: 0px 5px;
height: 20px;
margin-right: 5px;
height: 20px;
border: 1px solid #ccc;
background: white;
`,
"c-flex c-items-center c-text-sm"
)}
>
{file}
</div>
);
const color = darkenColor(generateRandomColor(file.extension)); const color = darkenColor(generateRandomColor(file.extension));
let content = ( let content = (
<div <div
className={cx( className={cx(
css` css`
background: white;
border: 1px solid ${color}; border: 1px solid ${color};
color: ${color}; color: ${color};
border-radius: 3px; border-radius: 3px;
@ -22,12 +48,46 @@ export const FilePreview = ({ url }: { url: string }) => {
{file.extension} {file.extension}
</div> </div>
); );
if (variant === "thumb") {
content = (
<div
className={cx(
css`
background: white;
color: ${color};
border: 1px solid ${color};
color: ${color};
border-radius: 3px;
text-transform: uppercase;
font-size: 16px;
font-weight: black;
padding: 3px 7px;
margin-left: 5px;
height: 30px;
`,
"c-flex c-items-center"
)}
>
{file.extension}
<div className="c-ml-1">
<ExternalLink size="12px" />
</div>
</div>
);
}
if (url.startsWith("_file/")) { if (url.startsWith("_file/")) {
if ([".png", ".jpeg", ".jpg", ".webp"].find((e) => url.endsWith(e))) { if ([".png", ".jpeg", ".jpg", ".webp"].find((e) => url.endsWith(e))) {
content = ( content = (
<img <img
className="c-py-1 c-rounded-md" className="c-py-1 c-rounded-md"
src={siteurl(`/_img/${url.substring("_file/".length)}?w=100&h=20`)} src={siteurl(
`/_img/${url.substring("_file/".length)}?${
variant === "thumb" ? "w=95&h=95" : "w=100&h=20"
}`
)}
/> />
); );
} }
@ -36,16 +96,33 @@ export const FilePreview = ({ url }: { url: string }) => {
<> <>
{file.extension && ( {file.extension && (
<div <div
className="c-flex c-border c-rounded c-items-center c-px-1 c-pr-2 c-bg-white hover:c-bg-blue-50 c-cursor-pointer" className={cx(
"c-flex c-border c-rounded c-items-center c-px-1 c-bg-white c-cursor-pointer",
variant !== "thumb"
? "c-pr-2"
: css`
width: 95px;
max-height: 95px;
min-height: 50px;
`,
css`
&:hover {
border: 1px solid #1c4ed8;
outline: 1px solid #1c4ed8;
}
`
)}
onClick={() => { onClick={() => {
let _url = siteurl(url || ""); let _url = siteurl(url || "");
window.open(_url, "_blank"); window.open(_url, "_blank");
}} }}
> >
{content} {content}
<div className="c-ml-2"> {variant !== "thumb" && (
<ExternalLink size="12px" /> <div className="c-ml-2">
</div> <ExternalLink size="12px" />
</div>
)}
</div> </div>
)} )}
</> </>
@ -90,6 +167,17 @@ function generateRandomColor(str: string): string {
return color; return color;
} }
const getFileName = (url: string) => { const getFileName = (url: string) => {
if (url.startsWith("[")) {
try {
const list = JSON.parse(url);
if (list.length === 0) return "Empty";
return `${list.length} File${list.length > 1 ? "s" : ""}`;
} catch (e) {
console.error(`Error parsing multi-file: ${url}`);
}
return "Unknown File";
}
const fileName = url.substring(url.lastIndexOf("/") + 1); const fileName = url.substring(url.lastIndexOf("/") + 1);
const dotIndex = fileName.lastIndexOf("."); const dotIndex = fileName.lastIndexOf(".");
const fullname = fileName; const fullname = fileName;

View File

@ -63,7 +63,6 @@ export const FieldTypeInput: FC<{
// let value: any = "2024-05-14T05:58:01.376Z" // case untuk date time // let value: any = "2024-05-14T05:58:01.376Z" // case untuk date time
field.input = input; field.input = input;
if (!field.prop) field.prop = prop;
if (["date", "datetime", "datetime-local", "time"].includes(type_field)) { if (["date", "datetime", "datetime-local", "time"].includes(type_field)) {
if (typeof value === "string" || value instanceof Date) { if (typeof value === "string" || value instanceof Date) {
let date = parse(value); let date = parse(value);

View File

@ -1,14 +1,8 @@
import { useLocal } from "@/utils/use-local";
import get from "lodash.get";
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
import { FC } from "react"; import { FC } from "react";
import * as XLSX from "xlsx";
import { FMLocal, FieldLocal, FieldProp } from "../../typings"; import { FMLocal, FieldLocal, FieldProp } from "../../typings";
import { FilePreview } from "./FilePreview";
import { PropTypeInput } from "./TypeInput"; import { PropTypeInput } from "./TypeInput";
const w = window as unknown as { import { FieldUploadMulti } from "./TypeUploadMulti";
serverurl: string; import { FieldUploadSingle } from "./TypeUploadSingle";
};
export const FieldUpload: FC<{ export const FieldUpload: FC<{
field: FieldLocal; field: FieldLocal;
@ -17,7 +11,11 @@ export const FieldUpload: FC<{
styling?: string; styling?: string;
arg: FieldProp; arg: FieldProp;
on_change: (e: any) => void | Promise<void>; on_change: (e: any) => void | Promise<void>;
}> = ({ field, fm, prop, on_change, arg }) => { }> = (pass) => {
console.log(field.prop.upload); const { field, fm, prop, on_change, arg } = pass;
return <></>; let mode = field.prop.upload?.mode || "single-file";
if (mode === "single-file") {
return <FieldUploadSingle {...pass} />;
}
return <FieldUploadMulti {...pass} />;
}; };

View File

@ -0,0 +1,223 @@
import { useLocal } from "@/utils/use-local";
import get from "lodash.get";
import { Trash2, Upload } from "lucide-react";
import { ChangeEvent, FC } from "react";
import { FMLocal, FieldLocal, FieldProp } from "../../typings";
import { PropTypeInput } from "./TypeInput";
import { FilePreview } from "./FilePreview";
import { Spinner } from "lib/comps/ui/field-loading";
const w = window as unknown as {
serverurl: string;
};
export const FieldUploadMulti: FC<{
field: FieldLocal;
fm: FMLocal;
prop: PropTypeInput;
styling?: string;
arg: FieldProp;
on_change: (e: any) => void | Promise<void>;
}> = ({ field, fm, prop, on_change, arg }) => {
let value: string = (fm.data[field.name] || "").trim();
// let type_upload =
const input = useLocal({
value: 0 as any,
display: false as any,
ref: null as any,
drop: false as boolean,
uploading: new Set<File>(),
fase: value ? "preview" : ("start" as "start" | "upload" | "preview"),
style: "inline" as "inline" | "full",
});
const parse_list = () => {
let list: string[] = [];
if (value.startsWith("[")) {
try {
list = JSON.parse(value);
} catch (e) {}
} else if (typeof value === "string" && value) {
list.push(value);
}
return list;
};
const on_upload = async (event: ChangeEvent<HTMLInputElement>) => {
const upload_single = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
let url = siteurl("/_upload");
if (
location.hostname === "prasi.avolut.com" ||
location.host === "localhost:4550"
) {
const newurl = new URL(location.href);
newurl.pathname = `/_proxy/${url}`;
url = newurl.toString();
}
const response = await fetch(url, {
method: "POST",
body: formData,
});
if (response.ok) {
const contentType: any = response.headers.get("content-type");
let result;
if (contentType.includes("application/json")) {
result = await response.json();
} else if (contentType.includes("text/plain")) {
result = await response.text();
} else {
result = await response.blob();
}
if (Array.isArray(result)) {
return `_file${get(result, "[0]")}`;
}
}
throw new Error("Upload Failed");
};
if (event.target.files) {
const list = parse_list();
for (let i = 0; i < event.target.files.length; i++) {
const file = event.target?.files?.item(i);
if (file) {
input.uploading.add(file);
upload_single(file).then((path) => {
input.uploading.delete(file);
list.push(path);
fm.data[field.name] = JSON.stringify(list);
fm.render();
});
}
}
input.render();
}
if (input.ref) {
input.ref.value = null;
}
};
if (isEditor) input.fase = "start";
const list = parse_list();
return (
<div className="c-flex-grow c-flex-col c-flex c-w-full c-h-full c-items-stretch c-p-1">
<div
className={cx(
"c-flex c-flex-row c-flex-wrap",
css`
flex-flow: row wrap;
`
)}
>
{list.map((value, idx) => {
return (
<div
className="c-py-1 c-pr-2"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<div
className={cx(
"c-relative",
css`
.del {
opacity: 0;
}
&:hover {
.del {
opacity: 1;
}
}
`
)}
>
<FilePreview url={value || ""} variant="thumb" />
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (confirm("Remove this file ?")) {
list.splice(idx, 1);
fm.data[field.name] = JSON.stringify(list);
fm.render();
}
}}
className={cx(
"c-flex c-flex-row c-items-center c-px-1 c-rounded c-bg-white c-cursor-pointer hover:c-bg-red-100 c-absolute c-top-0 c-right-0 del transition-all",
css`
border: 1px solid red;
width: 25px;
height: 25px;
margin: 5px;
`
)}
>
<Trash2 className="c-text-red-500 c-h-4 c-w-4 " />
</div>
</div>
</div>
);
})}
{input.uploading.size > 0 && (
<div className="c-flex c-space-x-1 c-p-2 c-border">
<Spinner /> <div>Uploading</div>
</div>
)}
</div>
<div className="c-flex">
<div className={cx("c-flex c-border c-rounded ")}>
<div
className={cx(
"c-flex c-flex-row c-relative c-py-1 c-flex-grow c-pr-2 c-items-center c-cursor-pointer hover:c-bg-blue-50",
css`
input[type="file"],
input[type="file"]::-webkit-file-upload-button {
cursor: pointer;
}
`
)}
>
{!isEditor && (
<input
ref={(ref) => {
if (!input.ref) {
input.ref = ref;
}
}}
type="file"
multiple={true}
accept={field.prop.upload?.accept}
onChange={on_upload}
className={cx(
"c-absolute c-w-full c-h-full c-cursor-pointer c-top-0 c-left-0 c-opacity-0"
)}
/>
)}
<div className="c-items-center c-flex c-text-base c-px-1 c-outline-none c-rounded c-cursor-pointer ">
<div className="c-flex c-flex-row c-items-center c-px-2">
<Upload className="c-h-4 c-w-4" />
</div>
<div className="c-flex c-flex-row c-items-center c-text-sm">
Upload File
</div>
</div>
</div>
</div>
<div
className="c-flex-1"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
></div>
</div>
</div>
);
};

View File

@ -1,7 +1,7 @@
import { useLocal } from "@/utils/use-local"; import { useLocal } from "@/utils/use-local";
import get from "lodash.get"; import get from "lodash.get";
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react"; import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
import { FC } from "react"; import { ChangeEvent, FC } from "react";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import { FMLocal, FieldLocal, FieldProp } from "../../typings"; import { FMLocal, FieldLocal, FieldProp } from "../../typings";
import { FilePreview } from "./FilePreview"; import { FilePreview } from "./FilePreview";
@ -19,7 +19,6 @@ export const FieldUploadSingle: FC<{
on_change: (e: any) => void | Promise<void>; on_change: (e: any) => void | Promise<void>;
}> = ({ field, fm, prop, on_change, arg }) => { }> = ({ field, fm, prop, on_change, arg }) => {
const styling = arg.upload_style ? arg.upload_style : "full"; const styling = arg.upload_style ? arg.upload_style : "full";
let type_field = prop.sub_type;
let value: any = fm.data[field.name]; let value: any = fm.data[field.name];
// let type_upload = // let type_upload =
const input = useLocal({ const input = useLocal({
@ -31,30 +30,46 @@ export const FieldUploadSingle: FC<{
style: "inline" as "inline" | "full", style: "inline" as "inline" | "full",
}); });
const on_upload = async (event: any) => { const on_upload = async (event: ChangeEvent<HTMLInputElement>) => {
let file = null; let file = null;
try { try {
file = event.target.files[0]; file = event.target?.files?.[0];
} catch (ex) {} } catch (ex) {}
if (type_field === "import") { if (prop.model_upload === "import") {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e: any) => { function arrayBufferToBinaryString(buffer: ArrayBuffer): string {
const binaryStr = e.target.result; const bytes = new Uint8Array(buffer);
const workbook = XLSX.read(binaryStr, { type: "binary" }); return String.fromCharCode.apply(null, Array.from(bytes));
}
const worksheet = workbook.Sheets[workbook.SheetNames[0]]; reader.onload = (e: ProgressEvent<FileReader>) => {
const jsonData = XLSX.utils.sheet_to_json(worksheet); if (e.target && e.target.result) {
if (typeof on_change === "function") { const binaryStr =
const res = on_change({ typeof e.target.result === "string"
value: jsonData, ? e.target.result
file: file, : arrayBufferToBinaryString(e.target.result);
binnary: e.target.result, const workbook = XLSX.read(binaryStr, { type: "binary" });
});
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(worksheet);
if (typeof on_change === "function") {
on_change({
value: jsonData,
file: file,
binnary: e.target.result,
});
}
} }
}; };
reader.readAsBinaryString(file); if (file) {
} else { if (typeof reader.readAsArrayBuffer === "function") {
reader.readAsArrayBuffer(file);
} else {
reader.readAsBinaryString(file);
}
}
} else if (file) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
@ -132,6 +147,7 @@ export const FieldUploadSingle: FC<{
ref={(ref) => (input.ref = ref)} ref={(ref) => (input.ref = ref)}
type="file" type="file"
multiple={false} multiple={false}
accept={field.prop.upload?.accept}
onChange={on_upload} onChange={on_upload}
className={cx( className={cx(
"c-absolute c-w-full c-h-full c-cursor-pointer c-top-0 c-left-0 c-opacity-0" "c-absolute c-w-full c-h-full c-cursor-pointer c-top-0 c-left-0 c-opacity-0"
@ -152,7 +168,7 @@ export const FieldUploadSingle: FC<{
<div className="c-flex c-flex-row c-items-center c-px-2"> <div className="c-flex c-flex-row c-items-center c-px-2">
<Upload className="c-h-4 c-w-4" /> <Upload className="c-h-4 c-w-4" />
</div> </div>
<div className="c-flex c-flex-row c-items-center"> <div className="c-flex c-flex-row c-items-center c-text-sm">
Upload File Upload File
</div> </div>
</div> </div>
@ -173,7 +189,7 @@ export const FieldUploadSingle: FC<{
}} }}
className={cx( className={cx(
input.drop ? "c-bg-gray-100" : "", input.drop ? "c-bg-gray-100" : "",
"hover:c-bg-gray-100 c-flex-grow c-m-1 c-relative c-flex-grow c-p-4 c-items-center c-flex c-flex-row c-text-gray-400 c-border c-border-gray-200 c-border-dashed c-rounded c-cursor-pointer" "hover:c-bg-gray-100 c-m-1 c-relative c-flex-grow c-p-4 c-items-center c-flex c-flex-row c-text-gray-400 c-border c-border-gray-200 c-border-dashed c-rounded c-cursor-pointer"
)} )}
> >
<div className="c-flex-row c-flex c-flex-grow c-space-x-2"> <div className="c-flex-row c-flex c-flex-grow c-space-x-2">
@ -217,19 +233,19 @@ export const FieldUploadSingle: FC<{
) : input.fase === "preview" ? ( ) : input.fase === "preview" ? (
<div className="c-flex c-justify-between c-flex-1 c-p-1"> <div className="c-flex c-justify-between c-flex-1 c-p-1">
<FilePreview url={value || ""} /> <FilePreview url={value || ""} />
<div className="c-flex c-flex-row c-items-center c-border c-px-2 c-rounded c-cursor-pointer hover:c-bg-red-100"> <div
<Trash2 onClick={(e) => {
className="c-text-red-500 c-h-4 c-w-4" e.preventDefault();
onClick={(e) => { e.stopPropagation();
e.preventDefault(); if (confirm("Clear this file ?")) {
e.stopPropagation(); input.fase = "start";
if (confirm("Clear this file ?")) { fm.data[field.name] = null;
input.fase = "start"; fm.render();
fm.data[field.name] = null; }
fm.render(); }}
} className="c-flex c-flex-row c-items-center c-border c-px-2 c-rounded c-cursor-pointer hover:c-bg-red-100"
}} >
/> <Trash2 className="c-text-red-500 c-h-4 c-w-4" />
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -48,7 +48,7 @@ export type FieldProp = {
label: string; label: string;
desc?: string; desc?: string;
props?: any; props?: any;
upload?: { mode: "single-file" | "multi-file" }; upload?: { mode: "single-file" | "multi-file"; accept: string };
link: { link: {
text: text:
| string | string
@ -187,7 +187,7 @@ export type FieldInternal<T extends FieldProp["type"]> = {
name: string; name: string;
fm: FMLocal; fm: FMLocal;
}) => void | Promise<void>; }) => void | Promise<void>;
prop?: FieldProp; prop: FieldProp;
max_date?: FieldProp["max_date"]; max_date?: FieldProp["max_date"];
min_date?: FieldProp["min_date"]; min_date?: FieldProp["min_date"];
error?: any; error?: any;

View File

@ -56,7 +56,12 @@ export const formInit = (fm: FMLocal, props: FMProps) => {
<Button <Button
variant={"link"} variant={"link"}
size={"xs"} size={"xs"}
className="c-cursor-pointer" className={cx(
css`
color: gray !important;
`,
"c-cursor-pointer"
)}
onClick={() => { onClick={() => {
const md = fm.deps.md as MDLocal; const md = fm.deps.md as MDLocal;
toast.dismiss(); toast.dismiss();
@ -72,9 +77,14 @@ export const formInit = (fm: FMLocal, props: FMProps) => {
</Button> </Button>
<Button <Button
variant={"default"} variant={"outline"}
className={cx(
css`
color: green;
`,
"c-cursor-pointer"
)}
size={"xs"} size={"xs"}
className="c-cursor-pointer"
onClick={() => { onClick={() => {
const md = fm.deps.md as MDLocal; const md = fm.deps.md as MDLocal;
toast.dismiss(); toast.dismiss();

View File

@ -60,7 +60,7 @@ export const useField = (
field.render(); field.render();
} }
}, []); }, []);
field.prop = arg; field.prop = arg as any;
return field; return field;
}; };