Moved Uploader to shared-components
All checks were successful
armco-org/shared-components/pipeline/head This commit looks good
All checks were successful
armco-org/shared-components/pipeline/head This commit looks good
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@armco/shared-components",
|
||||
"description": "React Component Lib of independent components that can be utilised by sophisticated ones in @armco/components",
|
||||
"version": "0.0.60",
|
||||
"version": "0.0.61",
|
||||
"type": "module",
|
||||
"author": "Armco (@restruct-corporate-advantage)",
|
||||
"types": "build/types/index.d.ts",
|
||||
|
||||
@@ -6,11 +6,23 @@ set -e
|
||||
|
||||
npm run build
|
||||
cp package.json build/
|
||||
sed -i '' -E 's/"build"/"*"/' build/package.json
|
||||
|
||||
sed -i '' 's#"build/cjs/index.js"#"cjs/index.js"#' build/package.json
|
||||
sed -i '' 's#"build/es/index.js"#"es/index.js"#' build/package.json
|
||||
sed -i '' 's#"build/types/index.d.ts"#"types/index.d.ts"#' build/package.json
|
||||
# Use Node.js for portable package.json normalization
|
||||
PKG_PATH="$(pwd)/build/package.json" node - <<'EOF'
|
||||
const fs = require('fs');
|
||||
const path = process.env.PKG_PATH;
|
||||
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
pkg.private = false;
|
||||
delete pkg.scripts;
|
||||
delete pkg.devDependencies;
|
||||
if (!pkg.files) pkg.files = ['*'];
|
||||
else pkg.files = pkg.files.map(x => x === 'build' ? '*' : x);
|
||||
['main','module','types'].forEach(k => {
|
||||
if (pkg[k]) pkg[k] = pkg[k].replace(/^\.??\/build\//, '').replace(/^build\//, '');
|
||||
});
|
||||
if (pkg.deprecated && typeof pkg.deprecated !== 'string') delete pkg.deprecated;
|
||||
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
|
||||
EOF
|
||||
|
||||
cd build
|
||||
npm pack --pack-destination ~/__Projects__/Common
|
||||
@@ -1,7 +1,7 @@
|
||||
.ar-Drawer {
|
||||
border-right: 1px solid var(--ar-color-layout-border);
|
||||
|
||||
.ar_Drawer__content {
|
||||
.ar-Drawer__content {
|
||||
height: 100%;
|
||||
}
|
||||
&.has-title .ar-Drawer__content {
|
||||
|
||||
38
src/Uploader.component.scss
Executable file
38
src/Uploader.component.scss
Executable file
@@ -0,0 +1,38 @@
|
||||
.ar-Uploader {
|
||||
.ar-Uploader__file-uploader-progress-bar {
|
||||
transition: max-height 0.3s, opacity 0.3s, padding 0.3s, margin-bottom 0.3s, background-color 0.3s;
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
&.show {
|
||||
opacity: 1;
|
||||
max-height: 4rem;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--ar-bg-hover-6);
|
||||
}
|
||||
}
|
||||
|
||||
.ar-Button.loading .pre {
|
||||
animation: rotating 0.5s linear infinite;
|
||||
}
|
||||
|
||||
&:not(.compact):not(.image-rect):not(.image-circle) .ar-Uploader__file-input {
|
||||
transition: border 0.3s ease-in-out;
|
||||
&:has(.ar-Avatar.FAILED) {
|
||||
border-color: var(--ar-color-danger-soft);
|
||||
}
|
||||
border: 1px dashed lightgrey;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--ar-bg-base);
|
||||
}
|
||||
|
||||
.ar-Uploader__upload-prompt-text {
|
||||
color: var(--ar-color-obscure-4);
|
||||
}
|
||||
|
||||
.ar-Uploader__upload-message {
|
||||
color: var(--ar-color-obscure-2);
|
||||
}
|
||||
|
||||
}
|
||||
637
src/Uploader.tsx
Executable file
637
src/Uploader.tsx
Executable file
@@ -0,0 +1,637 @@
|
||||
import {
|
||||
ChangeEvent,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { v4 as uuid } from "uuid"
|
||||
import { NativeTypes } from "react-dnd-html5-backend"
|
||||
import { BaseProps, FunctionType } from "@armco/types"
|
||||
import { API_CONFIG } from "@armco/configs/endpoints"
|
||||
import { ArAlertType } from "@armco/utils"
|
||||
import { useNotification } from "@armco/utils/hooks"
|
||||
import { put } from "@armco/utils/network"
|
||||
import Icon from "@armco/icon"
|
||||
import {
|
||||
ArButtonVariants,
|
||||
ArPopoverSlots,
|
||||
ArProgress,
|
||||
ArSizes,
|
||||
} from "./enums"
|
||||
import { ImageProps } from "./Image"
|
||||
import Droppable from "./Droppable"
|
||||
import Button from "./Button"
|
||||
import Text from "./Text"
|
||||
import ProgressIndicator from "./ProgressIndicator"
|
||||
import Tooltip from "./Tooltip"
|
||||
import Avatar from "./Avatar"
|
||||
import { convertToBytes } from "./utils"
|
||||
import useUpload from "./useUpload"
|
||||
import "./Uploader.component.scss"
|
||||
|
||||
|
||||
export type UploadHandler = (file: File) => Promise<UploadResults>
|
||||
export type UploadResults = {
|
||||
[key: string]: UploadResult
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
file: File | { name: string }
|
||||
imageProps?: Partial<ImageProps>
|
||||
status: ArProgress
|
||||
progress: number
|
||||
type?: string
|
||||
url?: string
|
||||
error?: string
|
||||
httpStatus?: number
|
||||
}
|
||||
|
||||
export interface UploaderProps extends BaseProps {
|
||||
acceptedFileTypes?: string | Array<string>
|
||||
acceptedFileCount?: number
|
||||
afterUpload?: FunctionType
|
||||
allowMultiple?: boolean
|
||||
avatarSize?: ArSizes
|
||||
beforeUpload?: FunctionType
|
||||
disabled?: boolean
|
||||
endpoint?: string
|
||||
errorMessage?: string
|
||||
files?: Array<File | { name: string; url?: string }>
|
||||
fileSizeLimit?: string
|
||||
handler?: UploadHandler
|
||||
message?: string
|
||||
updateEndpoint?: string
|
||||
uploadEntryRenderer?: FunctionType
|
||||
uploadSequential?: boolean
|
||||
onFileSelected: FunctionType
|
||||
variant: "compact" | "regular" | "large" | "image-circle" | "image-rect"
|
||||
uploadButtonText?: string
|
||||
uploadingButtonText?: string
|
||||
}
|
||||
|
||||
|
||||
const UploadedEntry = ({
|
||||
clear,
|
||||
demo,
|
||||
disabled,
|
||||
result,
|
||||
}: {
|
||||
clear: FunctionType
|
||||
demo: boolean
|
||||
disabled: boolean
|
||||
result: UploadResult
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState<boolean>()
|
||||
const [displayed, display] = useState<boolean>()
|
||||
|
||||
useEffect(() => {
|
||||
display(true)
|
||||
}, [])
|
||||
|
||||
const isImage = result.type === "image"
|
||||
const progressRender = (
|
||||
<ProgressIndicator
|
||||
label={result.file.name}
|
||||
preIcon={isImage ? "" : "im.ImAttachment"}
|
||||
progress={result.progress}
|
||||
slot={ArPopoverSlots.ANCHOR}
|
||||
rightEndContent={
|
||||
(hovered || result.status === ArProgress.FAILED) && (
|
||||
<Icon
|
||||
icon="ri.RiDeleteBin6Fill"
|
||||
attributes={{
|
||||
colors: {
|
||||
fillColor:
|
||||
result.status === ArProgress.FAILED ? "#ff4d4f" : "grey",
|
||||
},
|
||||
classes: disabled ? "" : "cursor-pointer",
|
||||
}}
|
||||
events={{
|
||||
onClick: () => {
|
||||
if (!disabled) {
|
||||
display(false)
|
||||
setTimeout(clear, 300)
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
status={result.status}
|
||||
thumbnailRenderer={() => {
|
||||
if (isImage) {
|
||||
return (
|
||||
<Avatar
|
||||
classes="me-3"
|
||||
item={result}
|
||||
size={ArSizes.XSMALL}
|
||||
variant="circle"
|
||||
viewOnly
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const extractError = (errorObj: any) => {
|
||||
if (typeof errorObj === "string") {
|
||||
return errorObj
|
||||
}
|
||||
return errorObj.error || errorObj.message || "Server Error"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ar-Uploader__file-uploader-progress-bar position-relative ${displayed ? " show p-1 border-radius l1 mb-1 cursor-pointer" : " px-1"
|
||||
} ${result.status}`}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{result.status === ArProgress.FAILED ? (
|
||||
<Tooltip demo={demo}>
|
||||
{progressRender}
|
||||
<span slot={ArPopoverSlots.POPOVER}>
|
||||
{extractError(result.error)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
progressRender
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Uploader = memo(
|
||||
(props: UploaderProps): ReactNode => {
|
||||
const {
|
||||
acceptedFileTypes,
|
||||
acceptedFileCount,
|
||||
afterUpload,
|
||||
allowMultiple,
|
||||
beforeUpload,
|
||||
demo,
|
||||
disabled,
|
||||
uploadEntryRenderer,
|
||||
errorMessage: externalErrorMessage = "Failed to upload",
|
||||
endpoint,
|
||||
fileSizeLimit = "5mb",
|
||||
files,
|
||||
handler,
|
||||
message,
|
||||
onFileSelected,
|
||||
avatarSize = ArSizes.REGULAR,
|
||||
variant = "compact",
|
||||
theme,
|
||||
updateEndpoint,
|
||||
uploadSequential,
|
||||
uploadButtonText = "Upload",
|
||||
uploadingButtonText = "Uploading...",
|
||||
} = props
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { notify } = useNotification()
|
||||
const [localResults, setLocalResults] = useState<UploadResults>()
|
||||
const [localAllowMultiple, setLocalAllowMultiple] = useState<boolean>()
|
||||
const [localAcceptedFileTypes, setLocalAcceptedFileTypes] = useState<
|
||||
string | Array<string>
|
||||
>()
|
||||
const [errorMessage, setErrorMessage] = useState<string>()
|
||||
const externalFiles = useMemo(
|
||||
() =>
|
||||
Boolean(localAllowMultiple)
|
||||
? acceptedFileCount !== undefined
|
||||
? files?.slice(0, acceptedFileCount)
|
||||
: files
|
||||
: files?.slice(0, 1),
|
||||
[files],
|
||||
)
|
||||
const { clear, upload, results } = useUpload(
|
||||
endpoint || handler || "",
|
||||
uploadSequential,
|
||||
externalFiles,
|
||||
)
|
||||
const isAvatar = variant === "image-circle" || variant === "image-rect"
|
||||
|
||||
useEffect(() => {
|
||||
setLocalResults(results)
|
||||
if ((!results || Object.keys(results).length === 0) && inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
let hasError
|
||||
localResults &&
|
||||
Object.keys(localResults).length > 0 &&
|
||||
Object.keys(localResults).every((resultKey) => {
|
||||
const result = localResults[resultKey]
|
||||
hasError ||= result.status === ArProgress.FAILED
|
||||
return result.status === ArProgress.FAILED
|
||||
})
|
||||
hasError && setErrorMessage(externalErrorMessage)
|
||||
}, [results])
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAllowMultiple(allowMultiple)
|
||||
}, [allowMultiple])
|
||||
|
||||
useEffect(() => {
|
||||
setLocalAcceptedFileTypes(acceptedFileTypes)
|
||||
}, [acceptedFileTypes])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAvatar) {
|
||||
setLocalAllowMultiple(false)
|
||||
setLocalAcceptedFileTypes([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
])
|
||||
}
|
||||
}, [isAvatar])
|
||||
|
||||
const checkAndUpload = (files: Array<File>) => {
|
||||
const sizeLimit = convertToBytes(fileSizeLimit)
|
||||
let maxFileCount
|
||||
try {
|
||||
maxFileCount =
|
||||
acceptedFileCount !== undefined &&
|
||||
(typeof acceptedFileCount === "number"
|
||||
? acceptedFileCount
|
||||
: parseInt(acceptedFileCount))
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"Incorrect prop acceptedFileCount supplied, should be a numeric value",
|
||||
)
|
||||
}
|
||||
if (
|
||||
typeof maxFileCount === "number" &&
|
||||
files &&
|
||||
files.length + Object.keys(localResults || {}).length > maxFileCount
|
||||
) {
|
||||
setErrorMessage("Exceeded allowed number of files.")
|
||||
notify({
|
||||
message: "You've selected more then allowed number of files",
|
||||
type: ArAlertType.WARNING,
|
||||
uid: uuid(),
|
||||
})
|
||||
} else if (files.findIndex((file) => file.size > sizeLimit) > -1) {
|
||||
const message = `${localAllowMultiple
|
||||
? "One or more files exceed "
|
||||
: "Selected file exceeds "
|
||||
}specified size limit of ${fileSizeLimit}`
|
||||
setErrorMessage(message)
|
||||
notify({
|
||||
message,
|
||||
type: ArAlertType.WARNING,
|
||||
uid: uuid(),
|
||||
})
|
||||
} else if (files && files.length > 0) {
|
||||
upload(files, afterUpload, localAllowMultiple)
|
||||
onFileSelected && onFileSelected(files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement> | any) => {
|
||||
setErrorMessage("")
|
||||
const files: Array<File> = Array.from(
|
||||
(event.target || event.dataTransfer)?.files,
|
||||
)
|
||||
if (!beforeUpload || beforeUpload(files, () => checkAndUpload(files))) {
|
||||
checkAndUpload(files)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading =
|
||||
localResults &&
|
||||
Object.keys(localResults).findIndex((resultKey) => {
|
||||
const result = localResults[resultKey]
|
||||
return result.status === ArProgress.IN_PROGRESS
|
||||
}) > -1
|
||||
|
||||
const allCompleted = !!(
|
||||
localResults &&
|
||||
Object.keys(localResults).length > 0 &&
|
||||
Object.keys(localResults).every((resultKey) => {
|
||||
const result = localResults[resultKey]
|
||||
return (
|
||||
result.status === ArProgress.COMPLETED ||
|
||||
result.status === ArProgress.FAILED
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const onlyResult =
|
||||
localResults && localResults[Object.keys(localResults)[0]]
|
||||
|
||||
const onImageChange = (imageProps: Partial<ImageProps>) => {
|
||||
if (localResults && onlyResult) {
|
||||
const fileKey = Object.keys(localResults).find((key) =>
|
||||
key.startsWith(onlyResult.file.name),
|
||||
)
|
||||
if (fileKey) {
|
||||
const resultClone = { ...localResults[fileKey] }
|
||||
resultClone.imageProps = imageProps
|
||||
localResults[fileKey] = resultClone
|
||||
setLocalResults({ ...localResults })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onImageCommit = (imageProps: Partial<ImageProps>) => {
|
||||
if (localResults && onlyResult) {
|
||||
const fileKey = Object.keys(localResults).find((key) =>
|
||||
key.startsWith(onlyResult.file.name),
|
||||
)
|
||||
if (fileKey) {
|
||||
const resultClone = localResults[fileKey]
|
||||
let url = updateEndpoint ? updateEndpoint : endpoint
|
||||
if (url) {
|
||||
const environment = process.env.NODE_ENV || "development"
|
||||
const host = url.startsWith("http")
|
||||
? ""
|
||||
: API_CONFIG.STATIC_HOST[environment as keyof object]
|
||||
url = host + url
|
||||
put(url, resultClone).then((res) => {
|
||||
if (res.status === 200) {
|
||||
notify({
|
||||
message: "Image updaetd successfully!",
|
||||
type: ArAlertType.SUCCESS,
|
||||
uid: uuid(),
|
||||
})
|
||||
} else {
|
||||
notify({
|
||||
message: "Failed to update image!",
|
||||
type: ArAlertType.ERROR,
|
||||
uid: uuid(),
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
notify({
|
||||
message: "Missing image update URL",
|
||||
type: ArAlertType.WARNING,
|
||||
uid: uuid(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inputRender = (
|
||||
<div
|
||||
className={`ar-Uploader__file-input flex-v-center${isLoading ? " disabled" : ""
|
||||
}${variant !== "compact" && !isLoading && !disabled
|
||||
? " cursor-pointer"
|
||||
: ""
|
||||
}${variant === "large"
|
||||
? " flex-h-center flex-column p-3"
|
||||
: " justify-content-between"
|
||||
}${variant === "image-circle" ? " border-radius-50" : ""}`}
|
||||
onClick={() =>
|
||||
variant !== "compact" &&
|
||||
!isLoading &&
|
||||
!disabled &&
|
||||
inputRef.current?.click()
|
||||
}
|
||||
>
|
||||
{isAvatar ? (
|
||||
<Avatar
|
||||
clearImage={clear}
|
||||
demo={demo}
|
||||
variant={variant === "image-circle" ? "circle" : "rect"}
|
||||
item={onlyResult}
|
||||
onChange={onImageChange}
|
||||
onCommit={onImageCommit}
|
||||
size={avatarSize}
|
||||
uploadButtonText={uploadButtonText}
|
||||
uploadingButtonText={uploadingButtonText}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="ar-Uploader__file-input__prompt d-flex flex-column">
|
||||
<div
|
||||
className={`d-inline-flex ${variant === "large"
|
||||
? " flex-center flex-column"
|
||||
: "flex-v-center"
|
||||
}`}
|
||||
>
|
||||
{variant === "compact" ? (
|
||||
<>
|
||||
<Button
|
||||
classes={isLoading ? "loading" : ""}
|
||||
content={
|
||||
isLoading ? uploadingButtonText : uploadButtonText
|
||||
}
|
||||
preIcon={
|
||||
isLoading
|
||||
? "ri.RiLoader5Fill"
|
||||
: allCompleted
|
||||
? errorMessage
|
||||
? "fa.FaExclamation"
|
||||
: "fa.FaCheck"
|
||||
: "md.MdOutlineFileUpload"
|
||||
}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
theme={theme}
|
||||
disabled={disabled || isLoading}
|
||||
/>
|
||||
{localResults &&
|
||||
Object.keys(localResults).length > 0 &&
|
||||
variant === "compact" && (
|
||||
<>
|
||||
{localAllowMultiple ? (
|
||||
<span className="fw-bold flex-v-center ms-3">
|
||||
{`(${Object.keys(localResults).length} file${Object.keys(localResults).length > 1 ? "s" : ""
|
||||
})`}
|
||||
</span>
|
||||
) : (
|
||||
<Text
|
||||
classes="fw-bold flex-v-center ms-3 text-decoration-underline"
|
||||
descriptor={{
|
||||
id: "temp-text-id",
|
||||
order: 0,
|
||||
name: "Text",
|
||||
text: localResults[Object.keys(localResults)[0]]
|
||||
.file.name,
|
||||
chunks: {},
|
||||
}}
|
||||
overflowEllipsis
|
||||
overflowTooltip
|
||||
/>
|
||||
)}
|
||||
{
|
||||
<Icon
|
||||
icon="io5.IoClose"
|
||||
attributes={{
|
||||
colors: {
|
||||
fillColor: "red",
|
||||
},
|
||||
classes: `ms-2 ${disabled ? "" : "cursor-pointer"
|
||||
}`,
|
||||
}}
|
||||
events={{
|
||||
onClick: () => {
|
||||
if (disabled) {
|
||||
clear()
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
icon="io5.IoFileTrayOutline"
|
||||
attributes={{
|
||||
colors: {
|
||||
fillColor: "#1677ff",
|
||||
},
|
||||
classes: variant === "large" ? "mb-2" : "me-3",
|
||||
size: variant === "large" ? "3rem" : "1rem",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`ar-Uploader__upload-prompt-text${variant === "large" ? " h5 mb-0" : ""
|
||||
}`}
|
||||
>
|
||||
Click or drag files here to upload
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{message && (
|
||||
<small
|
||||
className={`ar-Uploader__upload-message text-decoration-underline ${variant === "large" ? "fw-bold f3 mt-2 mb-2" : "mt-1"
|
||||
}`}
|
||||
>
|
||||
{message}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="ar-Uploader__file-input__actions">
|
||||
{variant !== "compact" &&
|
||||
localResults &&
|
||||
Object.keys(localResults).length > 0 && (
|
||||
<Button
|
||||
containerClasses={`text-end${variant === "large" ? " mt-3" : ""
|
||||
}`}
|
||||
content="Clear"
|
||||
variant={ArButtonVariants.DANGER}
|
||||
size={
|
||||
variant === "regular" ? ArSizes.XSMALL : ArSizes.SMALL
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
clear()
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = ""
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
className="ar-Uploader__input d-none"
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
ref={inputRef}
|
||||
title="file-uploader"
|
||||
accept={
|
||||
Array.isArray(localAcceptedFileTypes)
|
||||
? localAcceptedFileTypes.join(",")
|
||||
: localAcceptedFileTypes
|
||||
}
|
||||
disabled={disabled}
|
||||
multiple={localAllowMultiple}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const errorRender = errorMessage && (
|
||||
<span className="error my-1 small">{errorMessage}</span>
|
||||
)
|
||||
|
||||
const resultsRender = localResults && variant !== "compact" && (
|
||||
<div className="ar-Uploader__file-upload-progress mt-2">
|
||||
{Object.entries(localResults).map(([fileName, result]) =>
|
||||
uploadEntryRenderer ? (
|
||||
uploadEntryRenderer(result, () => clear(fileName))
|
||||
) : (
|
||||
<UploadedEntry
|
||||
clear={() => clear(fileName)}
|
||||
demo={!!demo}
|
||||
disabled={!!disabled}
|
||||
key={fileName}
|
||||
result={result}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const uploaderRender = (
|
||||
<div
|
||||
className={`ar-Uploader d-flex flex-column${variant ? " " + variant : ""
|
||||
}${isAvatar ? " flex-center" : " w-100"}`}
|
||||
onDragOver={(e: MouseEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
onDragLeave={(e: MouseEvent<HTMLDivElement>) => e.preventDefault()}
|
||||
>
|
||||
{variant !== "compact" ? (
|
||||
<Droppable
|
||||
dropHandler={(e) => {
|
||||
if (inputRef.current && !disabled) {
|
||||
inputRef.current.files = e.dataTransfer.files
|
||||
inputRef.current.dispatchEvent(
|
||||
new Event("change", { bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
}}
|
||||
acceptTypes={[NativeTypes.FILE]}
|
||||
hideHoverEffect={isAvatar || disabled}
|
||||
>
|
||||
{inputRender}
|
||||
</Droppable>
|
||||
) : (
|
||||
inputRender
|
||||
)}
|
||||
{!isAvatar && errorRender}
|
||||
{!isAvatar && resultsRender}
|
||||
</div>
|
||||
)
|
||||
|
||||
return uploaderRender
|
||||
},
|
||||
(prevProps, props) => {
|
||||
return (
|
||||
prevProps.acceptedFileTypes === props.acceptedFileTypes &&
|
||||
prevProps.acceptedFileCount === props.acceptedFileCount &&
|
||||
prevProps.allowMultiple === props.allowMultiple &&
|
||||
prevProps.disabled === props.disabled &&
|
||||
prevProps.errorMessage === props.errorMessage &&
|
||||
prevProps.endpoint === props.endpoint &&
|
||||
prevProps.fileSizeLimit === props.fileSizeLimit &&
|
||||
JSON.stringify(prevProps.files) === JSON.stringify(props.files) &&
|
||||
prevProps.message === props.message &&
|
||||
prevProps.avatarSize === props.avatarSize &&
|
||||
prevProps.variant === props.variant &&
|
||||
prevProps.theme === props.theme &&
|
||||
prevProps.uploadSequential === props.uploadSequential &&
|
||||
prevProps.uploadButtonText === props.uploadButtonText &&
|
||||
prevProps.uploadingButtonText === props.uploadingButtonText
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default Uploader
|
||||
@@ -3,6 +3,8 @@ import "./styles/global.scss"
|
||||
/* PLOP_INJECT_IMPORT */
|
||||
export { default as Avatar } from "./Avatar"
|
||||
export { default as ImageEditor } from "./ImageEditor"
|
||||
export { default as Uploader } from "./Uploader"
|
||||
export { default as useUpload } from "./useUpload"
|
||||
export { default as Anchor } from "./Anchor"
|
||||
export { default as Card } from "./Card"
|
||||
export { default as Component_404 } from "./Component_404"
|
||||
|
||||
214
src/useUpload.ts
Normal file
214
src/useUpload.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// useUpload.ts
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { FunctionType } from "@armco/types"
|
||||
import { upload as httpUpload } from "@armco/utils/network"
|
||||
import { UploadHandler, UploadResults } from "./Uploader"
|
||||
import { ArProgress } from "./enums"
|
||||
import { generateFileKey } from "./utils"
|
||||
|
||||
const SUCCESS_RESULT = {
|
||||
status: ArProgress.COMPLETED,
|
||||
progress: 100,
|
||||
}
|
||||
|
||||
const FAILED_RESULT = {
|
||||
status: ArProgress.FAILED,
|
||||
progress: 0,
|
||||
}
|
||||
|
||||
const useUpload = (
|
||||
endpointOrHandler: string | UploadHandler,
|
||||
isSequential?: boolean,
|
||||
files?: Array<File | { name: string; url?: string }>,
|
||||
) => {
|
||||
const [results, setResults] = useState<UploadResults>()
|
||||
const clearActionRef = useRef<{ [key: string]: boolean }>({})
|
||||
const intervalRef = useRef<{ [key: string]: NodeJS.Timeout }>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (files) {
|
||||
const results: UploadResults = {}
|
||||
files?.forEach((file) => {
|
||||
results[generateFileKey(file)] = {
|
||||
...file,
|
||||
progress: 100,
|
||||
status: ArProgress.COMPLETED,
|
||||
file: typeof file === "object" ? file : { name: (file as File).name },
|
||||
}
|
||||
})
|
||||
setResults(results)
|
||||
}
|
||||
}, [files])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = intervalRef.current
|
||||
return () => {
|
||||
Object.keys(interval).forEach((key) => clearInterval(interval[key]))
|
||||
}
|
||||
}, [intervalRef])
|
||||
|
||||
const clear = (fileId?: string) => {
|
||||
if (fileId) {
|
||||
const resultsClone = { ...results }
|
||||
delete resultsClone[fileId]
|
||||
setResults(resultsClone)
|
||||
clearActionRef.current[fileId] = true
|
||||
clearInterval(intervalRef.current[fileId])
|
||||
} else {
|
||||
setResults(undefined)
|
||||
results &&
|
||||
Object.keys(results).forEach(
|
||||
(key) =>
|
||||
results[key].status === ArProgress.IN_PROGRESS &&
|
||||
(clearActionRef.current[key] = true),
|
||||
)
|
||||
Object.keys(intervalRef.current).forEach((key) =>
|
||||
clearInterval(intervalRef.current[key]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const isHandler = typeof endpointOrHandler === "function"
|
||||
const handler = isHandler ? (endpointOrHandler as UploadHandler) : null
|
||||
const fileKey = generateFileKey(file)
|
||||
intervalRef.current[fileKey] = setInterval(() => {
|
||||
setResults((prevResult) => {
|
||||
const prevResultObj = prevResult && prevResult[fileKey as string]
|
||||
if (!prevResultObj || prevResultObj.progress >= 95) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current[fileKey])
|
||||
}
|
||||
return prevResult
|
||||
}
|
||||
const increment = Math.floor(Math.random() * 8) + 5
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: {
|
||||
...prevResultObj,
|
||||
progress: prevResultObj.progress + increment,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
|
||||
if (handler) {
|
||||
return await handler(file)
|
||||
.then((res) => {
|
||||
if (!clearActionRef.current[fileKey]) {
|
||||
if (!res.ok) {
|
||||
setResults((prevResult) => {
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: { file, ...FAILED_RESULT },
|
||||
}
|
||||
})
|
||||
}
|
||||
setResults((prevResult) => ({
|
||||
...prevResult,
|
||||
[fileKey]: { file, ...SUCCESS_RESULT },
|
||||
}))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clearActionRef.current[fileKey]) {
|
||||
setResults((prevResult) => {
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: { file, ...FAILED_RESULT },
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current[fileKey])
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return await httpUpload(endpointOrHandler as string, file)
|
||||
.then((response) => {
|
||||
if (!clearActionRef.current[fileKey]) {
|
||||
if (!response.body) {
|
||||
setResults((prevResult) => {
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: {
|
||||
file,
|
||||
...FAILED_RESULT,
|
||||
error: response.body,
|
||||
httpStatus: response.status,
|
||||
},
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const savedRecord = response.body.saved[0]
|
||||
setResults((prevResult) => {
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: { ...(savedRecord || {}), file, ...SUCCESS_RESULT },
|
||||
}
|
||||
})
|
||||
} else {
|
||||
delete clearActionRef.current[fileKey]
|
||||
delete intervalRef.current[fileKey]
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!clearActionRef.current[fileKey]) {
|
||||
setResults((prevResult) => {
|
||||
return {
|
||||
...prevResult,
|
||||
[fileKey]: { file, ...FAILED_RESULT, error },
|
||||
}
|
||||
})
|
||||
} else {
|
||||
delete clearActionRef.current[fileKey]
|
||||
delete intervalRef.current[fileKey]
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current[fileKey])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const upload = async (
|
||||
newFiles: File[],
|
||||
callback?: FunctionType,
|
||||
shouldAppend?: boolean,
|
||||
) => {
|
||||
const newResults: UploadResults = {}
|
||||
newFiles.forEach((file) => {
|
||||
const fileKey = generateFileKey(file)
|
||||
newResults[fileKey] = {
|
||||
file,
|
||||
status: ArProgress.IN_PROGRESS,
|
||||
progress: isSequential ? 1 : Math.floor(Math.random() * 10),
|
||||
}
|
||||
})
|
||||
|
||||
setResults(shouldAppend ? { ...results, ...newResults } : newResults)
|
||||
|
||||
let promises = []
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (isSequential) {
|
||||
promises.push(await uploadFile(file))
|
||||
} else {
|
||||
promises.push(uploadFile(file))
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises).then(
|
||||
(responses) => callback && callback(responses),
|
||||
)
|
||||
}
|
||||
|
||||
return { clear, upload, results }
|
||||
}
|
||||
|
||||
export default useUpload
|
||||
47
src/utils.ts
47
src/utils.ts
@@ -381,4 +381,49 @@ const applyTransformations = (
|
||||
ctx.translate(translateX, translateY)
|
||||
}
|
||||
}
|
||||
// ======= IMAGE EDITOR UTILS END ======= //
|
||||
// ======= IMAGE EDITOR UTILS END ======= //
|
||||
// ======= UPLOADER UTILS ======= //
|
||||
const validImageMimeTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/x-icon",
|
||||
]
|
||||
|
||||
export function generateFileKey(file: File | { name: string }) {
|
||||
return "size" in file
|
||||
? `${file.name}_${file.size}_${file.type}_${file.lastModified}`
|
||||
: file.name + "-preexisting-" + Date.now()
|
||||
}
|
||||
|
||||
export function convertToBytes(input: string): number {
|
||||
const trimmedInput = input.trim().toLowerCase()
|
||||
|
||||
try {
|
||||
if (trimmedInput.endsWith("kb")) {
|
||||
const number = parseFloat(trimmedInput.slice(0, -2))
|
||||
return number * 1024
|
||||
} else if (trimmedInput.endsWith("mb")) {
|
||||
const number = parseFloat(trimmedInput.slice(0, -2))
|
||||
return number * 1024 * 1024
|
||||
} else if (trimmedInput.endsWith("bytes")) {
|
||||
const number = parseFloat(trimmedInput.slice(0, -5))
|
||||
return number
|
||||
} else if (!isNaN(parseFloat(trimmedInput))) {
|
||||
return parseFloat(trimmedInput)
|
||||
} else {
|
||||
return -1
|
||||
}
|
||||
} catch (error) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
export function isImage(type: string) {
|
||||
return !!type && validImageMimeTypes.indexOf(type) > -1
|
||||
}
|
||||
// ======= UPLOADER UTILS END ======= //
|
||||
Reference in New Issue
Block a user