Major reshuffle post demodularization

This commit is contained in:
2025-11-12 10:40:37 +05:30
commit bca59aa7d1
16 changed files with 8759 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Swap the comments on the following lines if you don't wish to use zero-installs
# Documentation here: https://yarnpkg.com/features/zero-installs
!.yarn/cache
#.pnp.*
# Testing
coverage
# Production
build
# Miscellaneous
*.local
.DS_Store

6
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,6 @@
@Library('jenkins-shared') _
kanikoPipeline(
repoName: 'uploader',
branch: env.BRANCH_NAME ?: 'main',
isNpmLib: true
)

50
README.md Normal file
View File

@@ -0,0 +1,50 @@
# Armco Template for the tech stack: React, TS, Dart Sass, Redux Tookkit, react-redux, react browser routing, TS based plop generator
# Armco Uploader Component
## Overview
A flexible React/TypeScript file uploader supporting images and arbitrary files, with progress tracking, error handling, and avatar/image preview. Integrates with custom endpoints or upload handlers, and supports sequential or parallel uploads.
## Source Files
- **Uploader.tsx**: Main component. Handles file selection, validation, upload logic, progress display, error messages, avatar/image preview, and custom entry rendering. Uses hooks and memoization for performance.
- **useUpload.ts**: Custom React hook for managing upload state, progress, results, and clearing files. Supports both endpoint-based and handler-based uploads, with sequential/parallel logic and progress simulation.
- **utils.ts**: Utility functions for file key generation, image type detection, and byte conversion (e.g., "5mb" → bytes).
- **types.ts**: TypeScript interfaces for uploader props, upload results, file metadata, and handler signatures. Integrates with shared types from `@armco/types` and `@armco/shared-components`.
## Key Features
- **File Selection**: Accepts single or multiple files, with configurable type and count limits.
- **Image Preview**: Avatar/image preview for image uploads, with support for circle/rect variants.
- **Progress Tracking**: Shows upload progress, completion, and error states per file.
- **Custom Handlers**: Supports custom upload handlers or HTTP endpoints.
- **Sequential/Parallel Uploads**: Configurable upload mode for batch or stepwise uploads.
- **Error Handling**: Displays error messages for failed uploads, invalid file types, or size limits.
- **Custom Entry Rendering**: Allows custom rendering of uploaded file entries via `uploadEntryRenderer` prop.
- **Clear/Remove Files**: Users can remove files from the upload list, with animated transitions.
## Usage Example
```tsx
import Uploader from "@armco/uploader"
<Uploader
endpoint="/api/upload"
allowMultiple
acceptedFileTypes={["image/jpeg", "image/png"]}
fileSizeLimit="5mb"
onFileSelected={files => console.log(files)}
variant="regular"
/>
```
## API Highlights
- `acceptedFileTypes`: string or array of allowed MIME types
- `acceptedFileCount`: max number of files
- `fileSizeLimit`: max file size (e.g., "5mb")
- `handler` or `endpoint`: custom upload logic or HTTP endpoint
- `uploadSequential`: boolean for sequential uploads
- `uploadEntryRenderer`: custom file entry renderer
- `avatarSize`, `variant`: avatar/image preview options
- `onFileSelected`: callback for file selection
## Development Notes
- Written in TypeScript, React functional and memoized components
- Uses SCSS for styling
- Integrates with shared Armco types and components

7474
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

68
package.json Normal file
View File

@@ -0,0 +1,68 @@
{
"name": "@armco/uploader",
"version": "0.0.5",
"type": "module",
"main": "build/cjs/index.js",
"module": "build/es/index.js",
"types": "build/types/index.d.ts",
"scripts": {
"build": "rm -rf build && tsc && vite build",
"format": "prettier --write .",
"lint": "eslint .",
"publish:sh": "./publish.sh",
"publish:local": "./publish-local.sh"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"uuid": "^9.0.0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"plugins": [
"prettier"
],
"rules": {
"prettier/prettier": "error",
"react/jsx-no-target-blank": "off"
}
},
"prettier": "prettier-config-nick",
"repository": {
"type": "git",
"url": "git+https://gitea.armco.dev/ReStruct-Corporate-Advantage/uploader.git"
},
"keywords": [
"components",
"atomic",
"building-blocks",
"foundation"
],
"license": "ISC",
"bugs": {
"url": "https://gitea.armco.dev/ReStruct-Corporate-Advantage/uploader/issues"
},
"homepage": "https://gitea.armco.dev/ReStruct-Corporate-Advantage/uploader#readme",
"devDependencies": {
"@armco/types": "^0.0.22",
"@types/node": "^24.10.0",
"@vitejs/plugin-react": "^5.1.0",
"sass-embedded": "^1.93.3",
"typescript": "^5.9.3",
"vite-plugin-css-injected-by-js": "^3.5.2",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-externalize-deps": "^0.10.0",
"vitest": "^4.0.8"
},
"dependencies": {
"@armco/configs": "^0.0.15",
"@armco/icon": "^0.0.13",
"@armco/shared-components": "^0.0.59",
"@armco/utils": "^0.0.31",
"@types/uuid": "^10.0.0"
}
}

