├── .github └── workflows │ └── chromatic.yml ├── .gitignore ├── README.md ├── package.json ├── packages ├── components │ ├── .babelrc │ ├── .npmignore │ ├── .storybook │ │ ├── main.ts │ │ └── preview.ts │ ├── README.md │ ├── package.json │ ├── postcss.config.js │ ├── rollup.config.js │ ├── src │ │ ├── actions │ │ │ ├── FileDeleteAllButton.tsx │ │ │ ├── FileItemActions.tsx │ │ │ ├── FileUploadAllButton.tsx │ │ │ └── index.ts │ │ ├── components │ │ │ ├── FileUploadContainer.tsx │ │ │ ├── FileUploadControl.tsx │ │ │ ├── camera │ │ │ │ ├── custom-react-webcam.tsx │ │ │ │ ├── frames │ │ │ │ │ ├── WebcamFrameA4.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── use-webcam.tsx │ │ │ ├── file-drop │ │ │ │ ├── FileDropArea.tsx │ │ │ │ ├── FileDropLarge.tsx │ │ │ │ ├── FileDropSmall.tsx │ │ │ │ └── index.ts │ │ │ ├── file-list │ │ │ │ ├── FileList.tsx │ │ │ │ ├── FileListActions.tsx │ │ │ │ ├── FileListContainer.tsx │ │ │ │ ├── FileListItem.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── lib │ │ │ └── cn.ts │ │ ├── loaders │ │ │ ├── FileLoaderActions.tsx │ │ │ ├── FileLoaderCamera.tsx │ │ │ ├── FileLoaderClipboard.tsx │ │ │ ├── FileLoaderFileSystem.tsx │ │ │ └── index.ts │ │ ├── stories │ │ │ ├── FileUploadControlPreProcess.stories.tsx │ │ │ ├── FileUploadControlPreProcess.tsx │ │ │ ├── FileUploadControlSimple.stories.tsx │ │ │ ├── FileUploadControlSimple.tsx │ │ │ ├── FileUploadControlSmall.stories.tsx │ │ │ ├── FileUploadControlSmall.tsx │ │ │ ├── FileUploadControlWithProgress.stories.tsx │ │ │ ├── FileUploadControlWithProgress.tsx │ │ │ └── assets │ │ │ │ ├── accessibility.png │ │ │ │ ├── accessibility.svg │ │ │ │ ├── addon-library.png │ │ │ │ ├── assets.png │ │ │ │ ├── avif-test-image.avif │ │ │ │ ├── context.png │ │ │ │ ├── discord.svg │ │ │ │ ├── docs.png │ │ │ │ ├── figma-plugin.png │ │ │ │ ├── github.svg │ │ │ │ ├── share.png │ │ │ │ ├── styling.png │ │ │ │ ├── testing.png │ │ │ │ ├── theming.png │ │ │ │ ├── tutorials.svg │ │ │ │ └── youtube.svg │ │ ├── styles │ │ │ └── tailwind.css │ │ ├── translations │ │ │ ├── de.json │ │ │ └── en.json │ │ ├── types.ts │ │ └── ui │ │ │ ├── button │ │ │ ├── Button.tsx │ │ │ └── index.ts │ │ │ ├── card │ │ │ ├── Card.tsx │ │ │ └── index.ts │ │ │ ├── dialog │ │ │ ├── Dialog.tsx │ │ │ └── index.ts │ │ │ ├── icons │ │ │ └── index.ts │ │ │ ├── image-zoom │ │ │ └── index.ts │ │ │ ├── progress │ │ │ ├── Progress.tsx │ │ │ └── index.ts │ │ │ ├── select │ │ │ ├── Select.tsx │ │ │ └── index.ts │ │ │ ├── seperator │ │ │ ├── Seperator.tsx │ │ │ └── index.ts │ │ │ └── tooltip │ │ │ ├── Tooltip.tsx │ │ │ └── index.ts │ ├── tailwind.config.js │ └── tsconfig.json ├── core │ ├── .babelrc │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useFileOperations.ts │ │ │ ├── useFileValidation.ts │ │ │ ├── useUploadActions.ts │ │ │ └── useUploadStatus.ts │ │ ├── index.ts │ │ ├── providers │ │ │ ├── UploadedFilesManager.tsx │ │ │ ├── UploadedFilesProvider.tsx │ │ │ └── index.ts │ │ └── types.ts │ └── tsconfig.json ├── processors │ ├── README.md │ ├── package.json │ ├── src │ │ ├── canvas-utils.ts │ │ ├── index.ts │ │ ├── load-image.ts │ │ ├── pdf │ │ │ ├── generate-images-from-pdf.ts │ │ │ └── process-pdf-to-jpeg.ts │ │ └── types.ts │ ├── tsconfig.json │ └── webpack.config.js └── shared │ ├── .babelrc │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── hooks │ │ ├── index.ts │ │ ├── use-mobile-detect.ts │ │ └── use-state-machine.ts │ ├── index.ts │ ├── types.ts │ └── utils │ │ ├── api-mocker.ts │ │ ├── date │ │ └── index.ts │ │ ├── file-types.ts │ │ ├── format-bytes.ts │ │ ├── generate-id.ts │ │ ├── image-processing │ │ ├── blob-to-base64.ts │ │ ├── canvas-utils.ts │ │ ├── index.ts │ │ └── load-image.ts │ │ ├── index.ts │ │ └── is-filedrop-error.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.github/workflows/chromatic.yml: -------------------------------------------------------------------------------- 1 | # Workflow name 2 | name: "Chromatic Deployment" 3 | 4 | # Event for the workflow 5 | on: push 6 | 7 | # List of jobs 8 | jobs: 9 | chromatic: 10 | name: "Run Chromatic" 11 | runs-on: ubuntu-latest 12 | # Job steps 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - uses: pnpm/action-setup@v2 18 | with: 19 | version: 8 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | cache: "pnpm" 24 | - run: pnpm install 25 | #👇 Build all packages before running Chromatic 26 | - name: Build packages 27 | run: | 28 | pnpm --filter "@osmandvc/react-upload-control-shared" build 29 | pnpm --filter "@osmandvc/react-upload-control-processors" build 30 | pnpm --filter "@osmandvc/react-upload-control" build 31 | pnpm --filter "@osmandvc/react-upload-control-components" build 32 | #👇 Adds Chromatic as a step in the workflow 33 | - uses: chromaui/action@latest 34 | # Options required for Chromatic's GitHub Action 35 | with: 36 | #👇 Chromatic projectToken, see https://storybook.js.org/tutorials/intro-to-storybook/svelte/en/deploy/ to obtain it 37 | projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | storybook-static 4 | 5 | *.tgz 6 | *.log -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Upload Control 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 4 | 5 | A modern, flexible, easy-to-use file upload solution for React applications. This monorepo contains the following packages: 6 | 7 | ## Packages 8 | 9 | ### [@osmandvc/react-upload-control](packages/core/README.md) 10 | 11 | [![npm version](https://img.shields.io/npm/v/@osmandvc/react-upload-control.svg)](https://www.npmjs.com/package/@osmandvc/react-upload-control) 12 | 13 | The lightweight core package providing essential hooks and providers for file upload management: 14 | 15 | - Powerful file upload hooks and providers 16 | - Built-in file ordering system 17 | - State machine for upload lifecycle 18 | - Zero dependencies (except optional toaster) 19 | 20 | ### [@osmandvc/react-upload-control-components](packages/components/README.md) 21 | 22 | [![npm version](https://img.shields.io/npm/v/@osmandvc/react-upload-control-components.svg)](https://www.npmjs.com/package/@osmandvc/react-upload-control-components) 23 | 24 | Pre-built UI components around the core package with a beautiful, modern design: 25 | 26 | - Drag & Drop support 27 | - Camera integration 28 | - Clipboard support 29 | - Tailwind-powered UI 30 | 31 | ## Quick Start 32 | 33 | ```bash 34 | # Install the core package 35 | npm install @osmandvc/react-upload-control 36 | 37 | # Optional: Install the components package 38 | npm install @osmandvc/react-upload-control-components 39 | ``` 40 | 41 | ## Demo 42 | 43 | Check out our interactive demo: 44 | [React Upload Control Demo](https://main--675c9582166050575d7b72e2.chromatic.com) 45 | 46 | ## Development 47 | 48 | ```bash 49 | # Install dependencies 50 | npm install 51 | 52 | # Build packages 53 | npm run build 54 | 55 | # Run tests 56 | npm run test 57 | 58 | # Run Storybook 59 | npm run storybook 60 | ``` 61 | 62 | ## License 63 | 64 | MIT [Osman Deveci](https://github.com/osmandvc) 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-upload-control", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "build": "cd packages/core && npm run build", 9 | "dev": "cd packages/core && npm run start", 10 | "test": "cd packages/core && npm run test", 11 | "lint": "cd packages/core && npm run lint", 12 | "storybook": "cd packages/components && npm run storybook", 13 | "build-storybook": "cd packages/components && npm run build-storybook" 14 | }, 15 | "devDependencies": { 16 | "@rollup/plugin-babel": "^6.0.4", 17 | "@rollup/plugin-commonjs": "^28.0.2", 18 | "@rollup/plugin-node-resolve": "^16.0.0", 19 | "@rollup/plugin-typescript": "^12.1.2", 20 | "rollup-plugin-terser": "^7.0.2", 21 | "rollup-plugin-visualizer": "^5.14.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/components/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/components/.npmignore: -------------------------------------------------------------------------------- 1 | # Source 2 | src/ 3 | tests/ 4 | stories/ 5 | .storybook/ 6 | temp/ 7 | 8 | # Configuration files 9 | tsconfig.json 10 | webpack.config.js 11 | .eslintrc 12 | .prettierrc 13 | jest.config.js 14 | .babelrc 15 | rollup.config.js 16 | postcss.config.js 17 | tailwind.config.js 18 | 19 | # Development files 20 | *.test.ts 21 | *.test.tsx 22 | *.stories.tsx 23 | 24 | # Build artifacts 25 | storybook-static/ 26 | coverage/ 27 | .cache/ 28 | 29 | # IDE and OS files 30 | .DS_Store 31 | .idea/ 32 | .vscode/ 33 | 34 | # Dependencies 35 | node_modules/ 36 | 37 | # Logs 38 | *.log -------------------------------------------------------------------------------- /packages/components/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-webpack5"; 2 | import webpack from "webpack"; 3 | 4 | const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin"); 5 | 6 | import { join, dirname } from "path"; 7 | 8 | /** 9 | * This function is used to resolve the absolute path of a package. 10 | * It is needed in projects that use Yarn PnP or are set up within a monorepo. 11 | */ 12 | function getAbsolutePath(value: string): any { 13 | return dirname(require.resolve(join(value, "package.json"))); 14 | } 15 | const config: StorybookConfig = { 16 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 17 | addons: [ 18 | getAbsolutePath("@storybook/addon-webpack5-compiler-swc"), 19 | getAbsolutePath("@storybook/addon-onboarding"), 20 | getAbsolutePath("@storybook/addon-links"), 21 | getAbsolutePath("@storybook/addon-essentials"), 22 | getAbsolutePath("@storybook/addon-interactions"), 23 | { 24 | name: "@storybook/addon-styling", 25 | options: { 26 | // Check out https://github.com/storybookjs/addon-styling/blob/main/docs/api.md 27 | // For more details on this addon's options. 28 | postCss: true, 29 | }, 30 | }, 31 | ], 32 | 33 | framework: { 34 | name: getAbsolutePath("@storybook/react-webpack5"), 35 | options: {}, 36 | }, 37 | webpackFinal: async (config) => { 38 | config.resolve!.plugins = [ 39 | ...(config.resolve!.plugins || []), 40 | new TsconfigPathsPlugin({ 41 | extensions: config.resolve!.extensions, 42 | }), 43 | ]; 44 | 45 | config.plugins!.push( 46 | new webpack.ProvidePlugin({ 47 | React: "react", 48 | }) 49 | ); 50 | 51 | return config; 52 | }, 53 | }; 54 | export default config; 55 | -------------------------------------------------------------------------------- /packages/components/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | actions: { argTypesRegex: "^on[A-Z].*" }, 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | options: { 13 | storySort: { 14 | order: [ 15 | "Examples", 16 | [ 17 | "Upload Control With Progress", 18 | "Small Upload Control", 19 | "Upload Control without Drag and Drop", 20 | "Upload Control with PDF Pre-Processing", 21 | ], 22 | ], 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | export default preview; 29 | -------------------------------------------------------------------------------- /packages/components/README.md: -------------------------------------------------------------------------------- 1 | # React Upload Control Components 2 | 3 | [![npm version](https://img.shields.io/npm/v/@osmandvc/react-upload-control-components.svg)](https://www.npmjs.com/package/@osmandvc/react-upload-control-components) 4 | [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | 7 | Pre-built UI components for React Upload Control, providing a beautiful and feature-rich file upload experience. This package is part of the React Upload Control ecosystem. 8 | 9 | ## Demo 10 | 11 | Check out our interactive demo cases here: 12 | [React Upload Control Demo](https://main--675c9582166050575d7b72e2.chromatic.com) 13 | 14 | ## Features 🔥 15 | 16 | - 📁 **Drag & Drop:** Intuitive file uploading with visual feedback and validation 17 | - 📋 **File Management:** Built-in drag-to-reorder capability for organizing uploads 18 | - 📷 **Camera Integration:** Optional camera integration for capturing photos directly 19 | - 🎨 **Beautiful UI:** Modern, responsive design powered by Tailwind CSS 20 | - 📱 **Mobile Ready:** Optimized experience across all device sizes 21 | - 🔍 **File Preview:** Built-in preview support for images 22 | - 📎 **Clipboard Support:** Paste files directly from clipboard 23 | 24 | ## Installation 25 | 26 | ```bash 27 | # Install the components package 28 | npm install @osmandvc/react-upload-control-components 29 | 30 | # Install the core package (peer dependency) 31 | npm install @osmandvc/react-upload-control 32 | ``` 33 | 34 | ## Basic Usage 35 | 36 | ```tsx 37 | import { FileUploadControl } from "@osmandvc/react-upload-control-components"; 38 | import { UploadedFilesProvider } from "@osmandvc/react-upload-control"; 39 | 40 | function App() { 41 | return ( 42 | { 50 | // Your upload logic 51 | }, 52 | }} 53 | > 54 | 55 | 56 | ); 57 | } 58 | ``` 59 | 60 | ## Available Components 61 | 62 | ### FileUploadControl 63 | 64 | The main component that combines all features: 65 | 66 | ```tsx 67 | 74 | ``` 75 | 76 | ## Why a Separate Components Package? 77 | 78 | 1. **Flexibility:** Use only what you need. If you want to build your own UI, you can use just the core package 79 | 2. **Bundle Size:** Keep your bundle size minimal by only including the components you use 80 | 3. **Styling Freedom:** The components package includes styles, but you can build your own UI using the core package 81 | 4. **Independent Updates:** UI components can be updated without affecting core functionality 82 | 83 | ## Contributing 84 | 85 | We welcome contributions! Feel free to open issues or submit pull requests on our [GitHub repository](https://github.com/osmandvc/react-upload-control). 86 | 87 | ## License 88 | 89 | MIT © [osmandvc](https://github.com/osmandvc) 90 | -------------------------------------------------------------------------------- /packages/components/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@osmandvc/react-upload-control-components", 3 | "version": "1.0.1", 4 | "description": "UI components for the react-upload-control library.", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.esm.js", 7 | "types": "./dist/index.d.ts", 8 | "sideEffects": [ 9 | "*.css" 10 | ], 11 | "files": [ 12 | "dist", 13 | "README.md", 14 | "LICENSE" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/osmandvc/react-upload-control.git" 19 | }, 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "keywords": [ 24 | "react", 25 | "react-upload", 26 | "react-upload-control", 27 | "file-upload", 28 | "drag-and-drop", 29 | "upload", 30 | "typescript", 31 | "react-component", 32 | "file-uploader", 33 | "react18", 34 | "tailwindcss", 35 | "camera-upload", 36 | "image-upload", 37 | "file-preview", 38 | "multiple-upload", 39 | "async-upload" 40 | ], 41 | "scripts": { 42 | "build": "rollup -c", 43 | "test": "jest", 44 | "lint": "eslint src --ext .js,.jsx,.ts,.tsx", 45 | "storybook": "storybook dev -p 6006", 46 | "build-storybook": "storybook build", 47 | "prepublishOnly": "pnpm run build" 48 | }, 49 | "dependencies": { 50 | "@dnd-kit/core": "^6.2.0", 51 | "@dnd-kit/sortable": "^9.0.0", 52 | "@dnd-kit/utilities": "^3.2.2", 53 | "@osmandvc/react-upload-control-shared": "workspace:*", 54 | "@radix-ui/react-dialog": "^1.1.2", 55 | "@radix-ui/react-progress": "^1.1.0", 56 | "@radix-ui/react-select": "^2.1.2", 57 | "@radix-ui/react-separator": "^1.1.0", 58 | "@radix-ui/react-slot": "^1.1.0", 59 | "@radix-ui/react-tooltip": "^1.1.3", 60 | "@tabler/icons-react": "^3.19.0", 61 | "class-variance-authority": "^0.7.0", 62 | "clsx": "^2.1.1", 63 | "react-medium-image-zoom": "^5.2.10", 64 | "sonner": "^1.5.0", 65 | "tailwind-merge": "^2.5.4", 66 | "tailwindcss-animate": "^1.0.7" 67 | }, 68 | "devDependencies": { 69 | "@babel/core": "^7.25.8", 70 | "@babel/preset-env": "^7.25.8", 71 | "@babel/preset-react": "^7.25.7", 72 | "@babel/preset-typescript": "^7.25.7", 73 | "@osmandvc/react-upload-control-processors": "workspace:*", 74 | "@rollup/plugin-json": "^6.1.0", 75 | "@storybook/addon-essentials": "^8.3.6", 76 | "@storybook/addon-interactions": "^8.3.6", 77 | "@storybook/addon-links": "^8.3.6", 78 | "@storybook/addon-onboarding": "^8.3.6", 79 | "@storybook/addon-styling": "^1.3.7", 80 | "@storybook/addon-webpack5-compiler-swc": "^1.0.5", 81 | "@storybook/blocks": "^8.3.6", 82 | "@storybook/react": "^8.3.6", 83 | "@storybook/react-webpack5": "^8.3.6", 84 | "@storybook/test": "^8.3.6", 85 | "@types/react": "^18.3.11", 86 | "@types/react-dom": "^18.3.1", 87 | "autoprefixer": "^10.4.20", 88 | "babel-loader": "^9.2.1", 89 | "chromatic": "^11.20.2", 90 | "css-loader": "^7.1.2", 91 | "mini-css-extract-plugin": "^2.9.2", 92 | "postcss": "^8.4.47", 93 | "postcss-loader": "^8.1.1", 94 | "process": "^0.11.10", 95 | "rollup-plugin-postcss": "^4.0.2", 96 | "storybook": "^8.3.6", 97 | "style-loader": "^4.0.0", 98 | "tailwind-scrollbar": "^3.1.0", 99 | "tailwindcss": "^3.4.14", 100 | "ts-loader": "^9.5.1", 101 | "tsconfig-paths-webpack-plugin": "^4.1.0", 102 | "tslib": "^2.8.1", 103 | "typescript": "^5.6.3", 104 | "webpack": "^5.95.0", 105 | "webpack-bundle-analyzer": "^4.10.2", 106 | "webpack-cli": "^5.1.4", 107 | "webpack-dev-server": "^5.1.0", 108 | "rollup": "^2.79.1" 109 | }, 110 | "peerDependencies": { 111 | "react": "^18.0.0", 112 | "react-dom": "^18.0.0", 113 | "@osmandvc/react-upload-control": "workspace:*" 114 | }, 115 | "peerDependenciesMeta": { 116 | "react": { 117 | "optional": false 118 | }, 119 | "react-dom": { 120 | "optional": false 121 | }, 122 | "@osmandvc/react-upload-control": { 123 | "optional": false 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /packages/components/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/components/rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { terser } from "rollup-plugin-terser"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | import commonjs from "@rollup/plugin-commonjs"; 5 | import typescript from "@rollup/plugin-typescript"; 6 | import { babel } from "@rollup/plugin-babel"; 7 | import postcss from "rollup-plugin-postcss"; 8 | import json from "@rollup/plugin-json"; 9 | 10 | import pkg from "./package.json"; 11 | 12 | export default { 13 | input: "./src/index.ts", 14 | output: [ 15 | { 16 | file: path.resolve(__dirname, "dist/index.esm.js"), 17 | format: "esm", 18 | }, 19 | ], 20 | external: [...Object.keys(pkg.peerDependencies), "react/jsx-runtime"], 21 | plugins: [ 22 | resolve({ 23 | extensions: [".ts", ".tsx", ".js", ".jsx"], 24 | }), 25 | commonjs(), 26 | typescript(), 27 | babel({ 28 | babelHelpers: "bundled", 29 | exclude: "node_modules/**", 30 | extensions: [".ts", ".tsx", ".js", ".jsx"], 31 | }), 32 | postcss({ 33 | config: { 34 | path: "./postcss.config.js", 35 | }, 36 | extensions: [".css"], 37 | minimize: true, 38 | inject: { 39 | insertAt: "top", 40 | }, 41 | }), 42 | json(), 43 | terser(), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /packages/components/src/actions/FileDeleteAllButton.tsx: -------------------------------------------------------------------------------- 1 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control"; 2 | import { Button } from "../ui/button/Button"; 3 | import { ResetIcon } from "../ui/icons"; 4 | 5 | export const FileDeleteAllButton = () => { 6 | const { files, deleteAllFiles, smStatusIs } = useUploadFilesProvider(); 7 | 8 | return ( 9 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/components/src/actions/FileItemActions.tsx: -------------------------------------------------------------------------------- 1 | import { IconCheck } from "@tabler/icons-react"; 2 | 3 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control"; 4 | import { UploadedFileItemStage } from "../types"; 5 | import { Button } from "../ui/button/Button"; 6 | 7 | type Props = { 8 | id: string; 9 | stage?: UploadedFileItemStage; 10 | }; 11 | 12 | export const FileItemActions = ({ id, stage }: Props) => { 13 | const { deleteFile } = useUploadFilesProvider(); 14 | 15 | return ( 16 |
17 | {(!stage || 18 | stage === UploadedFileItemStage.IDLE || 19 | stage === UploadedFileItemStage.FAILED) && ( 20 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/components/src/actions/FileUploadAllButton.tsx: -------------------------------------------------------------------------------- 1 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control"; 2 | import { CheckIcon } from "../ui/icons"; 3 | import { Button } from "../ui/button/Button"; 4 | 5 | export const FileUploadAllButton = () => { 6 | const { uploadAllFiles, files, smStatusIs } = useUploadFilesProvider(); 7 | 8 | return ( 9 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/components/src/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./FileUploadAllButton"; 2 | export * from "./FileDeleteAllButton"; 3 | export * from "./FileItemActions"; 4 | -------------------------------------------------------------------------------- /packages/components/src/components/FileUploadContainer.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react"; 2 | import { cn } from "../lib/cn"; 3 | 4 | /** 5 | * The Default Container for the File Upload Control 6 | */ 7 | 8 | interface ContainerProps extends PropsWithChildren { 9 | className?: string; 10 | } 11 | 12 | export const FileUploadContainer = ({ 13 | className, 14 | children, 15 | }: ContainerProps) => { 16 | return
{children}
; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/components/src/components/FileUploadControl.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/tailwind.css"; 2 | import { arrayMove } from "@dnd-kit/sortable"; 3 | import { cn } from "../lib/cn"; 4 | import { DndResult, FileUploadControlProps } from "../types"; 5 | import { FileList, FileListActions, FileListContainer } from "./file-list"; 6 | import { FileDropLarge, FileDropSmall } from "./file-drop"; 7 | import { useUploadFilesProvider } from "@osmandvc/react-upload-control"; 8 | 9 | /** 10 | * The Default File-Upload-Control Component with a Drop-Area and a List which displays the Files. 11 | */ 12 | export const FileUploadControl = ({ 13 | className, 14 | children, 15 | disableCamera, 16 | disableFileSystem, 17 | size = "auto", 18 | }: FileUploadControlProps) => { 19 | const { files, setFiles, smStatusIs } = useUploadFilesProvider(); 20 | const hasFiles = !!files.length; 21 | 22 | function handleOnDragEnd(result: DndResult) { 23 | const { source, destination } = result; 24 | 25 | // If dropped outside the list or at the same position, do nothing 26 | if (!destination || source.index === destination.index) { 27 | return; 28 | } 29 | 30 | setFiles((prevFiles) => { 31 | const newFiles = arrayMove(prevFiles, source.index, destination.index); 32 | return newFiles.map((file, index) => ({ ...file, order: index + 1 })); 33 | }); 34 | } 35 | 36 | return ( 37 |
43 |
52 | 63 | {children} 64 | 65 | 75 | {children} 76 | 77 |
78 |
84 | {hasFiles && ( 85 | 86 | 87 | 88 | 89 | )} 90 |
91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /packages/components/src/components/camera/custom-react-webcam.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // polyfill based on https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia 4 | (function polyfillGetUserMedia() { 5 | const nav = navigator as any; 6 | 7 | if (typeof window === "undefined") return; 8 | 9 | // Older browsers might not implement mediaDevices at all, so we set an empty object first 10 | if (nav.mediaDevices === undefined) { 11 | nav.mediaDevices = {}; 12 | } 13 | 14 | // Some browsers partially implement mediaDevices. We can't just assign an object 15 | // with getUserMedia as it would overwrite existing properties. 16 | // Here, we will just add the getUserMedia property if it's missing. 17 | if (nav.mediaDevices.getUserMedia === undefined) { 18 | nav.mediaDevices.getUserMedia = function ( 19 | constraints: any 20 | ): Promise { 21 | // First get ahold of the legacy getUserMedia, if present 22 | const getUserMedia = 23 | nav.getUserMedia || 24 | nav.webkitGetUserMedia || 25 | nav.mozGetUserMedia || 26 | nav.msGetUserMedia; 27 | 28 | // Some browsers just don't implement it - return a rejected promise with an error 29 | // to keep a consistent interface 30 | if (!getUserMedia) { 31 | return Promise.reject( 32 | new Error("getUserMedia is not implemented in this browser") 33 | ); 34 | } 35 | 36 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise 37 | return new Promise(function (resolve, reject) { 38 | getUserMedia.call(nav, constraints, resolve, reject); 39 | }); 40 | }; 41 | } 42 | })(); 43 | 44 | function hasGetUserMedia() { 45 | return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); 46 | } 47 | 48 | interface ScreenshotDimensions { 49 | width: number; 50 | height: number; 51 | } 52 | 53 | interface ChildrenProps { 54 | getScreenshot: (screenshotDimensions?: ScreenshotDimensions) => string | null; 55 | } 56 | 57 | export type WebcamProps = Omit, "ref"> & { 58 | audio: boolean; 59 | audioConstraints?: MediaStreamConstraints["audio"]; 60 | forceScreenshotSourceSize: boolean; 61 | imageSmoothing: boolean; 62 | mirrored: boolean; 63 | minScreenshotHeight?: number; 64 | minScreenshotWidth?: number; 65 | onUserMedia: (stream: MediaStream) => void; 66 | onUserMediaError: (error: string | DOMException) => void; 67 | screenshotFormat: "image/webp" | "image/png" | "image/jpeg"; 68 | screenshotQuality: number; 69 | videoConstraints?: MediaStreamConstraints["video"]; 70 | children?: (childrenProps: ChildrenProps) => JSX.Element; 71 | }; 72 | 73 | interface WebcamState { 74 | hasUserMedia: boolean; 75 | src?: string; 76 | } 77 | 78 | export default class Webcam extends React.Component { 79 | static defaultProps = { 80 | audio: false, 81 | forceScreenshotSourceSize: false, 82 | imageSmoothing: true, 83 | mirrored: false, 84 | onUserMedia: () => undefined, 85 | onUserMediaError: () => undefined, 86 | screenshotFormat: "image/jpeg", 87 | screenshotQuality: 0.9, 88 | }; 89 | 90 | private canvas: HTMLCanvasElement | null = null; 91 | private ctx: CanvasRenderingContext2D | null = null; 92 | private requestUserMediaId: number = 0; 93 | private unmounted: boolean = false; 94 | private requestedUserMedia: boolean = false; 95 | 96 | stream: MediaStream | null = null; 97 | video: HTMLVideoElement | null = null; 98 | 99 | constructor(props: WebcamProps) { 100 | super(props); 101 | this.state = { 102 | hasUserMedia: false, 103 | }; 104 | } 105 | 106 | componentDidMount() { 107 | const { state, props } = this; 108 | this.unmounted = false; 109 | 110 | if (!hasGetUserMedia()) { 111 | props.onUserMediaError("getUserMedia not supported"); 112 | return; 113 | } 114 | 115 | if (!state.hasUserMedia && !this.requestedUserMedia) { 116 | this.requestUserMedia(); 117 | } 118 | 119 | if (props.children && typeof props.children != "function") { 120 | console.warn("children must be a function"); 121 | } 122 | } 123 | 124 | componentDidUpdate(nextProps: WebcamProps) { 125 | const { props } = this; 126 | 127 | if (!hasGetUserMedia()) { 128 | props.onUserMediaError("getUserMedia not supported"); 129 | return; 130 | } 131 | 132 | const audioConstraintsChanged = 133 | JSON.stringify(nextProps.audioConstraints) !== 134 | JSON.stringify(props.audioConstraints); 135 | const videoConstraintsChanged = 136 | JSON.stringify(nextProps.videoConstraints) !== 137 | JSON.stringify(props.videoConstraints); 138 | const minScreenshotWidthChanged = 139 | nextProps.minScreenshotWidth !== props.minScreenshotWidth; 140 | const minScreenshotHeightChanged = 141 | nextProps.minScreenshotHeight !== props.minScreenshotHeight; 142 | if ( 143 | videoConstraintsChanged || 144 | minScreenshotWidthChanged || 145 | minScreenshotHeightChanged 146 | ) { 147 | this.canvas = null; 148 | this.ctx = null; 149 | } 150 | if (audioConstraintsChanged || videoConstraintsChanged) { 151 | this.stopAndCleanup(); 152 | this.requestUserMedia(); 153 | } 154 | } 155 | 156 | componentWillUnmount() { 157 | const { props } = this; 158 | this.unmounted = true; 159 | this.stopAndCleanup(); 160 | } 161 | 162 | private static stopMediaStream(stream: MediaStream | null) { 163 | const st = stream as any; 164 | if (st) { 165 | st.getTracks().forEach((track: any) => { 166 | track.stop(); 167 | track.enabled = false; 168 | st?.removeTrack(track); 169 | }); 170 | if (st.stop) st.stop(); 171 | 172 | /*if (stream.getVideoTracks && stream.getAudioTracks) { 173 | stream.getVideoTracks().map((track) => { 174 | stream.removeTrack(track); 175 | track.stop(); 176 | }); 177 | stream.getAudioTracks().map((track) => { 178 | stream.removeTrack(track); 179 | track.stop(); 180 | }); 181 | } else { 182 | (stream as unknown as MediaStreamTrack).stop(); 183 | }*/ 184 | } 185 | } 186 | 187 | private stopAndCleanup() { 188 | const { state } = this; 189 | 190 | if (state.hasUserMedia) { 191 | Webcam.stopMediaStream(this.stream); 192 | 193 | if (state.src) { 194 | window.URL.revokeObjectURL(state.src); 195 | } 196 | 197 | /*if (this.video) { 198 | this.video.srcObject = null; 199 | }*/ 200 | } 201 | } 202 | 203 | getScreenshot(screenshotDimensions?: ScreenshotDimensions) { 204 | const { state, props } = this; 205 | 206 | if (!state.hasUserMedia) return null; 207 | 208 | const canvas = this.getCanvas(screenshotDimensions); 209 | 210 | return ( 211 | canvas && 212 | canvas.toDataURL(props.screenshotFormat, props.screenshotQuality) 213 | ); 214 | } 215 | 216 | getCanvas(screenshotDimensions?: ScreenshotDimensions) { 217 | const { state, props } = this; 218 | 219 | if (!this.video) return null; 220 | if (!state.hasUserMedia || !this.video.videoHeight) return null; 221 | 222 | if (!this.ctx) { 223 | let canvasWidth = this.video.videoWidth; 224 | let canvasHeight = this.video.videoHeight; 225 | 226 | if (!this.props.forceScreenshotSourceSize) { 227 | const aspectRatio = canvasWidth / canvasHeight; 228 | 229 | canvasWidth = props.minScreenshotWidth || this.video.clientWidth; 230 | canvasHeight = canvasWidth / aspectRatio; 231 | 232 | if ( 233 | props.minScreenshotHeight && 234 | canvasHeight < props.minScreenshotHeight 235 | ) { 236 | canvasHeight = props.minScreenshotHeight; 237 | canvasWidth = canvasHeight * aspectRatio; 238 | } 239 | } 240 | 241 | this.canvas = document.createElement("canvas"); 242 | this.canvas.width = screenshotDimensions?.width || canvasWidth; 243 | this.canvas.height = screenshotDimensions?.height || canvasHeight; 244 | this.ctx = this.canvas.getContext("2d"); 245 | } 246 | 247 | const { ctx, canvas } = this; 248 | 249 | if (ctx && canvas) { 250 | // adjust the height and width of the canvas to the given dimensions 251 | canvas.width = screenshotDimensions?.width || canvas.width; 252 | canvas.height = screenshotDimensions?.height || canvas.height; 253 | 254 | // mirror the screenshot 255 | if (props.mirrored) { 256 | ctx.translate(canvas.width, 0); 257 | ctx.scale(-1, 1); 258 | } 259 | 260 | ctx.imageSmoothingEnabled = props.imageSmoothing; 261 | ctx.drawImage( 262 | this.video, 263 | 0, 264 | 0, 265 | screenshotDimensions?.width || canvas.width, 266 | screenshotDimensions?.height || canvas.height 267 | ); 268 | 269 | // invert mirroring 270 | if (props.mirrored) { 271 | ctx.scale(-1, 1); 272 | ctx.translate(-canvas.width, 0); 273 | } 274 | } 275 | 276 | return canvas; 277 | } 278 | 279 | private requestUserMedia() { 280 | const { props } = this; 281 | this.requestedUserMedia = true; 282 | 283 | const sourceSelected = ( 284 | audioConstraints: boolean | MediaTrackConstraints | undefined, 285 | videoConstraints: boolean | MediaTrackConstraints | undefined 286 | ) => { 287 | const constraints: MediaStreamConstraints = { 288 | video: 289 | typeof videoConstraints !== "undefined" ? videoConstraints : true, 290 | }; 291 | 292 | if (props.audio) { 293 | constraints.audio = 294 | typeof audioConstraints !== "undefined" ? audioConstraints : true; 295 | } 296 | 297 | this.requestUserMediaId++; 298 | const myRequestUserMediaId = this.requestUserMediaId; 299 | 300 | if (this.stream) return; 301 | 302 | navigator.mediaDevices 303 | .getUserMedia(constraints) 304 | .then((stream) => { 305 | if ( 306 | this.unmounted || 307 | myRequestUserMediaId !== this.requestUserMediaId 308 | ) { 309 | Webcam.stopMediaStream(stream); 310 | } else { 311 | this.handleUserMedia(null, stream); 312 | } 313 | }) 314 | .catch((e) => { 315 | this.handleUserMedia(e); 316 | }) 317 | .finally(() => { 318 | this.requestedUserMedia = false; 319 | }); 320 | }; 321 | 322 | if ("mediaDevices" in navigator) { 323 | sourceSelected(props.audioConstraints, props.videoConstraints); 324 | } else { 325 | const optionalSource = (id: string | null) => 326 | ({ optional: [{ sourceId: id }] } as MediaTrackConstraints); 327 | 328 | const constraintToSourceId = (constraint: any) => { 329 | const { deviceId } = constraint; 330 | 331 | if (typeof deviceId === "string") return deviceId; 332 | if (Array.isArray(deviceId) && deviceId.length > 0) return deviceId[0]; 333 | if (typeof deviceId === "object" && deviceId.ideal) 334 | return deviceId.ideal; 335 | return null; 336 | }; 337 | 338 | // @ts-ignore: deprecated api 339 | MediaStreamTrack.getSources((sources: any) => { 340 | let audioSource: string | null = null; 341 | let videoSource: string | null = null; 342 | 343 | sources.forEach((source: MediaStreamTrack) => { 344 | if (source.kind === "audio") { 345 | audioSource = source.id; 346 | } else if (source.kind === "video") { 347 | videoSource = source.id; 348 | } 349 | }); 350 | 351 | const audioSourceId = constraintToSourceId(props.audioConstraints); 352 | if (audioSourceId) { 353 | audioSource = audioSourceId; 354 | } 355 | 356 | const videoSourceId = constraintToSourceId(props.videoConstraints); 357 | if (videoSourceId) { 358 | videoSource = videoSourceId; 359 | } 360 | 361 | sourceSelected( 362 | optionalSource(audioSource), 363 | optionalSource(videoSource) 364 | ); 365 | }); 366 | } 367 | } 368 | 369 | private handleUserMedia(err: any, stream?: MediaStream) { 370 | const { props } = this; 371 | 372 | if (err || !stream) { 373 | this.setState({ hasUserMedia: false }); 374 | props.onUserMediaError(err); 375 | return; 376 | } 377 | 378 | this.stream = stream; 379 | 380 | try { 381 | if (this.video) { 382 | this.video.srcObject = stream; 383 | } 384 | this.setState({ hasUserMedia: true }); 385 | } catch (error) { 386 | this.setState({ 387 | hasUserMedia: true, 388 | src: window.URL.createObjectURL(stream as any), 389 | }); 390 | } 391 | 392 | props.onUserMedia(stream); 393 | } 394 | 395 | render() { 396 | const { state, props } = this; 397 | 398 | const { 399 | audio, 400 | forceScreenshotSourceSize, 401 | onUserMedia, 402 | onUserMediaError, 403 | screenshotFormat, 404 | screenshotQuality, 405 | minScreenshotWidth, 406 | minScreenshotHeight, 407 | audioConstraints, 408 | videoConstraints, 409 | imageSmoothing, 410 | mirrored, 411 | style = {}, 412 | children, 413 | ...rest 414 | } = props; 415 | 416 | const videoStyle = mirrored 417 | ? { ...style, transform: `${style.transform || ""} scaleX(-1)` } 418 | : style; 419 | 420 | const childrenProps: ChildrenProps = { 421 | getScreenshot: this.getScreenshot.bind(this), 422 | }; 423 | 424 | return ( 425 | <> 426 |