16
publish-local.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
semver=${1:-patch}
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
cd build
npm pack --pack-destination ~/__Projects__/Common

28
publish.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/sh
semver=${1:-patch}
set -e
npm --no-git-tag-version version ${semver}
npm run build
cp package.json build/
# Use Node.js for portable package.json normalization
# Pass the target path via env var to avoid Node treating it as a module/script argument
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\//, '');
});
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
EOF
cd build
npm publish --access public --loglevel verbose

38
src/Uploader.component.scss Executable file
View 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);
}
}

601
src/Uploader.tsx Executable file
View File

@@ -0,0 +1,601 @@
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 {
ArButtonVariants,
ArPopoverSlots,
ArProgress,
ArSizes,
} from "@armco/shared-components/enums"
import { ImageProps } from "@armco/shared-components/Image"
import Droppable from "@armco/shared-components/Droppable"
import Button from "@armco/shared-components/Button"
import Text from "@armco/shared-components/Text"
import ProgressIndicator from "@armco/shared-components/ProgressIndicator"
import Tooltip from "@armco/shared-components/Tooltip"
import Avatar from "@armco/shared-components/Avatar"
import Icon from "@armco/icon"
import {
UploaderProps,
UploadResult,
UploadResults,
} from "./types"
import { convertToBytes } from "./utils"
import useUpload from "./useUpload"
import "./Uploader.component.scss"
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

4
src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { default as useUpload } from "./useUpload"
export { default } from "./Uploader"
export * from "./utils"
export * from "./types"

71
src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1,71 @@
/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: "development" | "production"
readonly PUBLIC_URL: string
}
}
declare module "*.avif" {
const src: string
export default src
}
declare module "*.bmp" {
const src: string
export default src
}
declare module "*.gif" {
const src: string
export default src
}
declare module "*.jpg" {
const src: string
export default src
}
declare module "*.jpeg" {
const src: string
export default src
}
declare module "*.png" {
const src: string
export default src
}
declare module "*.webp" {
const src: string
export default src
}
declare module "*.svg" {
import * as React from "react"
export const ReactComponent: React.FunctionComponent<React.SVGProps<
SVGSVGElement
> & { title?: string }>
const src: string
export default src
}
declare module "*.module.css" {
const classes: { readonly [key: string]: string }
export default classes
}
declare module "*.module.scss" {
const classes: { readonly [key: string]: string }
export default classes
}
declare module "*.module.sass" {
const classes: { readonly [key: string]: string }
export default classes
}

42
src/types.ts Normal file
View File

@@ -0,0 +1,42 @@
import { BaseProps, FunctionType } from "@armco/types"
import { ImageProps } from "@armco/shared-components/Image"
import { ArProgress, ArSizes } from "@armco/shared-components/enums"
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
}

214
src/useUpload.ts Normal file
View File

@@ -0,0 +1,214 @@
// useUpload.ts
import { useEffect, useRef, useState } from "react"
import { FunctionType } from "@armco/types"
import { UploadHandler, UploadResults } from "./types"
import { ArProgress } from "@armco/shared-components/enums"
import { upload as httpUpload } from "@armco/utils/network"
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

43
src/utils.ts Normal file
View File

@@ -0,0 +1,43 @@
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
}

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"outDir": "build",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

46
vite.config.ts Normal file
View File

@@ -0,0 +1,46 @@
import { resolve } from "path"
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import dts from "vite-plugin-dts"
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"
import { externalizeDeps } from "vite-plugin-externalize-deps"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
dts({ outDir: "build/types" }),
cssInjectedByJsPlugin({
jsAssetsFilterFunction: (chunk) => chunk.fileName.includes("Uploader"),
}),
externalizeDeps(),
],
build: {
outDir: "build",
lib: {
entry: [
resolve(__dirname, "src/utils.ts"),
resolve(__dirname, "src/Uploader.tsx"),
resolve(__dirname, "src/useUpload.ts"),
resolve(__dirname, "src/index.ts"),
],
},
rollupOptions: {
treeshake: true,
output: [
{
format: "es",
dir: "build/es",
entryFileNames: "[name].js",
chunkFileNames: "[name]-chunk.js",
},
{
format: "cjs",
dir: "build/cjs",
entryFileNames: "[name].js",
chunkFileNames: "[name]-chunk.js",
},
],
},
},
})