├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .env.example ├── .vscode │ └── launch.json ├── LICENSE ├── Readme.md ├── app │ ├── config │ │ └── db.config.js │ ├── controllers │ │ ├── copyItem.controller.js │ │ ├── createFolder.controller.js │ │ ├── deleteItem.controller.js │ │ ├── downloadFile.controller.js │ │ ├── getItems.controller.js │ │ ├── moveItem.controller.js │ │ ├── renameItem.controller.js │ │ └── uploadFile.controller.js │ ├── middlewares │ │ ├── errorHandler.middleware.js │ │ └── multer.middleware.js │ ├── models │ │ └── FileSystem.model.js │ └── routes │ │ └── fileSystem.routes.js ├── fastapi_backend.py ├── package-lock.json ├── package.json ├── public │ └── uploads │ │ └── .gitkeep ├── server.js ├── swagger-output.json └── swagger.js ├── frontend ├── .env.example ├── .eslintrc.cjs ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ └── react-file-manager-logo.png ├── src │ ├── App.jsx │ ├── App.scss │ ├── FileManager │ │ ├── Actions │ │ │ ├── Actions.jsx │ │ │ ├── CreateFolder │ │ │ │ └── CreateFolder.action.jsx │ │ │ ├── Delete │ │ │ │ ├── Delete.action.jsx │ │ │ │ └── Delete.action.scss │ │ │ ├── PreviewFile │ │ │ │ ├── PreviewFile.action.jsx │ │ │ │ └── PreviewFile.action.scss │ │ │ ├── Rename │ │ │ │ └── Rename.action.jsx │ │ │ └── UploadFile │ │ │ │ ├── UploadFile.action.jsx │ │ │ │ ├── UploadFile.action.scss │ │ │ │ └── UploadItem.jsx │ │ ├── BreadCrumb │ │ │ ├── BreadCrumb.jsx │ │ │ └── BreadCrumb.scss │ │ ├── FileList │ │ │ ├── FileItem.jsx │ │ │ ├── FileList.jsx │ │ │ ├── FileList.scss │ │ │ ├── FilesHeader.jsx │ │ │ └── useFileList.jsx │ │ ├── FileManager.jsx │ │ ├── FileManager.scss │ │ ├── NavigationPane │ │ │ ├── FolderTree.jsx │ │ │ ├── NavigationPane.jsx │ │ │ └── NavigationPane.scss │ │ ├── Toolbar │ │ │ ├── LayoutToggler.jsx │ │ │ ├── Toolbar.jsx │ │ │ └── Toolbar.scss │ │ └── index.js │ ├── api │ │ ├── api.js │ │ ├── createFolderAPI.js │ │ ├── deleteAPI.js │ │ ├── downloadFileAPI.js │ │ ├── fileTransferAPI.js │ │ ├── getAllFilesAPI.js │ │ └── renameAPI.js │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── Button │ │ │ ├── Button.jsx │ │ │ └── Button.scss │ │ ├── Checkbox │ │ │ ├── Checkbox.jsx │ │ │ └── Checkbox.scss │ │ ├── Collapse │ │ │ └── Collapse.jsx │ │ ├── ContextMenu │ │ │ ├── ContextMenu.jsx │ │ │ ├── ContextMenu.scss │ │ │ └── SubMenu.jsx │ │ ├── ErrorTooltip │ │ │ ├── ErrorTooltip.jsx │ │ │ └── ErrorTooltip.scss │ │ ├── Loader │ │ │ ├── Loader.jsx │ │ │ └── Loader.scss │ │ ├── Modal │ │ │ ├── Modal.jsx │ │ │ └── Modal.scss │ │ ├── NameInput │ │ │ ├── NameInput.jsx │ │ │ └── NameInput.scss │ │ └── Progress │ │ │ ├── Progress.jsx │ │ │ └── Progress.scss │ ├── constants │ │ └── index.js │ ├── contexts │ │ ├── ClipboardContext.jsx │ │ ├── FileNavigationContext.jsx │ │ ├── FilesContext.jsx │ │ ├── LayoutContext.jsx │ │ ├── SelectionContext.jsx │ │ └── TranslationProvider.jsx │ ├── hooks │ │ ├── useColumnResize.js │ │ ├── useDetectOutsideClick.js │ │ ├── useFileIcons.jsx │ │ ├── useKeyPress.js │ │ ├── useShortcutHandler.js │ │ └── useTriggerAction.js │ ├── i18n.js │ ├── index.js │ ├── locales │ │ ├── ar-SA.json │ │ ├── de-DE.json │ │ ├── en-US.json │ │ ├── es-ES.json │ │ ├── fr-FR.json │ │ ├── he-IL.json │ │ ├── hi-IN.json │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ ├── ko-KR.json │ │ ├── pt-BR.json │ │ ├── ru-RU.json │ │ ├── tr-TR.json │ │ ├── uk-UA.json │ │ ├── ur-UR.json │ │ ├── vi-VN.json │ │ └── zh-CN.json │ ├── main.jsx │ ├── styles │ │ └── _variables.scss │ ├── utils │ │ ├── createFolderTree.js │ │ ├── duplicateNameHandler.js │ │ ├── formatDate.js │ │ ├── getDataSize.js │ │ ├── getFileExtension.js │ │ ├── getParentPath.js │ │ ├── shortcuts.js │ │ ├── sortFiles.js │ │ └── validateApiCallback.js │ └── validators │ │ └── propValidators.js └── vite.config.js └── release.config.cjs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: saifullah_dev 2 | buy_me_a_coffee: saifullah_dev 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: "lts/*" 22 | - run: cd frontend && npm ci 23 | - run: cd frontend && npm run build 24 | - run: cd frontend && npm audit signatures 25 | - name: Release 26 | env: 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | NPM_TOKEN: ${{secrets.NPM_TOKEN}} 29 | run: cd frontend && npx semantic-release 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | backend/.vscode/* 11 | !backend/.vscode/launch.json 12 | backend/public/uploads/* 13 | !backend/public/uploads/.gitkeep 14 | .env 15 | node_modules 16 | dist 17 | dist-ssr 18 | *.local 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | .DS_Store 25 | *.suo 26 | *.ntvs* 27 | *.njsproj 28 | *.sln 29 | *.sw? 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Saifullah Zubair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | MONGO_URI=mongodb://localhost:27017/fileManagerDB 3 | CLIENT_URI=http://localhost:5173 -------------------------------------------------------------------------------- /backend/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${file}/server.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Saifullah Zubair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/Readme.md: -------------------------------------------------------------------------------- 1 | # 📂 File Manager Backend 2 | 3 | This backend provides a RESTful API for managing files and folders, intended to be used with a front-end file manager component. It allows users to perform various operations such as creating folders, uploading files, renaming, moving, copying, deleting, and downloading files. All APIs are documented using **Swagger**. 4 | 5 | ## 🚀 Getting Started 6 | 7 | ### Prerequisites 8 | 9 | Make sure you have the following installed: 10 | 11 | - [Node.js](https://nodejs.org/) 🟢 12 | - [npm](https://www.npmjs.com/) 📦 13 | 14 | ### Installation 15 | 16 | 1. Clone the repository: 17 | 18 | ```bash 19 | git clone https://github.com/Saifullah-dev/react-file-manager.git 20 | ``` 21 | 22 | 2. Navigate to the `backend` directory: 23 | 24 | ```bash 25 | cd backend 26 | ``` 27 | 28 | 3. Install the dependencies: 29 | ```bash 30 | npm i 31 | ``` 32 | 33 | ### 🎯 Running the Backend 34 | 35 | 1. Create a `.env` file based on the `.env.example` and set your environment variables accordingly. 36 | 37 | 2. Start the server: 38 | 39 | ```bash 40 | npm run devStart 41 | ``` 42 | 43 | This will start the backend server on `http://localhost:3000`. 44 | 45 | ### ![swagger-icon](https://github.com/user-attachments/assets/9cb14fef-febc-4b52-873c-52dfc80e601e) API Documentation 46 | 47 | The API documentation is generated through **Swagger** and can be viewed [here](https://app.swaggerhub.com/apis-docs/SaifullahZubair/file-system_api/1.0.0). 48 | 49 | 1. To Generate the Swagger docs: 50 | 51 | ```bash 52 | npm run genDocs 53 | ``` 54 | 55 | 2. Access the Swagger documentation: 56 | Open [http://localhost:3000/api-docs/](http://localhost:3000/api-docs/) in your browser to see all available API endpoints and their details. 57 | 58 | ### ![postman-icon](https://github.com/user-attachments/assets/b0bd6b21-056e-4934-a4d6-b8dc6f7fd6d5) Postman Collection 59 | 60 | You can download and use the Postman collection from [here](https://github.com/user-attachments/files/17149486/File.Management.API.postman_collection.json). 61 | 62 | ## 🔧 API Endpoints 63 | 64 | The backend supports the following file system operations: 65 | 66 | - **📁 Create a Folder**: `/folder` 67 | - **⬆️ Upload a File**: `/upload` 68 | - **📋 Copy File(s) or Folder(s)**: `/copy` 69 | - **📂 Get All Files/Folders**: `/` 70 | - **⬇️ Download File(s) or Folder(s)**: `/download` 71 | - **📤 Move File(s) or Folder(s)**: `/move` 72 | - **✏️ Rename a File or Folder**: `/rename` 73 | - **🗑️ Delete File(s) or Folder(s)**: `/` 74 | 75 | Refer to the [Swagger Documentation](http://localhost:3000/api-docs/) for detailed request/response formats. 76 | 77 | ## 🗂️ Folder Structure 78 | 79 | ``` 80 | backend/ 81 | │ 82 | ├── app/ 83 | │ ├── config/ 84 | │ │ └── db.config.js # Database configuration (if applicable) 85 | │ ├── controllers/ # API controllers for various file system operations 86 | │ │ ├── copyItem.controller.js 87 | │ │ ├── createFolder.controller.js 88 | │ │ ├── deleteItem.controller.js 89 | │ │ ├── downloadFile.controller.js 90 | │ │ ├── getItems.controller.js 91 | │ │ ├── moveItem.controller.js 92 | │ │ ├── renameItem.controller.js 93 | │ │ └── uploadFile.controller.js 94 | │ ├── middlewares/ # Custom middlewares 95 | │ │ ├── errorHandler.middleware.js 96 | │ │ └── multer.middleware.js 97 | │ ├── models/ 98 | │ │ └── FileSystem.model.js # Mongoose model for file system (if using a DB) 99 | │ └── routes/ 100 | │ └── fileSystem.routes.js # Route definitions for file system operations 101 | │ 102 | ├── public/ 103 | │ └── uploads/ # Uploaded files will be stored here 104 | │ 105 | ├── swagger.js # Swagger configuration 106 | ├── package.json 107 | ├── server.js # Entry point of the application 108 | └── .env # Environment variables 109 | ``` 110 | 111 | ### 📁 Uploads and Folder Creation 112 | 113 | - All uploaded files and folders created through the API are placed in the `/public/uploads/` directory. Ensure this directory has the appropriate permissions set to allow file storage. 114 | 115 | ## ⚠️ Error Handling 116 | 117 | Custom error handling is provided via the middleware in `errorHandler.middleware.js`. 118 | 119 | ## 📜 License 120 | 121 | React File Manager is [MIT Licensed](LICENSE). 122 | -------------------------------------------------------------------------------- /backend/app/config/db.config.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const dotenv = require("dotenv"); 3 | 4 | dotenv.config(); 5 | 6 | const connectDB = async () => { 7 | try { 8 | await mongoose.connect(process.env.MONGO_URI); 9 | console.log("MongoDB connected"); 10 | } catch (error) { 11 | console.error("MongoDB connection failed:", error.message); 12 | process.exit(1); 13 | } 14 | }; 15 | 16 | module.exports = connectDB; 17 | -------------------------------------------------------------------------------- /backend/app/controllers/copyItem.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const fs = require("fs"); 3 | const mongoose = require("mongoose"); 4 | const path = require("path"); 5 | 6 | const recursiveCopy = async (sourceItem, destinationFolder) => { 7 | const copyItem = new FileSystem({ 8 | name: sourceItem.name, 9 | isDirectory: sourceItem.isDirectory, 10 | path: `${destinationFolder?.path ?? ""}/${sourceItem.name}`, 11 | parentId: destinationFolder?._id || null, 12 | size: sourceItem.size, 13 | mimeType: sourceItem.mimeType, 14 | }); 15 | 16 | await copyItem.save(); 17 | 18 | const children = await FileSystem.find({ parentId: sourceItem._id }); 19 | 20 | for (const child of children) { 21 | await recursiveCopy(child, copyItem); 22 | } 23 | }; 24 | 25 | const copyItem = async (req, res) => { 26 | // #swagger.summary = 'Copies file/folder(s) to the destination folder.' 27 | /* #swagger.parameters['body'] = { 28 | in: 'body', 29 | required: true, 30 | schema: { $ref: "#/definitions/CopyItems" }, 31 | description: 'An array of item IDs to copy and the destination folder ID.' 32 | } 33 | */ 34 | /* #swagger.responses[200] = { 35 | schema: {message: "Item(s) copied successfully!"} 36 | } 37 | */ 38 | 39 | const { sourceIds, destinationId } = req.body; 40 | const isRootDestination = !destinationId; 41 | 42 | if (!sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) { 43 | return res.status(400).json({ error: "Invalid request body, expected an array of sourceIds." }); 44 | } 45 | 46 | try { 47 | const validIds = sourceIds.filter((id) => mongoose.Types.ObjectId.isValid(id)); 48 | if (validIds.length !== sourceIds.length) { 49 | return res.status(400).json({ error: "One or more of the provided sourceIds are invalid." }); 50 | } 51 | 52 | const sourceItems = await FileSystem.find({ _id: { $in: validIds } }); 53 | if (sourceItems.length !== validIds.length) { 54 | return res.status(404).json({ error: "One or more of the provided sourceIds do not exist." }); 55 | } 56 | 57 | const copyPromises = sourceItems.map(async (sourceItem) => { 58 | const srcFullPath = path.join(__dirname, "../../public/uploads", sourceItem.path); 59 | 60 | if (isRootDestination) { 61 | const destFullPath = path.join(__dirname, "../../public/uploads", sourceItem.name); 62 | await fs.promises.cp(srcFullPath, destFullPath, { recursive: true }); 63 | await recursiveCopy(sourceItem, null); // Destination Folder -> Root Folder 64 | } else { 65 | const destinationFolder = await FileSystem.findById(destinationId); 66 | if (!destinationFolder || !destinationFolder.isDirectory) { 67 | throw new Error("Invalid destinationId!"); 68 | } 69 | const destFullPath = path.join( 70 | __dirname, 71 | "../../public/uploads", 72 | destinationFolder.path, 73 | sourceItem.name 74 | ); 75 | await fs.promises.cp(srcFullPath, destFullPath, { recursive: true }); 76 | await recursiveCopy(sourceItem, destinationFolder); 77 | } 78 | }); 79 | 80 | try { 81 | await Promise.all(copyPromises); 82 | } catch (error) { 83 | return res.status(400).json({ error: error.message }); 84 | } 85 | 86 | res.status(200).json({ message: "Item(s) copied successfully!" }); 87 | } catch (error) { 88 | res.status(500).json({ error: error.message }); 89 | } 90 | }; 91 | 92 | module.exports = copyItem; 93 | -------------------------------------------------------------------------------- /backend/app/controllers/createFolder.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | const createFolder = async (req, res) => { 6 | // #swagger.summary = 'Creates a new folder.' 7 | /* #swagger.parameters['body'] = { 8 | in: 'body', 9 | required: true, 10 | schema: {$ref: '#/definitions/CreateFolder'} 11 | } 12 | */ 13 | try { 14 | const { name, parentId } = req.body; 15 | 16 | // Path calculation 17 | let folderPath = ""; 18 | if (parentId) { 19 | const parentFolder = await FileSystem.findById(parentId); 20 | if (!parentFolder || !parentFolder.isDirectory) { 21 | return res.status(400).json({ error: "Invalid parentId" }); 22 | } 23 | folderPath = `${parentFolder.path}/${name}`; 24 | } else { 25 | folderPath = `/${name}`; // Root Folder 26 | } 27 | // 28 | 29 | // Physical folder creation using fs 30 | const fullFolderPath = path.join(__dirname, "../../public/uploads", folderPath); 31 | if (!fs.existsSync(fullFolderPath)) { 32 | await fs.promises.mkdir(fullFolderPath, { recursive: true }); 33 | } else { 34 | return res.status(400).json({ error: "Folder already exists!" }); 35 | } 36 | // 37 | 38 | const newFolder = new FileSystem({ 39 | name, 40 | isDirectory: true, 41 | path: folderPath, 42 | parentId: parentId || null, 43 | }); 44 | 45 | await newFolder.save(); 46 | 47 | /* #swagger.responses[201] = { 48 | schema: { $ref: '#/definitions/Folder' }, 49 | } */ 50 | res.status(201).json(newFolder); 51 | } catch (error) { 52 | res.status(500).json({ error: error.message }); 53 | } 54 | }; 55 | 56 | module.exports = createFolder; 57 | -------------------------------------------------------------------------------- /backend/app/controllers/deleteItem.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const fs = require("fs"); 3 | const mongoose = require("mongoose"); 4 | const path = require("path"); 5 | 6 | const deleteRecursive = async (item) => { 7 | const children = await FileSystem.find({ parentId: item._id }); 8 | 9 | for (const child of children) { 10 | await deleteRecursive(child); 11 | } 12 | 13 | await FileSystem.findByIdAndDelete(item._id); 14 | }; 15 | 16 | const deleteItem = async (req, res) => { 17 | // #swagger.summary = 'Deletes file/folder(s).' 18 | /* #swagger.parameters['body'] = { 19 | in: 'body', 20 | required: true, 21 | schema: { $ref: "#/definitions/DeleteItems" }, 22 | description: 'An array of item IDs to delete.' 23 | } 24 | */ 25 | /* #swagger.responses[200] = { 26 | schema: {message: "File(s) or Folder(s) deleted successfully."} 27 | } 28 | */ 29 | const { ids } = req.body; 30 | 31 | if (!ids || !Array.isArray(ids) || ids.length === 0) { 32 | return res.status(400).json({ error: "Invalid request body, expected an array of ids." }); 33 | } 34 | 35 | try { 36 | const validIds = ids.filter((id) => mongoose.Types.ObjectId.isValid(id)); 37 | if (validIds.length !== ids.length) { 38 | return res.status(400).json({ error: "One or more of the provided ids are invalid." }); 39 | } 40 | 41 | const items = await FileSystem.find({ _id: { $in: validIds } }); 42 | if (items.length !== validIds.length) { 43 | return res.status(404).json({ error: "One or more of the provided ids do not exist." }); 44 | } 45 | 46 | const deletePromises = items.map(async (item) => { 47 | const itemPath = path.join(__dirname, "../../public/uploads", item.path); 48 | await fs.promises.rm(itemPath, { recursive: true }); 49 | 50 | await deleteRecursive(item); 51 | }); 52 | 53 | await Promise.all(deletePromises); 54 | 55 | res.status(200).json({ message: "File(s) or Folder(s) deleted successfully." }); 56 | } catch (error) { 57 | res.status(500).json({ error: error.message }); 58 | } 59 | }; 60 | 61 | module.exports = deleteItem; 62 | -------------------------------------------------------------------------------- /backend/app/controllers/downloadFile.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const mongoose = require("mongoose"); 5 | const archiver = require("archiver"); 6 | 7 | const downloadFile = async (req, res) => { 8 | // Todo: Update download request query swagger docs. 9 | // #swagger.summary = 'Downloads file/folder(s).' 10 | /* #swagger.parameters['filePath'] = { 11 | in: 'query', 12 | type: 'string', 13 | required: 'true', 14 | } 15 | #swagger.responses[200] = {description:'File Downloaded Successfully'} 16 | */ 17 | try { 18 | let files = req.query.files; 19 | const isSingleFile = mongoose.Types.ObjectId.isValid(files); 20 | const isMultipleFiles = Array.isArray(files); 21 | 22 | if (!files || (!isSingleFile && !isMultipleFiles)) { 23 | return res 24 | .status(400) 25 | .json({ error: "Invalid request body, expected a file ID or an array of file IDs." }); 26 | } 27 | 28 | if (isSingleFile) { 29 | const file = await FileSystem.findById(files); 30 | if (!file) return res.status(404).json({ error: "File not found!" }); 31 | 32 | if (file.isDirectory) { 33 | files = [files]; 34 | } else { 35 | const filePath = path.join(__dirname, "../../public/uploads", file.path); 36 | if (fs.existsSync(filePath)) { 37 | res.setHeader("Content-Disposition", `attachment; filename="${file.name}"`); 38 | return res.sendFile(filePath); 39 | } else { 40 | return res.status(404).send("File not found"); 41 | } 42 | } 43 | } 44 | 45 | const multipleFiles = await FileSystem.find({ _id: { $in: files } }); 46 | if (!multipleFiles || multipleFiles.length !== files.length) { 47 | return res.status(404).json({ error: "One or more of the provided file IDs do not exist." }); 48 | } 49 | 50 | const archive = archiver("zip", { zlib: { level: 9 } }); 51 | 52 | archive.on("error", (err) => { 53 | throw err; 54 | }); 55 | 56 | archive.pipe(res); 57 | 58 | multipleFiles.forEach((file) => { 59 | const filePath = path.join(__dirname, "../../public/uploads", file.path); 60 | if (fs.existsSync(filePath)) { 61 | if (file.isDirectory) { 62 | archive.directory(filePath, file.name); 63 | } else { 64 | archive.file(filePath, { name: file.name }); 65 | } 66 | } else { 67 | console.log("File not found"); 68 | } 69 | }); 70 | 71 | await archive.finalize(); 72 | } catch (error) { 73 | res.status(500).json({ error: error.message }); 74 | } 75 | }; 76 | 77 | module.exports = downloadFile; 78 | -------------------------------------------------------------------------------- /backend/app/controllers/getItems.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | 3 | const getItems = async (req, res) => { 4 | // #swagger.summary = 'Get all items (files & folders)' 5 | try { 6 | const files = await FileSystem.find(); 7 | /* 8 | #swagger.responses[200] = { 9 | description: 'Successful response', 10 | schema:[{$ref: "#/definitions/FileSystem"}] 11 | } 12 | */ 13 | res.status(200).json(files); 14 | } catch (error) { 15 | res.status(500).json({ error: error.message }); 16 | } 17 | }; 18 | 19 | module.exports = getItems; 20 | -------------------------------------------------------------------------------- /backend/app/controllers/moveItem.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const fs = require("fs"); 3 | const mongoose = require("mongoose"); 4 | const path = require("path"); 5 | 6 | const recursiveMove = async (sourceItem, destinationFolder) => { 7 | const moveItem = new FileSystem({ 8 | name: sourceItem.name, 9 | isDirectory: sourceItem.isDirectory, 10 | path: `${destinationFolder?.path ?? ""}/${sourceItem.name}`, 11 | parentId: destinationFolder?._id || null, 12 | size: sourceItem.size, 13 | mimeType: sourceItem.mimeType, 14 | }); 15 | 16 | await moveItem.save(); 17 | await FileSystem.findByIdAndDelete(sourceItem._id); 18 | 19 | const children = await FileSystem.find({ parentId: sourceItem._id }); 20 | for (const child of children) { 21 | await recursiveMove(child, moveItem); 22 | } 23 | }; 24 | 25 | const moveItem = async (req, res) => { 26 | // #swagger.summary = 'Moves file/folder(s) to the destination folder.' 27 | /* #swagger.parameters['body'] = { 28 | in: 'body', 29 | required: true, 30 | schema: { $ref: "#/definitions/CopyItems" }, 31 | description: 'An array of item IDs to move and the destination folder ID.' 32 | } */ 33 | /* #swagger.responses[200] = { 34 | schema: {message: "Item(s) moved successfully!"} 35 | } 36 | */ 37 | 38 | const { sourceIds, destinationId } = req.body; 39 | const isRootDestination = !destinationId; 40 | 41 | if (!sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) { 42 | return res.status(400).json({ error: "Invalid request body, expected an array of sourceIds." }); 43 | } 44 | try { 45 | const validIds = sourceIds.filter((id) => mongoose.Types.ObjectId.isValid(id)); 46 | if (validIds.length !== sourceIds.length) { 47 | return res.status(400).json({ error: "One or more of the provided sourceIds are invalid." }); 48 | } 49 | 50 | const sourceItems = await FileSystem.find({ _id: { $in: validIds } }); 51 | if (sourceItems.length !== validIds.length) { 52 | return res.status(404).json({ error: "One or more of the provided sourceIds do not exist." }); 53 | } 54 | 55 | const movePromises = sourceItems.map(async (sourceItem) => { 56 | const srcFullPath = path.join(__dirname, "../../public/uploads", sourceItem.path); 57 | 58 | if (isRootDestination) { 59 | const destFullPath = path.join(__dirname, "../../public/uploads", sourceItem.name); 60 | await fs.promises.cp(srcFullPath, destFullPath, { recursive: true }); 61 | await fs.promises.rm(srcFullPath, { recursive: true }); 62 | 63 | await recursiveMove(sourceItem, null); 64 | } else { 65 | const destinationFolder = await FileSystem.findById(destinationId); 66 | if (!destinationFolder || !destinationFolder.isDirectory) { 67 | throw new Error("Invalid destinationId!"); 68 | } 69 | 70 | const destFullPath = path.join( 71 | __dirname, 72 | "../../public/uploads", 73 | destinationFolder.path, 74 | sourceItem.name 75 | ); 76 | await fs.promises.cp(srcFullPath, destFullPath, { recursive: true }); 77 | await fs.promises.rm(srcFullPath, { recursive: true }); 78 | 79 | await recursiveMove(sourceItem, destinationFolder); 80 | } 81 | }); 82 | 83 | try { 84 | await Promise.all(movePromises); 85 | } catch (error) { 86 | return res.status(400).json({ error: error.message }); 87 | } 88 | 89 | res.status(200).json({ message: "Item(s) moved successfully!" }); 90 | } catch (error) { 91 | res.status(500).json({ error: error.message }); 92 | } 93 | }; 94 | 95 | module.exports = moveItem; 96 | -------------------------------------------------------------------------------- /backend/app/controllers/renameItem.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | const updateChildernPathRecursive = async (item) => { 6 | const children = await FileSystem.find({ parentId: item._id }); 7 | 8 | for (const child of children) { 9 | child.path = `${item.path}/${child.name}`; 10 | await child.save({ timestamps: false }); 11 | 12 | if (child.isDirectory) updateChildernPathRecursive(child); 13 | } 14 | }; 15 | 16 | const renameItem = async (req, res) => { 17 | // #swagger.summary = 'Renames a file/folder.' 18 | /* #swagger.parameters['body'] = { 19 | in: 'body', 20 | required: true, 21 | schema: { $ref: "#/definitions/RenameItem" } 22 | } */ 23 | /* #swagger.responses[200] = { 24 | schema: { message: "File or Folder renamed successfully!", item: {$ref: "#/definitions/FileSystem"} } 25 | } 26 | */ 27 | try { 28 | const { id, newName } = req.body; 29 | const item = await FileSystem.findById(id); 30 | if (!item) { 31 | return res.status(404).json({ error: "File or Folder not found!" }); 32 | } 33 | 34 | const parentDir = `${path.dirname(item.path)}`; 35 | const newPath = `${parentDir}${parentDir === "/" ? "" : "/"}${newName}`; 36 | 37 | const oldFullPath = path.join(__dirname, "../../public/uploads", item.path); 38 | const newFullPath = path.join(__dirname, "../../public/uploads", newPath); 39 | 40 | if (fs.existsSync(newFullPath)) { 41 | return res.status(400).json({ error: "A file or folder with that name already exists!" }); 42 | } 43 | 44 | await fs.promises.rename(oldFullPath, newFullPath); 45 | 46 | item.name = newName; 47 | item.path = newPath; 48 | 49 | await item.save(); 50 | 51 | if (item.isDirectory) { 52 | await updateChildernPathRecursive(item); 53 | } 54 | 55 | res.status(200).json({ message: "File or Folder renamed successfully!", item }); 56 | } catch (error) { 57 | res.status(500).json({ error: error.message }); 58 | } 59 | }; 60 | 61 | module.exports = renameItem; 62 | -------------------------------------------------------------------------------- /backend/app/controllers/uploadFile.controller.js: -------------------------------------------------------------------------------- 1 | const FileSystem = require("../models/FileSystem.model"); 2 | 3 | const uploadFile = async (req, res) => { 4 | // #swagger.summary = 'Uploads a new file.' 5 | /* 6 | #swagger.auto = false 7 | #swagger.consumes = ['multipart/form-data'] 8 | #swagger.parameters['file'] = { 9 | in: 'formData', 10 | type: 'file', 11 | required: 'true', 12 | } 13 | #swagger.parameters['parentId'] = { 14 | in: 'formData', 15 | type: 'string', 16 | } 17 | #swagger.responses[201] = { 18 | schema: { $ref: '#/definitions/File' } 19 | } 20 | #swagger.responses[400] 21 | #swagger.responses[500] 22 | */ 23 | try { 24 | const { parentId } = req.body; 25 | const file = req.file; 26 | 27 | let filePath = ""; 28 | if (parentId) { 29 | const parentFolder = await FileSystem.findById(parentId); 30 | if (!parentFolder || !parentFolder.isDirectory) { 31 | return res.status(400).json({ error: "Invalid parentId!" }); 32 | } 33 | filePath = `${parentFolder.path}/${file.originalname}`; 34 | } else { 35 | filePath = `/${file.originalname}`; 36 | } 37 | 38 | const newFile = new FileSystem({ 39 | name: file.originalname, 40 | isDirectory: false, 41 | path: filePath, 42 | parentId: parentId || null, 43 | size: file.size, 44 | mimeType: file.mimetype, 45 | }); 46 | 47 | await newFile.save(); 48 | 49 | res.status(201).json(newFile); 50 | } catch (error) { 51 | res.status(500).json({ error: error }); 52 | } 53 | }; 54 | 55 | module.exports = uploadFile; 56 | -------------------------------------------------------------------------------- /backend/app/middlewares/errorHandler.middleware.js: -------------------------------------------------------------------------------- 1 | const multer = require("multer"); 2 | 3 | const errorHandler = (err, req, res, next) => { 4 | if (err instanceof multer.MulterError) { 5 | return res.status(400).json({ error: err.code }); 6 | } 7 | 8 | res.status(500).json({ error: err.message }); 9 | }; 10 | 11 | module.exports = errorHandler; 12 | -------------------------------------------------------------------------------- /backend/app/middlewares/multer.middleware.js: -------------------------------------------------------------------------------- 1 | const multer = require("multer"); 2 | const path = require("path"); 3 | const FileSystem = require("../models/FileSystem.model"); 4 | const fs = require("fs"); 5 | 6 | const storage = multer.diskStorage({ 7 | destination: async (req, file, cb) => { 8 | let uploadPath = path.join(__dirname, "../../public/uploads"); 9 | 10 | if (req.body.parentId) { 11 | try { 12 | const parentFolder = await FileSystem.findById(req.body.parentId); 13 | if (!parentFolder || !parentFolder.isDirectory) { 14 | return cb(new Error("Invalid parentId!"), false); 15 | } 16 | uploadPath = path.join(__dirname, "../../public/uploads", parentFolder.path); 17 | } catch (error) { 18 | return cb(error, false); 19 | } 20 | } 21 | 22 | const fullFilePath = path.join(uploadPath, file.originalname); 23 | if (fs.existsSync(fullFilePath)) { 24 | return cb(new multer.MulterError("File already exists!", file), false); 25 | } 26 | 27 | cb(null, uploadPath); 28 | }, 29 | filename: (req, file, cb) => { 30 | cb(null, file.originalname); 31 | }, 32 | }); 33 | 34 | const upload = multer({ storage }); 35 | 36 | module.exports = upload; 37 | -------------------------------------------------------------------------------- /backend/app/models/FileSystem.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const fileSystemSchema = new mongoose.Schema( 4 | { 5 | name: { 6 | type: String, 7 | required: true, 8 | }, 9 | isDirectory: { 10 | type: Boolean, 11 | required: true, 12 | }, 13 | path: { 14 | type: String, 15 | required: true, 16 | }, 17 | parentId: { 18 | type: mongoose.Schema.Types.ObjectId, 19 | ref: "FileSystem", 20 | default: null, 21 | }, 22 | size: { 23 | type: Number, 24 | default: null, // For files only 25 | }, 26 | mimeType: { 27 | type: String, 28 | default: null, // For files only 29 | }, 30 | }, 31 | { timestamps: true } 32 | ); 33 | 34 | const FileSystem = mongoose.model("FileSystem", fileSystemSchema); 35 | 36 | module.exports = FileSystem; 37 | -------------------------------------------------------------------------------- /backend/app/routes/fileSystem.routes.js: -------------------------------------------------------------------------------- 1 | const router = require("express").Router(); 2 | const upload = require("../middlewares/multer.middleware"); 3 | const createFolderController = require("../controllers/createFolder.controller"); 4 | const uploadFileController = require("../controllers/uploadFile.controller"); 5 | const getItemsController = require("../controllers/getItems.controller"); 6 | const copyItemController = require("../controllers/copyItem.controller"); 7 | const moveItemController = require("../controllers/moveItem.controller"); 8 | const renameItemController = require("../controllers/renameItem.controller"); 9 | const deleteItemController = require("../controllers/deleteItem.controller"); 10 | const downloadFileController = require("../controllers/downloadFile.controller"); 11 | 12 | router.post("/folder", createFolderController); 13 | router.post("/upload", upload.single("file"), uploadFileController); 14 | router.post("/copy", copyItemController); 15 | router.get("/", getItemsController); 16 | router.get("/download", downloadFileController); 17 | router.put("/move", moveItemController); 18 | router.patch("/rename", renameItemController); 19 | router.delete("/", deleteItemController); 20 | 21 | module.exports = router; 22 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "devStart": "nodemon server.js", 7 | "start": "node server.js", 8 | "genDocs": "node swagger.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "description": "", 14 | "dependencies": { 15 | "archiver": "^7.0.1", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.4.5", 18 | "express": "^4.19.2", 19 | "mongoose": "^8.5.4", 20 | "multer": "^1.4.5-lts.1", 21 | "swagger-ui-express": "^5.0.1" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^3.1.4", 25 | "swagger-autogen": "^2.23.7" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /backend/public/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Saifullah-dev/react-file-manager/bb1a0ddc297b69f20e1db06a4f772324ab6a652c/backend/public/uploads/.gitkeep -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const connectDB = require("./app/config/db.config"); 3 | const cors = require("cors"); 4 | const fileSystemRoutes = require("./app/routes/fileSystem.routes"); 5 | const errorHandler = require("./app/middlewares/errorHandler.middleware"); 6 | const dotenv = require("dotenv"); 7 | const swaggerUi = require("swagger-ui-express"); 8 | const swaggerDocument = require("./swagger-output.json"); 9 | 10 | dotenv.config(); 11 | 12 | const app = express(); 13 | 14 | // Database connection 15 | connectDB(); 16 | 17 | // CORS setup 18 | app.use(cors({ origin: process.env.CLIENT_URI })); 19 | 20 | // Static files serving 21 | app.use(express.static("public/uploads")); 22 | 23 | // Middlewares to parse URL-encoded body & JSON 24 | app.use(express.urlencoded({ extended: true })); 25 | app.use(express.json()); 26 | 27 | // Routes 28 | app.use("/api/file-system", fileSystemRoutes); 29 | 30 | // Swagger documentation 31 | app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument)); 32 | 33 | // Error handling middleware 34 | app.use(errorHandler); 35 | 36 | const PORT = process.env.PORT || 3000; 37 | 38 | app.listen(PORT, () => { 39 | console.log(`Server running on port ${PORT}`); 40 | }); 41 | -------------------------------------------------------------------------------- /backend/swagger.js: -------------------------------------------------------------------------------- 1 | const swaggerAutogen = require("swagger-autogen")(); 2 | const dotenv = require("dotenv"); 3 | 4 | dotenv.config(); 5 | 6 | const PORT = process.env.PORT || 3000; 7 | 8 | const doc = { 9 | info: { 10 | version: "1.0.0", 11 | title: "File System API", 12 | description: 13 | "API for managing files and folders. \n \n**Note: Use parentId: null | '' for root folder.**", 14 | }, 15 | host: `localhost:${PORT}`, 16 | basePath: "/api/file-system", 17 | schemes: ["http"], 18 | consumes: ["application/json"], 19 | produces: ["application/json"], 20 | tags: [ 21 | { 22 | name: "File Operations", 23 | description: "Endpoints", 24 | }, 25 | ], 26 | "@definitions": { 27 | FileSystem: { 28 | type: "object", 29 | properties: { 30 | _id: { 31 | type: "string", 32 | description: "The unique identifier of the item (ObjectId)", 33 | example: "60d0fe4f5311236168a109ca", 34 | }, 35 | name: { 36 | type: "string", 37 | description: "Name of the file or folder", 38 | example: "Videos", 39 | }, 40 | isDirectory: { 41 | type: "boolean", 42 | description: "Indicates if the item is a folder", 43 | example: true, 44 | }, 45 | path: { 46 | type: "string", 47 | description: "Path of the file or folder", 48 | example: "Files/Videos", 49 | }, 50 | parentId: { 51 | type: "string", 52 | description: "The parent item ID (ObjectId)", 53 | example: "60d0fe4f5311236168a109cb", 54 | nullable: true, 55 | }, 56 | size: { 57 | type: "number", 58 | description: "Size of the file in bytes", 59 | example: null, 60 | nullable: true, 61 | }, 62 | mimeType: { 63 | type: "string", 64 | description: "MIME type of the file", 65 | example: null, 66 | nullable: true, 67 | }, 68 | }, 69 | }, 70 | }, 71 | definitions: { 72 | Folder: { 73 | _id: "60d0fe4f5311236168a109ca", 74 | name: "Documents", 75 | isDirectory: true, 76 | path: "/Files/Documents", 77 | parentId: "60d0fe4f5311236168a109cb", 78 | size: null, 79 | mimeType: null, 80 | createdAt: "2024-09-25T12:34:29.490Z", 81 | updatedAt: "2024-09-25T12:34:29.490Z", 82 | }, 83 | File: { 84 | _id: "50e6ge6d5347836199z314xc", 85 | name: "Requirements.pdf", 86 | isDirectory: true, 87 | path: "/Files/Documents/Requirements.pdf", 88 | parentId: "60d0fe4f5311236168a109ca", 89 | size: 1791868, 90 | mimeType: "application/pdf", 91 | createdAt: "2024-09-04T11:28:13.882Z", 92 | updatedAt: "2024-09-04T11:28:13.882Z", 93 | }, 94 | CreateFolder: { 95 | $name: "Pictures", 96 | parentId: "", 97 | }, 98 | CopyItems: { 99 | $sourceIds: ["50e6ge6d5347836199z314xc"], 100 | destinationId: "60d0fe4f5311236168a109cb", 101 | }, 102 | RenameItem: { 103 | $id: "50e6ge6d4568712390z314xc", 104 | $newName: "React File Manager.png", 105 | }, 106 | DeleteItems: { 107 | $ids: ["50e6ge6d4568712390z314xc"], 108 | }, 109 | Error: { 110 | error: "Error message", 111 | }, 112 | }, 113 | }; 114 | 115 | const outputFile = "./swagger-output.json"; 116 | const endpointsFiles = ["./app/routes/fileSystem.routes.js"]; 117 | 118 | swaggerAutogen(outputFile, endpointsFiles, doc).then(({ data }) => { 119 | // Automate apply tags & Error schema to all endpoints 120 | Object.keys(data.paths).forEach((path) => { 121 | Object.keys(data.paths[path]).forEach((method) => { 122 | if (!data.paths[path][method].tags) { 123 | data.paths[path][method].tags = []; 124 | } 125 | data.paths[path][method].tags = ["File Operations"]; 126 | 127 | if (data.paths[path][method].responses) { 128 | ["400", "404", "500"].forEach((statusCode) => { 129 | if (data.paths[path][method].responses[statusCode]) { 130 | data.paths[path][method].responses[statusCode].schema = { 131 | $ref: "#/definitions/Error", 132 | }; 133 | } 134 | }); 135 | } 136 | }); 137 | }); 138 | 139 | const fs = require("fs"); 140 | fs.writeFileSync(outputFile, JSON.stringify(data, null, 2)); 141 | // 142 | 143 | require("./server"); 144 | }); 145 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_BASE_URL=http://localhost:3000/api/file-system 2 | VITE_API_FILES_BASE_URL=http://localhost:3000 -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:react/recommended', 7 | 'plugin:react/jsx-runtime', 8 | 'plugin:react-hooks/recommended', 9 | ], 10 | ignorePatterns: ['dist', '.eslintrc.cjs'], 11 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 12 | settings: { react: { version: '18.2' } }, 13 | plugins: ['react-refresh'], 14 | rules: { 15 | 'react/jsx-no-target-blank': 'off', 16 | 'react-refresh/only-export-components': [ 17 | 'warn', 18 | { allowConstantExport: true }, 19 | ], 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /frontend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Saifullah Zubair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React File Manager 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cubone/react-file-manager", 3 | "private": false, 4 | "version": "1.10.5", 5 | "type": "module", 6 | "module": "dist/react-file-manager.es.js", 7 | "files": [ 8 | "dist/", 9 | "README.md", 10 | "LICENSE" 11 | ], 12 | "publishConfig": { 13 | "access": "public" 14 | }, 15 | "scripts": { 16 | "dev": "vite", 17 | "build": "vite build", 18 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 19 | "preview": "vite preview", 20 | "semantic-release": "semantic-release" 21 | }, 22 | "dependencies": { 23 | "i18next": "^25.0.0", 24 | "react-collapsed": "^4.2.0", 25 | "react-icons": "^5.4.0" 26 | }, 27 | "devDependencies": { 28 | "@types/react": "^18.3.3", 29 | "@types/react-dom": "^18.3.0", 30 | "@vitejs/plugin-react-swc": "^3.5.0", 31 | "axios": "^1.7.7", 32 | "eslint": "^8.57.0", 33 | "eslint-plugin-react": "^7.34.2", 34 | "eslint-plugin-react-hooks": "^4.6.2", 35 | "eslint-plugin-react-refresh": "^0.4.7", 36 | "react": "^18.3.1", 37 | "react-dom": "^18.3.1", 38 | "sass": "^1.77.6", 39 | "semantic-release": "^24.1.0", 40 | "vite": "^6.3.4" 41 | }, 42 | "peerDependencies": { 43 | "react": ">=18", 44 | "react-dom": ">=18" 45 | }, 46 | "peerDependenciesMeta": { 47 | "react": { 48 | "optional": true 49 | }, 50 | "react-dom": { 51 | "optional": true 52 | } 53 | }, 54 | "description": "React File Manager is an open-source, user-friendly component designed to easily manage files and folders within applications. With smooth drag-and-drop functionality, responsive design, and efficient navigation, it simplifies file handling in any React project.", 55 | "main": "src/index.js", 56 | "repository": { 57 | "type": "git", 58 | "url": "git+https://github.com/Saifullah-dev/react-file-manager.git" 59 | }, 60 | "keywords": [ 61 | "react", 62 | "file-manager", 63 | "component", 64 | "react-file explorer" 65 | ], 66 | "author": "Saifullah Zubair", 67 | "license": "MIT", 68 | "bugs": { 69 | "url": "https://github.com/Saifullah-dev/react-file-manager/issues" 70 | }, 71 | "homepage": "https://github.com/Saifullah-dev/react-file-manager#readme" 72 | } 73 | -------------------------------------------------------------------------------- /frontend/public/react-file-manager-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Saifullah-dev/react-file-manager/bb1a0ddc297b69f20e1db06a4f772324ab6a652c/frontend/public/react-file-manager-logo.png -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import FileManager from "./FileManager/FileManager"; 3 | import { createFolderAPI } from "./api/createFolderAPI"; 4 | import { renameAPI } from "./api/renameAPI"; 5 | import { deleteAPI } from "./api/deleteAPI"; 6 | import { copyItemAPI, moveItemAPI } from "./api/fileTransferAPI"; 7 | import { getAllFilesAPI } from "./api/getAllFilesAPI"; 8 | import { downloadFile } from "./api/downloadFileAPI"; 9 | import "./App.scss"; 10 | 11 | function App() { 12 | const fileUploadConfig = { 13 | url: import.meta.env.VITE_API_BASE_URL + "/upload", 14 | }; 15 | const [isLoading, setIsLoading] = useState(false); 16 | const [files, setFiles] = useState([]); 17 | const isMountRef = useRef(false); 18 | 19 | // Get Files 20 | const getFiles = async () => { 21 | setIsLoading(true); 22 | const response = await getAllFilesAPI(); 23 | setFiles(response.data); 24 | setIsLoading(false); 25 | }; 26 | 27 | useEffect(() => { 28 | if (isMountRef.current) return; 29 | isMountRef.current = true; 30 | getFiles(); 31 | }, []); 32 | // 33 | 34 | // Create Folder 35 | const handleCreateFolder = async (name, parentFolder) => { 36 | setIsLoading(true); 37 | const response = await createFolderAPI(name, parentFolder?._id); 38 | if (response.status === 200 || response.status === 201) { 39 | setFiles((prev) => [...prev, response.data]); 40 | } else { 41 | console.error(response); 42 | } 43 | setIsLoading(false); 44 | }; 45 | // 46 | 47 | // File Upload Handlers 48 | const handleFileUploading = (file, parentFolder) => { 49 | return { parentId: parentFolder?._id }; 50 | }; 51 | 52 | const handleFileUploaded = (response) => { 53 | const uploadedFile = JSON.parse(response); 54 | setFiles((prev) => [...prev, uploadedFile]); 55 | }; 56 | // 57 | 58 | // Rename File/Folder 59 | const handleRename = async (file, newName) => { 60 | setIsLoading(true); 61 | const response = await renameAPI(file._id, newName); 62 | if (response.status === 200) { 63 | getFiles(); 64 | } else { 65 | console.error(response); 66 | } 67 | setIsLoading(false); 68 | }; 69 | // 70 | 71 | // Delete File/Folder 72 | const handleDelete = async (files) => { 73 | setIsLoading(true); 74 | const idsToDelete = files.map((file) => file._id); 75 | const response = await deleteAPI(idsToDelete); 76 | if (response.status === 200) { 77 | getFiles(); 78 | } else { 79 | console.error(response); 80 | setIsLoading(false); 81 | } 82 | }; 83 | // 84 | 85 | // Paste File/Folder 86 | const handlePaste = async (copiedItems, destinationFolder, operationType) => { 87 | setIsLoading(true); 88 | const copiedItemIds = copiedItems.map((item) => item._id); 89 | if (operationType === "copy") { 90 | const response = await copyItemAPI(copiedItemIds, destinationFolder?._id); 91 | } else { 92 | const response = await moveItemAPI(copiedItemIds, destinationFolder?._id); 93 | } 94 | await getFiles(); 95 | }; 96 | // 97 | 98 | const handleLayoutChange = (layout) => { 99 | console.log(layout); 100 | }; 101 | 102 | // Refresh Files 103 | const handleRefresh = () => { 104 | getFiles(); 105 | }; 106 | // 107 | 108 | const handleFileOpen = (file) => { 109 | console.log(`Opening file: ${file.name}`); 110 | }; 111 | 112 | const handleError = (error, file) => { 113 | console.error(error); 114 | }; 115 | 116 | const handleDownload = async (files) => { 117 | await downloadFile(files); 118 | }; 119 | 120 | const handleCut = (files) => { 121 | console.log("Moving Files", files); 122 | }; 123 | 124 | const handleCopy = (files) => { 125 | console.log("Copied Files", files); 126 | }; 127 | 128 | const handleSelect = (files) => { 129 | console.log("Selected Files", files); 130 | }; 131 | 132 | return ( 133 |
134 |
135 | 162 |
163 |
164 | ); 165 | } 166 | 167 | export default App; 168 | -------------------------------------------------------------------------------- /frontend/src/App.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | .app { 6 | height: 100dvh; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | background-color: #32363a; 11 | 12 | .file-manager-container { 13 | height: 70%; 14 | width: 67%; 15 | } 16 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/Actions.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Modal from "../../components/Modal/Modal"; 3 | import DeleteAction from "./Delete/Delete.action"; 4 | import UploadFileAction from "./UploadFile/UploadFile.action"; 5 | import PreviewFileAction from "./PreviewFile/PreviewFile.action"; 6 | import { useSelection } from "../../contexts/SelectionContext"; 7 | import { useShortcutHandler } from "../../hooks/useShortcutHandler"; 8 | import { useTranslation } from "../../contexts/TranslationProvider"; 9 | 10 | const Actions = ({ 11 | fileUploadConfig, 12 | onFileUploading, 13 | onFileUploaded, 14 | onDelete, 15 | onRefresh, 16 | maxFileSize, 17 | filePreviewPath, 18 | filePreviewComponent, 19 | acceptedFileTypes, 20 | triggerAction, 21 | permissions, 22 | }) => { 23 | const [activeAction, setActiveAction] = useState(null); 24 | const { selectedFiles } = useSelection(); 25 | const t = useTranslation(); 26 | 27 | // Triggers all the keyboard shortcuts based actions 28 | useShortcutHandler(triggerAction, onRefresh, permissions); 29 | 30 | const actionTypes = { 31 | uploadFile: { 32 | title: t("upload"), 33 | component: ( 34 | 41 | ), 42 | width: "35%", 43 | }, 44 | delete: { 45 | title: t("delete"), 46 | component: , 47 | width: "25%", 48 | }, 49 | previewFile: { 50 | title: t("preview"), 51 | component: ( 52 | 56 | ), 57 | width: "50%", 58 | }, 59 | }; 60 | 61 | useEffect(() => { 62 | if (triggerAction.isActive) { 63 | const actionType = triggerAction.actionType; 64 | if (actionType === "previewFile") { 65 | actionTypes[actionType].title = selectedFiles?.name ?? t("preview"); 66 | } 67 | setActiveAction(actionTypes[actionType]); 68 | } else { 69 | setActiveAction(null); 70 | } 71 | }, [triggerAction.isActive]); 72 | 73 | if (activeAction) { 74 | return ( 75 | 81 | {activeAction?.component} 82 | 83 | ); 84 | } 85 | }; 86 | 87 | export default Actions; 88 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/CreateFolder/CreateFolder.action.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useDetectOutsideClick } from "../../../hooks/useDetectOutsideClick"; 3 | import { duplicateNameHandler } from "../../../utils/duplicateNameHandler"; 4 | import NameInput from "../../../components/NameInput/NameInput"; 5 | import ErrorTooltip from "../../../components/ErrorTooltip/ErrorTooltip"; 6 | import { useFileNavigation } from "../../../contexts/FileNavigationContext"; 7 | import { useLayout } from "../../../contexts/LayoutContext"; 8 | import { validateApiCallback } from "../../../utils/validateApiCallback"; 9 | import { useTranslation } from "../../../contexts/TranslationProvider"; 10 | 11 | const maxNameLength = 220; 12 | 13 | const CreateFolderAction = ({ filesViewRef, file, onCreateFolder, triggerAction }) => { 14 | const [folderName, setFolderName] = useState(file.name); 15 | const [folderNameError, setFolderNameError] = useState(false); 16 | const [folderErrorMessage, setFolderErrorMessage] = useState(""); 17 | const [errorXPlacement, setErrorXPlacement] = useState("right"); 18 | const [errorYPlacement, setErrorYPlacement] = useState("bottom"); 19 | const outsideClick = useDetectOutsideClick((e) => { 20 | e.preventDefault(); 21 | e.stopPropagation(); 22 | }); 23 | const { currentFolder, currentPathFiles, setCurrentPathFiles } = useFileNavigation(); 24 | const { activeLayout } = useLayout(); 25 | const t = useTranslation(); 26 | 27 | // Folder name change handler function 28 | const handleFolderNameChange = (e) => { 29 | setFolderName(e.target.value); 30 | setFolderNameError(false); 31 | }; 32 | // 33 | 34 | // Validate folder name and call "onCreateFolder" function 35 | const handleValidateFolderName = (e) => { 36 | e.stopPropagation(); 37 | if (e.key === "Enter") { 38 | e.preventDefault(); 39 | handleFolderCreating(); 40 | return; 41 | } 42 | 43 | if (e.key === "Escape") { 44 | e.preventDefault(); 45 | triggerAction.close(); 46 | setCurrentPathFiles((prev) => prev.filter((f) => f.key !== file.key)); 47 | return; 48 | } 49 | 50 | const invalidCharsRegex = /[\\/:*?"<>|]/; 51 | if (invalidCharsRegex.test(e.key)) { 52 | e.preventDefault(); 53 | setFolderErrorMessage(t("invalidFileName")); 54 | setFolderNameError(true); 55 | } else { 56 | setFolderNameError(false); 57 | setFolderErrorMessage(""); 58 | } 59 | }; 60 | 61 | // Auto hide error message after 7 seconds 62 | useEffect(() => { 63 | if (folderNameError) { 64 | const autoHideError = setTimeout(() => { 65 | setFolderNameError(false); 66 | setFolderErrorMessage(""); 67 | }, 7000); 68 | 69 | return () => clearTimeout(autoHideError); 70 | } 71 | }, [folderNameError]); 72 | // 73 | 74 | function handleFolderCreating() { 75 | let newFolderName = folderName.trim(); 76 | const syncedCurrPathFiles = currentPathFiles.filter((f) => !(!!f.key && f.key === file.key)); 77 | 78 | const alreadyExists = syncedCurrPathFiles.find((f) => { 79 | return f.name.toLowerCase() === newFolderName.toLowerCase(); 80 | }); 81 | 82 | if (alreadyExists) { 83 | setFolderErrorMessage(t("folderExists", { renameFile: newFolderName })); 84 | setFolderNameError(true); 85 | outsideClick.ref.current?.focus(); 86 | outsideClick.ref.current?.select(); 87 | outsideClick.setIsClicked(false); 88 | return; 89 | } 90 | 91 | if (newFolderName === "") { 92 | newFolderName = duplicateNameHandler("New Folder", true, syncedCurrPathFiles); 93 | } 94 | 95 | validateApiCallback(onCreateFolder, "onCreateFolder", newFolderName, currentFolder); 96 | setCurrentPathFiles((prev) => prev.filter((f) => f.key !== file.key)); 97 | triggerAction.close(); 98 | } 99 | // 100 | 101 | // Folder name text selection upon visible 102 | useEffect(() => { 103 | outsideClick.ref.current?.focus(); 104 | outsideClick.ref.current?.select(); 105 | 106 | // Dynamic Error Message Placement based on available space 107 | if (outsideClick.ref?.current) { 108 | const errorMessageWidth = 292 + 8 + 8 + 5; // 8px padding on left and right + additional 5px for gap 109 | const errorMessageHeight = 56 + 20 + 10 + 2; // 20px :before height 110 | const filesContainer = filesViewRef.current; 111 | const filesContainerRect = filesContainer.getBoundingClientRect(); 112 | const nameInputContainer = outsideClick.ref.current; 113 | const nameInputContainerRect = nameInputContainer.getBoundingClientRect(); 114 | 115 | const rightAvailableSpace = filesContainerRect.right - nameInputContainerRect.left; 116 | rightAvailableSpace > errorMessageWidth 117 | ? setErrorXPlacement("right") 118 | : setErrorXPlacement("left"); 119 | 120 | const bottomAvailableSpace = 121 | filesContainerRect.bottom - (nameInputContainerRect.top + nameInputContainer.clientHeight); 122 | bottomAvailableSpace > errorMessageHeight 123 | ? setErrorYPlacement("bottom") 124 | : setErrorYPlacement("top"); 125 | } 126 | }, []); 127 | // 128 | 129 | useEffect(() => { 130 | if (outsideClick.isClicked) { 131 | handleFolderCreating(); 132 | } 133 | }, [outsideClick.isClicked]); 134 | 135 | return ( 136 | <> 137 | e.stopPropagation()} 144 | {...(activeLayout === "list" && { rows: 1 })} 145 | /> 146 | {folderNameError && ( 147 | 152 | )} 153 | 154 | ); 155 | }; 156 | 157 | export default CreateFolderAction; 158 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/Delete/Delete.action.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import Button from "../../../components/Button/Button"; 3 | import { useSelection } from "../../../contexts/SelectionContext"; 4 | import { useTranslation } from "../../../contexts/TranslationProvider"; 5 | import "./Delete.action.scss"; 6 | 7 | const DeleteAction = ({ triggerAction, onDelete }) => { 8 | const [deleteMsg, setDeleteMsg] = useState(""); 9 | const { selectedFiles, setSelectedFiles } = useSelection(); 10 | const t = useTranslation(); 11 | 12 | useEffect(() => { 13 | setDeleteMsg(() => { 14 | if (selectedFiles.length === 1) { 15 | return t("deleteItemConfirm", { fileName: selectedFiles[0].name }); 16 | } else if (selectedFiles.length > 1) { 17 | return t("deleteItemsConfirm", { count: selectedFiles.length }); 18 | } 19 | }); 20 | }, [t]); 21 | 22 | const handleDeleting = () => { 23 | onDelete(selectedFiles); 24 | setSelectedFiles([]); 25 | triggerAction.close(); 26 | }; 27 | 28 | return ( 29 |
30 |

{deleteMsg}

31 |
32 | 35 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default DeleteAction; 44 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/Delete/Delete.action.scss: -------------------------------------------------------------------------------- 1 | .file-delete-confirm { 2 | .file-delete-confirm-text { 3 | border-bottom: 1px solid #dddddd; 4 | padding: 15px; 5 | margin-top: 0; 6 | margin-bottom: .7rem; 7 | word-wrap: break-word; 8 | font-weight: 500; 9 | } 10 | 11 | .file-delete-confirm-actions { 12 | display: flex; 13 | gap: 0.5rem; 14 | justify-content: flex-end; 15 | margin-bottom: .7rem; 16 | margin-right: 1rem; 17 | } 18 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from "react"; 2 | import { getFileExtension } from "../../../utils/getFileExtension"; 3 | import Loader from "../../../components/Loader/Loader"; 4 | import { useSelection } from "../../../contexts/SelectionContext"; 5 | import Button from "../../../components/Button/Button"; 6 | import { getDataSize } from "../../../utils/getDataSize"; 7 | import { MdOutlineFileDownload } from "react-icons/md"; 8 | import { useFileIcons } from "../../../hooks/useFileIcons"; 9 | import { FaRegFileAlt } from "react-icons/fa"; 10 | import { useTranslation } from "../../../contexts/TranslationProvider"; 11 | import "./PreviewFile.action.scss"; 12 | 13 | const imageExtensions = ["jpg", "jpeg", "png"]; 14 | const videoExtensions = ["mp4", "mov", "avi"]; 15 | const audioExtensions = ["mp3", "wav", "m4a"]; 16 | const iFrameExtensions = ["txt", "pdf"]; 17 | 18 | const PreviewFileAction = ({ filePreviewPath, filePreviewComponent }) => { 19 | const [isLoading, setIsLoading] = useState(true); 20 | const [hasError, setHasError] = useState(false); 21 | const { selectedFiles } = useSelection(); 22 | const fileIcons = useFileIcons(73); 23 | const extension = getFileExtension(selectedFiles[0].name)?.toLowerCase(); 24 | const filePath = `${filePreviewPath}${selectedFiles[0].path}`; 25 | const t = useTranslation(); 26 | 27 | // Custom file preview component 28 | const customPreview = useMemo( 29 | () => filePreviewComponent?.(selectedFiles[0]), 30 | [filePreviewComponent] 31 | ); 32 | 33 | const handleImageLoad = () => { 34 | setIsLoading(false); // Loading is complete 35 | setHasError(false); // No error 36 | }; 37 | 38 | const handleImageError = () => { 39 | setIsLoading(false); // Loading is complete 40 | setHasError(true); // Error occurred 41 | }; 42 | 43 | const handleDownload = () => { 44 | window.location.href = filePath; 45 | }; 46 | 47 | if (React.isValidElement(customPreview)) { 48 | return customPreview; 49 | } 50 | 51 | return ( 52 |
53 | {hasError || 54 | (![ 55 | ...imageExtensions, 56 | ...videoExtensions, 57 | ...audioExtensions, 58 | ...iFrameExtensions, 59 | ].includes(extension) && ( 60 |
61 | {fileIcons[extension] ?? } 62 | {t("previewUnavailable")} 63 |
64 | {selectedFiles[0].name} 65 | {selectedFiles[0].size && -} 66 | {getDataSize(selectedFiles[0].size)} 67 |
68 | 74 |
75 | ))} 76 | {imageExtensions.includes(extension) && ( 77 | <> 78 | 79 | Preview Unavailable 87 | 88 | )} 89 | {videoExtensions.includes(extension) && ( 90 |
107 | ); 108 | }; 109 | 110 | export default PreviewFileAction; 111 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.scss: -------------------------------------------------------------------------------- 1 | .file-previewer { 2 | padding: .8em; 3 | height: 40dvh; 4 | display: flex; 5 | justify-content: center; 6 | 7 | .photo-popup-image { 8 | object-fit: contain; 9 | width: -webkit-fill-available; 10 | opacity: 1; 11 | transition: opacity 0.5s ease-in-out; 12 | } 13 | 14 | .img-loading { 15 | opacity: 0; 16 | height: 0%; 17 | width: 0%; 18 | } 19 | 20 | .img-loading { 21 | height: 0; 22 | } 23 | 24 | .audio-preview { 25 | align-self: center; 26 | width: 60%; 27 | } 28 | 29 | .photo-popup-iframe { 30 | width: -webkit-fill-available; 31 | } 32 | 33 | .preview-error { 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | flex-direction: column; 38 | gap: 10px; 39 | 40 | .error-icon { 41 | color: rgb(73, 73, 73); 42 | } 43 | 44 | .error-msg { 45 | font-weight: 500; 46 | font-size: 1.1em; 47 | margin-bottom: 4px; 48 | } 49 | 50 | .file-info { 51 | display: flex; 52 | gap: 6px; 53 | align-items: center; 54 | margin: 1px 0 5px 0; 55 | 56 | .file-name { 57 | padding: 4px 15px; 58 | background-color: rgb(233, 233, 233); 59 | border: 1px solid rgb(163, 173, 173); 60 | border-radius: 3px; 61 | } 62 | 63 | .file-size { 64 | font-size: .8em; 65 | } 66 | } 67 | 68 | .download-btn { 69 | display: flex; 70 | gap: 3px; 71 | align-items: center; 72 | } 73 | 74 | } 75 | } 76 | 77 | .file-previewer.pdf-previewer { 78 | height: 85dvh; 79 | } 80 | 81 | .video-preview { 82 | width: -webkit-fill-available; 83 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/UploadFile/UploadFile.action.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from "react"; 2 | import Button from "../../../components/Button/Button"; 3 | import { AiOutlineCloudUpload } from "react-icons/ai"; 4 | import UploadItem from "./UploadItem"; 5 | import Loader from "../../../components/Loader/Loader"; 6 | import { useFileNavigation } from "../../../contexts/FileNavigationContext"; 7 | import { getFileExtension } from "../../../utils/getFileExtension"; 8 | import { getDataSize } from "../../../utils/getDataSize"; 9 | import { useFiles } from "../../../contexts/FilesContext"; 10 | import { useTranslation } from "../../../contexts/TranslationProvider"; 11 | import "./UploadFile.action.scss"; 12 | 13 | const UploadFileAction = ({ 14 | fileUploadConfig, 15 | maxFileSize, 16 | acceptedFileTypes, 17 | onFileUploading, 18 | onFileUploaded, 19 | }) => { 20 | const [files, setFiles] = useState([]); 21 | const [isDragging, setIsDragging] = useState(false); 22 | const [isUploading, setIsUploading] = useState({}); 23 | const { currentFolder, currentPathFiles } = useFileNavigation(); 24 | const { onError } = useFiles(); 25 | const fileInputRef = useRef(null); 26 | const t = useTranslation(); 27 | 28 | // To open choose file if the "Choose File" button is focused and Enter key is pressed 29 | const handleChooseFileKeyDown = (e) => { 30 | if (e.key === "Enter") { 31 | fileInputRef.current.click(); 32 | } 33 | }; 34 | 35 | const checkFileError = (file) => { 36 | if (acceptedFileTypes) { 37 | const extError = !acceptedFileTypes.includes(getFileExtension(file.name)); 38 | if (extError) return t("fileTypeNotAllowed"); 39 | } 40 | 41 | const fileExists = currentPathFiles.some( 42 | (item) => item.name.toLowerCase() === file.name.toLowerCase() && !item.isDirectory 43 | ); 44 | if (fileExists) return t("fileAlreadyExist"); 45 | 46 | const sizeError = maxFileSize && file.size > maxFileSize; 47 | if (sizeError) return `${t("maxUploadSize")} ${getDataSize(maxFileSize, 0)}.`; 48 | }; 49 | 50 | const setSelectedFiles = (selectedFiles) => { 51 | selectedFiles = selectedFiles.filter( 52 | (item) => 53 | !files.some((fileData) => fileData.file.name.toLowerCase() === item.name.toLowerCase()) 54 | ); 55 | 56 | if (selectedFiles.length > 0) { 57 | const newFiles = selectedFiles.map((file) => { 58 | const appendData = onFileUploading(file, currentFolder); 59 | const error = checkFileError(file); 60 | error && onError({ type: "upload", message: error }, file); 61 | return { 62 | file: file, 63 | appendData: appendData, 64 | ...(error && { error: error }), 65 | }; 66 | }); 67 | setFiles((prev) => [...prev, ...newFiles]); 68 | } 69 | }; 70 | 71 | // Todo: Also validate allowed file extensions on drop 72 | const handleDrop = (e) => { 73 | e.preventDefault(); 74 | setIsDragging(false); 75 | const droppedFiles = Array.from(e.dataTransfer.files); 76 | setSelectedFiles(droppedFiles); 77 | }; 78 | 79 | const handleChooseFile = (e) => { 80 | const choosenFiles = Array.from(e.target.files); 81 | setSelectedFiles(choosenFiles); 82 | }; 83 | 84 | const handleFileRemove = (index) => { 85 | setFiles((prev) => { 86 | const newFiles = prev.map((file, i) => { 87 | if (index === i) { 88 | return { 89 | ...file, 90 | removed: true, 91 | }; 92 | } 93 | return file; 94 | }); 95 | 96 | // If every file is removed, empty files array 97 | if (newFiles.every((file) => !!file.removed)) return []; 98 | 99 | return newFiles; 100 | }); 101 | }; 102 | 103 | return ( 104 |
0 ? "file-selcted" : ""}`}> 105 |
106 |
e.preventDefault()} 110 | onDragEnter={() => setIsDragging(true)} 111 | onDragLeave={() => setIsDragging(false)} 112 | > 113 |
114 | 115 | {t("dragFileToUpload")} 116 |
117 |
118 |
119 | 131 |
132 |
133 | {files.length > 0 && ( 134 |
135 |
136 | {Object.values(isUploading).some((fileUploading) => fileUploading) ? ( 137 | <> 138 |

{t("uploading")}

139 | 140 | 141 | ) : ( 142 |

{t("completed")}

143 | )} 144 |
145 |
    146 | {files.map((fileData, index) => ( 147 | 157 | ))} 158 |
159 |
160 | )} 161 |
162 | ); 163 | }; 164 | 165 | export default UploadFileAction; 166 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/UploadFile/UploadFile.action.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .fm-upload-file { 4 | padding: 18px 15px; 5 | display: flex; 6 | gap: 18px; 7 | 8 | .select-files { 9 | width: 100%; 10 | 11 | .draggable-file-input { 12 | color: #696969; 13 | background-color: #f7f7f7; 14 | margin-bottom: 18px; 15 | height: 220px; 16 | border: 2px dashed #ccc; 17 | border-radius: 5px; 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | 22 | .input-text { 23 | pointer-events: none; // This ensures that the drag and drop event isn't binded with it's children 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | } 28 | 29 | &:hover { 30 | border-color: var(--file-manager-primary-color); 31 | } 32 | } 33 | 34 | .draggable-file-input.dragging { 35 | border-color: var(--file-manager-primary-color); 36 | } 37 | 38 | .btn-choose-file { 39 | display: flex; 40 | justify-content: center; 41 | 42 | label { 43 | display: inline-block; 44 | padding: 0.4rem 0.8rem; 45 | 46 | &:hover { 47 | cursor: pointer; 48 | } 49 | } 50 | 51 | .choose-file-input { 52 | display: none; 53 | } 54 | } 55 | } 56 | 57 | .files-progress { 58 | width: 60%; 59 | 60 | .heading { 61 | display: flex; 62 | gap: 4px; 63 | } 64 | 65 | h2 { 66 | font-size: 0.9em; 67 | margin: 0; 68 | } 69 | 70 | ul { 71 | padding-left: 0px; 72 | padding-right: 5px; 73 | padding-bottom: 10px; 74 | margin-top: 0.7rem; 75 | height: 220px; 76 | @include overflow-y-scroll; 77 | font-weight: 500; 78 | 79 | li { 80 | list-style: none; 81 | border-bottom: 1px solid #c6c6c6; 82 | display: flex; 83 | gap: 5px; 84 | margin-bottom: 18px; 85 | padding-bottom: 12px; 86 | 87 | .file-icon { 88 | width: 10%; 89 | } 90 | 91 | .file { 92 | width: 90%; 93 | 94 | .file-details { 95 | display: flex; 96 | align-items: center; 97 | justify-content: space-between; 98 | margin-bottom: 5px; 99 | 100 | .file-info { 101 | width: 90%; 102 | display: flex; 103 | align-items: baseline; 104 | 105 | .file-name { 106 | display: inline-block; 107 | max-width: 72%; 108 | margin-right: 8px; 109 | } 110 | } 111 | 112 | .file-size { 113 | font-size: 0.7em; 114 | } 115 | 116 | .retry-upload { 117 | padding: 3px; 118 | border-radius: 50%; 119 | 120 | &:hover { 121 | cursor: pointer; 122 | background-color: rgba(0, 0, 0, 0.07); 123 | color: var(--file-manager-primary-color); 124 | } 125 | } 126 | 127 | .rm-file { 128 | &:hover { 129 | cursor: pointer; 130 | color: red; 131 | } 132 | } 133 | 134 | .upload-success { 135 | color: var(--file-manager-primary-color); 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/Actions/UploadFile/UploadItem.jsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineClose } from "react-icons/ai"; 2 | import Progress from "../../../components/Progress/Progress"; 3 | import { getFileExtension } from "../../../utils/getFileExtension"; 4 | import { useFileIcons } from "../../../hooks/useFileIcons"; 5 | import { FaRegFile } from "react-icons/fa6"; 6 | import { useEffect, useRef, useState } from "react"; 7 | import { getDataSize } from "../../../utils/getDataSize"; 8 | import { FaRegCheckCircle } from "react-icons/fa"; 9 | import { IoMdRefresh } from "react-icons/io"; 10 | import { useFiles } from "../../../contexts/FilesContext"; 11 | import { useTranslation } from "../../../contexts/TranslationProvider"; 12 | 13 | const UploadItem = ({ 14 | index, 15 | fileData, 16 | setFiles, 17 | setIsUploading, 18 | fileUploadConfig, 19 | onFileUploaded, 20 | handleFileRemove, 21 | }) => { 22 | const [uploadProgress, setUploadProgress] = useState(0); 23 | const [isUploaded, setIsUploaded] = useState(false); 24 | const [isCanceled, setIsCanceled] = useState(false); 25 | const [uploadFailed, setUploadFailed] = useState(false); 26 | const fileIcons = useFileIcons(33); 27 | const xhrRef = useRef(); 28 | const { onError } = useFiles(); 29 | const t = useTranslation(); 30 | 31 | const handleUploadError = (xhr) => { 32 | setUploadProgress(0); 33 | setIsUploading((prev) => ({ 34 | ...prev, 35 | [index]: false, 36 | })); 37 | const error = { 38 | type: "upload", 39 | message: t("uploadFail"), 40 | response: { 41 | status: xhr.status, 42 | statusText: xhr.statusText, 43 | data: xhr.response, 44 | }, 45 | }; 46 | 47 | setFiles((prev) => 48 | prev.map((file, i) => { 49 | if (index === i) { 50 | return { 51 | ...file, 52 | error: error.message, 53 | }; 54 | } 55 | return file; 56 | }) 57 | ); 58 | 59 | setUploadFailed(true); 60 | 61 | onError(error, fileData.file); 62 | }; 63 | 64 | const fileUpload = (fileData) => { 65 | if (!!fileData.error) return; 66 | 67 | return new Promise((resolve, reject) => { 68 | const xhr = new XMLHttpRequest(); 69 | xhrRef.current = xhr; 70 | setIsUploading((prev) => ({ 71 | ...prev, 72 | [index]: true, 73 | })); 74 | 75 | xhr.upload.onprogress = (event) => { 76 | if (event.lengthComputable) { 77 | const progress = Math.round((event.loaded / event.total) * 100); 78 | setUploadProgress(progress); 79 | } 80 | }; 81 | 82 | xhr.onload = () => { 83 | setIsUploading((prev) => ({ 84 | ...prev, 85 | [index]: false, 86 | })); 87 | if (xhr.status === 200 || xhr.status === 201) { 88 | setIsUploaded(true); 89 | onFileUploaded(xhr.response); 90 | resolve(xhr.response); 91 | } else { 92 | reject(xhr.statusText); 93 | handleUploadError(xhr); 94 | } 95 | }; 96 | 97 | xhr.onerror = () => { 98 | reject(xhr.statusText); 99 | handleUploadError(xhr); 100 | }; 101 | 102 | const method = fileUploadConfig?.method || "POST"; 103 | xhr.open(method, fileUploadConfig?.url, true); 104 | const headers = fileUploadConfig?.headers; 105 | for (let key in headers) { 106 | xhr.setRequestHeader(key, headers[key]); 107 | } 108 | 109 | const formData = new FormData(); 110 | const appendData = fileData?.appendData; 111 | for (let key in appendData) { 112 | appendData[key] && formData.append(key, appendData[key]); 113 | } 114 | formData.append("file", fileData.file); 115 | 116 | xhr.send(formData); 117 | }); 118 | }; 119 | 120 | useEffect(() => { 121 | // Prevent double uploads with strict mode 122 | if (!xhrRef.current) { 123 | fileUpload(fileData); 124 | } 125 | }, []); 126 | 127 | const handleAbortUpload = () => { 128 | if (xhrRef.current) { 129 | xhrRef.current.abort(); 130 | setIsUploading((prev) => ({ 131 | ...prev, 132 | [index]: false, 133 | })); 134 | setIsCanceled(true); 135 | setUploadProgress(0); 136 | } 137 | }; 138 | 139 | const handleRetry = () => { 140 | if (fileData?.file) { 141 | setFiles((prev) => 142 | prev.map((file, i) => { 143 | if (index === i) { 144 | return { 145 | ...file, 146 | error: false, 147 | }; 148 | } 149 | return file; 150 | }) 151 | ); 152 | fileUpload({ ...fileData, error: false }); 153 | setIsCanceled(false); 154 | setUploadFailed(false); 155 | } 156 | }; 157 | 158 | // File was removed by the user beacuse it was unsupported or exceeds file size limit. 159 | if (!!fileData.removed) { 160 | return null; 161 | } 162 | // 163 | 164 | return ( 165 |
  • 166 |
    167 | {fileIcons[getFileExtension(fileData.file?.name)] ?? } 168 |
    169 |
    170 |
    171 |
    172 | 173 | {fileData.file?.name} 174 | 175 | {getDataSize(fileData.file?.size)} 176 |
    177 | {isUploaded ? ( 178 | 179 | ) : isCanceled || uploadFailed ? ( 180 | 181 | ) : ( 182 |
    handleFileRemove(index) : handleAbortUpload} 186 | > 187 | 188 |
    189 | )} 190 |
    191 | 197 |
    198 |
  • 199 | ); 200 | }; 201 | 202 | export default UploadItem; 203 | -------------------------------------------------------------------------------- /frontend/src/FileManager/BreadCrumb/BreadCrumb.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { MdHome, MdMoreHoriz, MdOutlineNavigateNext } from "react-icons/md"; 4 | import { TbLayoutSidebarLeftExpand, TbLayoutSidebarLeftCollapseFilled } from "react-icons/tb"; 5 | import { useFileNavigation } from "../../contexts/FileNavigationContext"; 6 | import { useDetectOutsideClick } from "../../hooks/useDetectOutsideClick"; 7 | import { useTranslation } from "../../contexts/TranslationProvider"; 8 | import "./BreadCrumb.scss"; 9 | 10 | const BreadCrumb = ({ collapsibleNav, isNavigationPaneOpen, setNavigationPaneOpen }) => { 11 | const [folders, setFolders] = useState([]); 12 | const [hiddenFolders, setHiddenFolders] = useState([]); 13 | const [hiddenFoldersWidth, setHiddenFoldersWidth] = useState([]); 14 | const [showHiddenFolders, setShowHiddenFolders] = useState(false); 15 | 16 | const { currentPath, setCurrentPath } = useFileNavigation(); 17 | const breadCrumbRef = useRef(null); 18 | const foldersRef = useRef([]); 19 | const moreBtnRef = useRef(null); 20 | const popoverRef = useDetectOutsideClick(() => { 21 | setShowHiddenFolders(false); 22 | }); 23 | const t = useTranslation(); 24 | const navTogglerRef = useRef(null); 25 | 26 | useEffect(() => { 27 | setFolders(() => { 28 | let path = ""; 29 | return currentPath?.split("/").map((item) => { 30 | return { 31 | name: item || t("home"), 32 | path: item === "" ? item : (path += `/${item}`), 33 | }; 34 | }); 35 | }); 36 | setHiddenFolders([]); 37 | setHiddenFoldersWidth([]); 38 | }, [currentPath, t]); 39 | 40 | const switchPath = (path) => { 41 | setCurrentPath(path); 42 | }; 43 | 44 | const getBreadCrumbWidth = () => { 45 | const containerWidth = breadCrumbRef.current.clientWidth; 46 | const containerStyles = getComputedStyle(breadCrumbRef.current); 47 | const paddingLeft = parseFloat(containerStyles.paddingLeft); 48 | const navTogglerGap = collapsibleNav ? 2 : 0; 49 | const navTogglerDividerWidth = 1; 50 | const navTogglerWidth = collapsibleNav 51 | ? navTogglerRef.current?.clientWidth + navTogglerDividerWidth 52 | : 0; 53 | const moreBtnGap = hiddenFolders.length > 0 ? 1 : 0; 54 | const flexGap = parseFloat(containerStyles.gap) * (folders.length + moreBtnGap + navTogglerGap); 55 | return containerWidth - (paddingLeft + flexGap + navTogglerWidth); 56 | }; 57 | 58 | const checkAvailableSpace = () => { 59 | const availableSpace = getBreadCrumbWidth(); 60 | const remainingFoldersWidth = foldersRef.current.reduce((prev, curr) => { 61 | if (!curr) return prev; 62 | return prev + curr.clientWidth; 63 | }, 0); 64 | const moreBtnWidth = moreBtnRef.current?.clientWidth || 0; 65 | return availableSpace - (remainingFoldersWidth + moreBtnWidth); 66 | }; 67 | 68 | const isBreadCrumbOverflowing = () => { 69 | return breadCrumbRef.current.scrollWidth > breadCrumbRef.current.clientWidth; 70 | }; 71 | 72 | useEffect(() => { 73 | if (isBreadCrumbOverflowing()) { 74 | const hiddenFolder = folders[1]; 75 | const hiddenFolderWidth = foldersRef.current[1]?.clientWidth; 76 | setHiddenFoldersWidth((prev) => [...prev, hiddenFolderWidth]); 77 | setHiddenFolders((prev) => [...prev, hiddenFolder]); 78 | setFolders((prev) => prev.filter((_, index) => index !== 1)); 79 | } else if (hiddenFolders.length > 0 && checkAvailableSpace() > hiddenFoldersWidth.at(-1)) { 80 | const newFolders = [folders[0], hiddenFolders.at(-1), ...folders.slice(1)]; 81 | setFolders(newFolders); 82 | setHiddenFolders((prev) => prev.slice(0, -1)); 83 | setHiddenFoldersWidth((prev) => prev.slice(0, -1)); 84 | } 85 | }, [isBreadCrumbOverflowing]); 86 | 87 | return ( 88 |
    89 |
    90 | {collapsibleNav && ( 91 | <> 92 |
    99 | setNavigationPaneOpen((prev) => !prev)} 102 | > 103 | {isNavigationPaneOpen ? ( 104 | 105 | ) : ( 106 | 107 | )} 108 | 109 |
    110 |
    111 | 112 | )} 113 | {folders.map((folder, index) => ( 114 |
    115 | switchPath(folder.path)} 118 | ref={(el) => (foldersRef.current[index] = el)} 119 | > 120 | {index === 0 ? : } 121 | {folder.name} 122 | 123 | {hiddenFolders?.length > 0 && index === 0 && ( 124 | 132 | )} 133 |
    134 | ))} 135 |
    136 | 137 | {showHiddenFolders && ( 138 |
      139 | {hiddenFolders.map((folder, index) => ( 140 |
    • { 143 | switchPath(folder.path); 144 | setShowHiddenFolders(false); 145 | }} 146 | > 147 | {folder.name} 148 |
    • 149 | ))} 150 |
    151 | )} 152 |
    153 | ); 154 | }; 155 | 156 | BreadCrumb.displayName = "BreadCrumb"; 157 | 158 | BreadCrumb.propTypes = { 159 | isNavigationPaneOpen: PropTypes.bool.isRequired, 160 | setNavigationPaneOpen: PropTypes.func.isRequired, 161 | }; 162 | 163 | export default BreadCrumb; 164 | -------------------------------------------------------------------------------- /frontend/src/FileManager/BreadCrumb/BreadCrumb.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .bread-crumb-container { 4 | position: relative; 5 | 6 | .breadcrumb { 7 | height: 22px; 8 | min-height: 22px; 9 | max-height: 22px; 10 | display: flex; 11 | gap: 0.5rem; 12 | border-bottom: 1px solid $border-color; 13 | padding: 6px 0 6PX 15px; 14 | overflow-x: hidden; 15 | 16 | &::-webkit-scrollbar { 17 | height: 3px; 18 | } 19 | 20 | &::-webkit-scrollbar-thumb { 21 | background: var(--file-manager-primary-color) !important; 22 | } 23 | 24 | .nav-toggler { 25 | display: flex; 26 | align-items: center; 27 | } 28 | 29 | .divider { 30 | width: 1px; 31 | background-color: $border-color; 32 | } 33 | 34 | .folder-name { 35 | display: flex; 36 | align-items: center; 37 | gap: 0.25rem; 38 | font-weight: 500; 39 | min-width: fit-content; 40 | 41 | &:hover { 42 | cursor: pointer; 43 | color: var(--file-manager-primary-color); 44 | } 45 | } 46 | 47 | .hidden-folders { 48 | padding: 0 4px; 49 | } 50 | 51 | .folder-name-btn { 52 | background-color: transparent; 53 | border: none; 54 | padding: 0; 55 | 56 | &:hover, 57 | &:focus { 58 | cursor: pointer; 59 | color: var(--file-manager-primary-color); 60 | background-color: #dddcdc; 61 | border-radius: 5px; 62 | } 63 | } 64 | 65 | } 66 | } 67 | 68 | .hidden-folders-container { 69 | position: absolute; 70 | margin: 0; 71 | z-index: 2; 72 | background-color: rgb(99, 99, 99); 73 | color: white; 74 | padding: 4px; 75 | border-radius: 5px; 76 | font-size: .9em; 77 | left: 3rem; 78 | display: flex; 79 | flex-direction: column; 80 | gap: 5px; 81 | 82 | li { 83 | padding: 5px 10px; 84 | border-radius: 4px; 85 | 86 | &:hover { 87 | cursor: pointer; 88 | background-color: rgb(117, 117, 117); 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/FileList/FileList.jsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import FileItem from "./FileItem"; 3 | import { useFileNavigation } from "../../contexts/FileNavigationContext"; 4 | import { useLayout } from "../../contexts/LayoutContext"; 5 | import ContextMenu from "../../components/ContextMenu/ContextMenu"; 6 | import { useDetectOutsideClick } from "../../hooks/useDetectOutsideClick"; 7 | import useFileList from "./useFileList"; 8 | import FilesHeader from "./FilesHeader"; 9 | import { useTranslation } from "../../contexts/TranslationProvider"; 10 | import "./FileList.scss"; 11 | 12 | const FileList = ({ 13 | onCreateFolder, 14 | onRename, 15 | onFileOpen, 16 | onRefresh, 17 | enableFilePreview, 18 | triggerAction, 19 | permissions, 20 | }) => { 21 | const { currentPathFiles } = useFileNavigation(); 22 | const filesViewRef = useRef(null); 23 | const { activeLayout } = useLayout(); 24 | const t = useTranslation(); 25 | 26 | const { 27 | emptySelecCtxItems, 28 | selecCtxItems, 29 | handleContextMenu, 30 | unselectFiles, 31 | visible, 32 | setVisible, 33 | setLastSelectedFile, 34 | selectedFileIndexes, 35 | clickPosition, 36 | isSelectionCtx, 37 | } = useFileList(onRefresh, enableFilePreview, triggerAction, permissions); 38 | 39 | const contextMenuRef = useDetectOutsideClick(() => setVisible(false)); 40 | 41 | return ( 42 |
    48 | {activeLayout === "list" && } 49 | 50 | {currentPathFiles?.length > 0 ? ( 51 | <> 52 | {currentPathFiles.map((file, index) => ( 53 | 69 | ))} 70 | 71 | ) : ( 72 |
    {t("folderEmpty")}
    73 | )} 74 | 75 | 83 |
    84 | ); 85 | }; 86 | 87 | FileList.displayName = "FileList"; 88 | 89 | export default FileList; 90 | -------------------------------------------------------------------------------- /frontend/src/FileManager/FileList/FileList.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .files { 4 | position: relative; 5 | display: flex; 6 | align-content: flex-start; 7 | flex-wrap: wrap; 8 | column-gap: 0.5em; 9 | row-gap: 5px; 10 | height: calc(100% - (35px + 16px)); 11 | @include overflow-y-scroll; 12 | padding: 8px; 13 | padding-right: 4px; 14 | 15 | .drag-move-tooltip { 16 | background-color: white; 17 | font-size: .78em; 18 | position: fixed; 19 | text-wrap: nowrap; 20 | border: 1px dashed black; 21 | padding: 1px 5px 2px 24px; 22 | border-radius: 3px; 23 | font-weight: bold; 24 | color: var(--file-manager-primary-color); 25 | z-index: 2; 26 | 27 | .drop-zone-file-name { 28 | color: rgb(48, 48, 48); 29 | } 30 | } 31 | 32 | .file-item-container { 33 | border-radius: 5px; 34 | margin: 5px 0; 35 | 36 | .drag-icon { 37 | position: absolute !important; 38 | top: -1000px; 39 | left: -1000px; 40 | z-Index: -1; 41 | border-radius: 4px; 42 | position: relative; 43 | color: white; 44 | background-color: var(--file-manager-primary-color); 45 | padding: 3px 8px; 46 | } 47 | } 48 | 49 | .file-item-container.file-drop-zone { 50 | background-color: rgb(0, 0, 0, 0.08) !important; 51 | } 52 | 53 | .file-item { 54 | position: relative; 55 | height: 81px; 56 | width: 138px; 57 | display: flex; 58 | flex-direction: column; 59 | gap: 0.5rem; 60 | align-items: center; 61 | justify-content: space-between; 62 | padding-top: 13px; 63 | padding-bottom: 1px; 64 | border-radius: 5px; 65 | 66 | &:hover { 67 | background-color: $item-hover-color; 68 | } 69 | 70 | .selection-checkbox { 71 | position: absolute; 72 | left: 5px; 73 | top: 8px; 74 | } 75 | 76 | .hidden { 77 | visibility: hidden; 78 | } 79 | 80 | .visible { 81 | visibility: visible; 82 | } 83 | 84 | .rename-file-container { 85 | position: absolute; 86 | top: 65px; 87 | width: 100%; 88 | text-align: center; 89 | } 90 | 91 | .rename-file-container.list { 92 | top: 6px; 93 | left: 58px; 94 | text-align: left; 95 | 96 | .rename-file { 97 | min-width: 15%; 98 | text-align: left; 99 | border-radius: 3px; 100 | border: none; 101 | top: 5px; 102 | white-space: nowrap; 103 | overflow-x: hidden; 104 | max-width: calc(100% - 62px); 105 | } 106 | 107 | .folder-name-error.right { 108 | left: 0px; 109 | bottom: -72px; 110 | } 111 | } 112 | 113 | .file-name { 114 | max-width: 115px; 115 | } 116 | } 117 | 118 | .file-selected { 119 | background-color: var(--file-manager-primary-color); 120 | color: white; 121 | 122 | &:hover { 123 | background-color: var(--file-manager-primary-color); 124 | } 125 | } 126 | 127 | .file-moving { 128 | opacity: 0.5; 129 | } 130 | } 131 | 132 | .files.list { 133 | flex-direction: column; 134 | flex-wrap: nowrap; 135 | gap: 0; 136 | padding-left: 0px; 137 | padding-top: 0px; 138 | 139 | .files-header { 140 | font-size: 0.83em; 141 | font-weight: 600; 142 | display: flex; 143 | gap: 5px; 144 | border-bottom: 1px solid #dddddd; 145 | padding: 4px 0 4px 5px; 146 | position: sticky; 147 | top: 0; 148 | background-color: #f5f5f5; 149 | z-index: 1; 150 | 151 | .file-select-all { 152 | width: 5%; 153 | height: 0.83em; 154 | } 155 | 156 | .file-name { 157 | width: calc(65% - 35px); 158 | padding-left: 35px; 159 | } 160 | 161 | .file-date { 162 | text-align: center; 163 | width: 20%; 164 | } 165 | 166 | .file-size { 167 | text-align: center; 168 | width: 10%; 169 | } 170 | } 171 | 172 | .file-item-container { 173 | display: flex; 174 | width: 100%; 175 | margin: 0; 176 | border-radius: 0; 177 | 178 | &:hover { 179 | background-color: rgb(0, 0, 0, 0.04); 180 | } 181 | } 182 | 183 | .file-item-container.file-selected { 184 | &:hover { 185 | background-color: var(--file-manager-primary-color) !important; 186 | } 187 | } 188 | 189 | .file-item { 190 | flex-direction: row; 191 | height: 13px; 192 | justify-content: unset; 193 | padding: 15px; 194 | padding-left: 33px; 195 | margin: 0; 196 | width: calc(70% - 30px); 197 | 198 | &:hover { 199 | background-color: unset; 200 | } 201 | 202 | .selection-checkbox { 203 | top: 12px; 204 | } 205 | 206 | .file-name { 207 | max-width: 285px; 208 | } 209 | } 210 | 211 | .modified-date { 212 | display: flex; 213 | align-items: center; 214 | justify-content: center; 215 | font-size: 0.8em; 216 | width: calc(20%); 217 | } 218 | 219 | .size { 220 | display: flex; 221 | align-items: center; 222 | justify-content: center; 223 | font-size: 0.8em; 224 | width: calc(10%); 225 | } 226 | } 227 | 228 | .empty-folder { 229 | display: flex; 230 | justify-content: center; 231 | align-items: center; 232 | width: 100%; 233 | height: 100%; 234 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/FileList/FilesHeader.jsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from "react"; 2 | import Checkbox from "../../components/Checkbox/Checkbox"; 3 | import { useSelection } from "../../contexts/SelectionContext"; 4 | import { useFileNavigation } from "../../contexts/FileNavigationContext"; 5 | 6 | const FilesHeader = ({ unselectFiles }) => { 7 | const [showSelectAll, setShowSelectAll] = useState(false); 8 | 9 | const { selectedFiles, setSelectedFiles } = useSelection(); 10 | const { currentPathFiles } = useFileNavigation(); 11 | 12 | const allFilesSelected = useMemo(() => { 13 | return currentPathFiles.length > 0 && selectedFiles.length === currentPathFiles.length; 14 | }, [selectedFiles, currentPathFiles]); 15 | 16 | const handleSelectAll = (e) => { 17 | if (e.target.checked) { 18 | setSelectedFiles(currentPathFiles); 19 | setShowSelectAll(true); 20 | } else { 21 | unselectFiles(); 22 | } 23 | }; 24 | 25 | return ( 26 |
    setShowSelectAll(true)} 29 | onMouseLeave={() => setShowSelectAll(false)} 30 | > 31 |
    32 | {(showSelectAll || allFilesSelected) && ( 33 | 34 | )} 35 |
    36 |
    Name
    37 |
    Modified
    38 |
    Size
    39 |
    40 | ); 41 | }; 42 | 43 | export default FilesHeader; 44 | -------------------------------------------------------------------------------- /frontend/src/FileManager/FileManager.jsx: -------------------------------------------------------------------------------- 1 | import Loader from "../components/Loader/Loader"; 2 | import Toolbar from "./Toolbar/Toolbar"; 3 | import NavigationPane from "./NavigationPane/NavigationPane"; 4 | import BreadCrumb from "./BreadCrumb/BreadCrumb"; 5 | import FileList from "./FileList/FileList"; 6 | import Actions from "./Actions/Actions"; 7 | import { FilesProvider } from "../contexts/FilesContext"; 8 | import { FileNavigationProvider } from "../contexts/FileNavigationContext"; 9 | import { SelectionProvider } from "../contexts/SelectionContext"; 10 | import { ClipBoardProvider } from "../contexts/ClipboardContext"; 11 | import { LayoutProvider } from "../contexts/LayoutContext"; 12 | import { useTriggerAction } from "../hooks/useTriggerAction"; 13 | import { useColumnResize } from "../hooks/useColumnResize"; 14 | import PropTypes from "prop-types"; 15 | import { dateStringValidator, urlValidator } from "../validators/propValidators"; 16 | import { TranslationProvider } from "../contexts/TranslationProvider"; 17 | import { useMemo, useState } from "react"; 18 | import { defaultPermissions } from "../constants"; 19 | import "./FileManager.scss"; 20 | 21 | const FileManager = ({ 22 | files, 23 | fileUploadConfig, 24 | isLoading, 25 | onCreateFolder, 26 | onFileUploading = () => {}, 27 | onFileUploaded = () => {}, 28 | onCut, 29 | onCopy, 30 | onPaste, 31 | onRename, 32 | onDownload, 33 | onDelete = () => null, 34 | onLayoutChange = () => {}, 35 | onRefresh, 36 | onFileOpen = () => {}, 37 | onSelect, 38 | onError = () => {}, 39 | layout = "grid", 40 | enableFilePreview = true, 41 | maxFileSize, 42 | filePreviewPath, 43 | acceptedFileTypes, 44 | height = "600px", 45 | width = "100%", 46 | initialPath = "", 47 | filePreviewComponent, 48 | primaryColor = "#6155b4", 49 | fontFamily = "Nunito Sans, sans-serif", 50 | language = "en", 51 | permissions: userPermissions = {}, 52 | collapsibleNav = false, 53 | defaultNavExpanded = true, 54 | }) => { 55 | const [isNavigationPaneOpen, setNavigationPaneOpen] = useState(defaultNavExpanded); 56 | const triggerAction = useTriggerAction(); 57 | const { containerRef, colSizes, isDragging, handleMouseMove, handleMouseUp, handleMouseDown } = 58 | useColumnResize(20, 80); 59 | const customStyles = { 60 | "--file-manager-font-family": fontFamily, 61 | "--file-manager-primary-color": primaryColor, 62 | height, 63 | width, 64 | }; 65 | 66 | const permissions = useMemo( 67 | () => ({ ...defaultPermissions, ...userPermissions }), 68 | [userPermissions] 69 | ); 70 | 71 | return ( 72 |
    e.preventDefault()} style={customStyles}> 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 86 |
    92 |
    98 | 99 |
    103 |
    104 | 105 |
    109 | 114 | 123 |
    124 |
    125 | 126 | 139 |
    140 |
    141 |
    142 |
    143 |
    144 |
    145 |
    146 | ); 147 | }; 148 | 149 | FileManager.displayName = "FileManager"; 150 | 151 | FileManager.propTypes = { 152 | files: PropTypes.arrayOf( 153 | PropTypes.shape({ 154 | name: PropTypes.string.isRequired, 155 | isDirectory: PropTypes.bool.isRequired, 156 | path: PropTypes.string.isRequired, 157 | updatedAt: dateStringValidator, 158 | size: PropTypes.number, 159 | }) 160 | ).isRequired, 161 | fileUploadConfig: PropTypes.shape({ 162 | url: urlValidator, 163 | headers: PropTypes.objectOf(PropTypes.string), 164 | method: PropTypes.oneOf(["POST", "PUT"]), 165 | }), 166 | isLoading: PropTypes.bool, 167 | onCreateFolder: PropTypes.func, 168 | onFileUploading: PropTypes.func, 169 | onFileUploaded: PropTypes.func, 170 | onRename: PropTypes.func, 171 | onDelete: PropTypes.func, 172 | onCut: PropTypes.func, 173 | onCopy: PropTypes.func, 174 | onPaste: PropTypes.func, 175 | onDownload: PropTypes.func, 176 | onLayoutChange: PropTypes.func, 177 | onRefresh: PropTypes.func, 178 | onFileOpen: PropTypes.func, 179 | onSelect: PropTypes.func, 180 | onError: PropTypes.func, 181 | layout: PropTypes.oneOf(["grid", "list"]), 182 | maxFileSize: PropTypes.number, 183 | enableFilePreview: PropTypes.bool, 184 | filePreviewPath: urlValidator, 185 | acceptedFileTypes: PropTypes.string, 186 | height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 187 | width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 188 | initialPath: PropTypes.string, 189 | filePreviewComponent: PropTypes.func, 190 | primaryColor: PropTypes.string, 191 | fontFamily: PropTypes.string, 192 | language: PropTypes.string, 193 | permissions: PropTypes.shape({ 194 | create: PropTypes.bool, 195 | upload: PropTypes.bool, 196 | move: PropTypes.bool, 197 | copy: PropTypes.bool, 198 | rename: PropTypes.bool, 199 | download: PropTypes.bool, 200 | delete: PropTypes.bool, 201 | }), 202 | collapsibleNav: PropTypes.bool, 203 | defaultNavExpanded: PropTypes.bool, 204 | }; 205 | 206 | export default FileManager; 207 | -------------------------------------------------------------------------------- /frontend/src/FileManager/FileManager.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap"); 2 | @import "../styles/variables"; 3 | 4 | .text-white { 5 | color: white; 6 | } 7 | 8 | .text-truncate { 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | white-space: nowrap; 12 | } 13 | 14 | img, 15 | svg { 16 | vertical-align: middle; 17 | } 18 | 19 | .fm-modal { 20 | border: 1px solid #c6c6c6; 21 | border-radius: 5px; 22 | width: fit-content; 23 | overflow-x: hidden; 24 | padding: 0; 25 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.253); 26 | } 27 | 28 | .w-25 { 29 | width: 25%; 30 | } 31 | 32 | .file-explorer { 33 | min-height: 400px; 34 | height: 100%; 35 | width: 100%; 36 | font-family: var(--file-manager-font-family); 37 | 38 | button { 39 | font-family: var(--file-manager-font-family); 40 | } 41 | 42 | border-radius: 8px; 43 | position: relative; 44 | background-color: white; 45 | cursor: default; 46 | // Disable Text Selection on Double Click 47 | -webkit-user-select: none; 48 | -ms-user-select: none; 49 | user-select: none; 50 | 51 | .files-container { 52 | display: flex; 53 | height: calc(100% - 46px); // Toolbar total height = baseHeight: 35px, padding top + bottom: 10px, border: 1px. 54 | 55 | .navigation-pane { 56 | z-index: 1; 57 | padding-top: 8px; 58 | 59 | position: relative; 60 | 61 | .sidebar-resize { 62 | position: absolute; 63 | right: 0px; 64 | top: 0px; 65 | bottom: 0px; 66 | width: 5px; 67 | cursor: col-resize; 68 | z-index: 10; 69 | border-right: 1px solid $border-color; 70 | 71 | &:hover { 72 | border-right: 1px solid #1e3a8a; 73 | } 74 | } 75 | 76 | .sidebar-dragging { 77 | border-right: 1px solid #1e3a8a; 78 | } 79 | } 80 | 81 | .navigation-pane.open { 82 | display: block; 83 | } 84 | 85 | .navigation-pane.closed { 86 | display: none; 87 | } 88 | 89 | .folders-preview { 90 | z-index: 2; 91 | background-color: white; 92 | padding-right: 0px; 93 | padding-left: 0px; 94 | border-bottom-right-radius: 8px; 95 | border-bottom-left-radius: 8px; 96 | } 97 | } 98 | } 99 | 100 | .close-icon { 101 | padding: 5px; 102 | border-radius: 50%; 103 | 104 | &:hover { 105 | cursor: pointer; 106 | background-color: rgb(0, 0, 0, 0.07); 107 | } 108 | } 109 | 110 | .fm-rename-folder-container { 111 | padding: 8px 0; 112 | 113 | .fm-rename-folder-input { 114 | border-bottom: 1px solid #c6c6c6; 115 | padding: 8px 12px 12px; 116 | 117 | .fm-rename-warning { 118 | display: flex; 119 | align-items: center; 120 | gap: 10px; 121 | } 122 | } 123 | 124 | .fm-rename-folder-action { 125 | display: flex; 126 | gap: 8px; 127 | justify-content: flex-end; 128 | padding: 8px 8px 0 0; 129 | } 130 | } 131 | 132 | .file-selcted { 133 | .select-files { 134 | width: 40%; 135 | } 136 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/NavigationPane/FolderTree.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import Collapse from "../../components/Collapse/Collapse"; 3 | import { FaRegFolder, FaRegFolderOpen } from "react-icons/fa"; 4 | import { MdKeyboardArrowRight } from "react-icons/md"; 5 | import { useFileNavigation } from "../../contexts/FileNavigationContext"; 6 | 7 | const FolderTree = ({ folder, onFileOpen }) => { 8 | const [isOpen, setIsOpen] = useState(false); 9 | const [isActive, setIsActive] = useState(false); 10 | const { currentPath, setCurrentPath } = useFileNavigation(); 11 | 12 | const handleFolderSwitch = () => { 13 | setIsActive(true); 14 | onFileOpen(folder); 15 | setCurrentPath(folder.path); 16 | }; 17 | 18 | const handleCollapseChange = (e) => { 19 | e.stopPropagation(); 20 | setIsOpen((prev) => !prev); 21 | }; 22 | 23 | useEffect(() => { 24 | setIsActive(currentPath === folder.path); //Setting isActive to a folder if its path matches currentPath 25 | 26 | // Auto expand parent folder if its child is accessed via file navigation 27 | // Explanation: Checks if the current folder's parent path matches with any folder path i.e. Folder's parent 28 | // then expand that parent. 29 | const currentPathArray = currentPath.split("/"); 30 | currentPathArray.pop(); //splits with '/' and pops to remove last element to get current folder's parent path 31 | const currentFolderParentPath = currentPathArray.join("/"); 32 | if (currentFolderParentPath === folder.path) { 33 | setIsOpen(true); 34 | } 35 | // 36 | }, [currentPath]); 37 | 38 | if (folder.subDirectories.length > 0) { 39 | return ( 40 | <> 41 |
    45 | 46 | 50 | 51 |
    52 | {isOpen || isActive ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 | 58 | {folder.name} 59 | 60 |
    61 |
    62 | 63 |
    64 | {folder.subDirectories.map((item, index) => ( 65 | 66 | ))} 67 |
    68 |
    69 | 70 | ); 71 | } else { 72 | return ( 73 |
    77 | 78 |
    79 | {isActive ? ( 80 | 81 | ) : ( 82 | 83 | )} 84 | 85 | {folder.name} 86 | 87 |
    88 |
    89 | ); 90 | } 91 | }; 92 | 93 | export default FolderTree; 94 | -------------------------------------------------------------------------------- /frontend/src/FileManager/NavigationPane/NavigationPane.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import FolderTree from "./FolderTree"; 3 | import { getParentPath } from "../../utils/getParentPath"; 4 | import { useFiles } from "../../contexts/FilesContext"; 5 | import { useTranslation } from "../../contexts/TranslationProvider"; 6 | import "./NavigationPane.scss"; 7 | 8 | const NavigationPane = ({ onFileOpen }) => { 9 | const [foldersTree, setFoldersTree] = useState([]); 10 | const { files } = useFiles(); 11 | const t = useTranslation(); 12 | 13 | const createChildRecursive = (path, foldersStruct) => { 14 | if (!foldersStruct[path]) return []; // No children for this path (folder) 15 | 16 | return foldersStruct[path]?.map((folder) => { 17 | return { 18 | ...folder, 19 | subDirectories: createChildRecursive(folder.path, foldersStruct), 20 | }; 21 | }); 22 | }; 23 | 24 | useEffect(() => { 25 | if (Array.isArray(files)) { 26 | const folders = files.filter((file) => file.isDirectory); 27 | // Grouping folders by parent path 28 | const foldersStruct = Object.groupBy(folders, ({ path }) => getParentPath(path)); 29 | setFoldersTree(() => { 30 | const rootPath = ""; 31 | return createChildRecursive(rootPath, foldersStruct); 32 | }); 33 | } 34 | }, [files]); 35 | 36 | return ( 37 |
    38 | {foldersTree?.length > 0 ? ( 39 | <> 40 | {foldersTree?.map((folder, index) => { 41 | return ; 42 | })} 43 | 44 | ) : ( 45 |
    {t("nothingHereYet")}
    46 | )} 47 |
    48 | ); 49 | }; 50 | 51 | NavigationPane.displayName = "NavigationPane"; 52 | 53 | export default NavigationPane; 54 | -------------------------------------------------------------------------------- /frontend/src/FileManager/NavigationPane/NavigationPane.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .sb-folders-list { 4 | list-style: none; 5 | margin: 0px 4px; 6 | height: 100%; 7 | @include overflow-y-scroll; 8 | 9 | .folder-collapsible { 10 | margin-left: 10px; 11 | } 12 | 13 | .sb-folders-list-item { 14 | display: flex; 15 | align-items: center; 16 | margin-bottom: 4px; 17 | padding: 6px 5px; 18 | border-radius: 4px; 19 | 20 | &:hover { 21 | cursor: pointer; 22 | background-color: $item-hover-color; 23 | } 24 | 25 | .non-expanable { 26 | min-width: 20px; 27 | } 28 | 29 | .sb-folder-details { 30 | display: flex; 31 | align-items: center; 32 | 33 | .folder-open-icon { 34 | margin: 0 7px; 35 | } 36 | 37 | .folder-close-icon { 38 | margin: 1px 9px 0px 8px; 39 | } 40 | 41 | .sb-folder-name { 42 | width: max-content; 43 | } 44 | } 45 | 46 | .folder-icon-default { 47 | transform: rotate(0deg); 48 | transition: transform 0.5s ease-in-out; 49 | 50 | &.folder-rotate-down { 51 | transform: rotate(90deg); 52 | } 53 | } 54 | } 55 | 56 | .active-list-item { 57 | background-color: var(--file-manager-primary-color); 58 | color: white; 59 | 60 | &:hover { 61 | cursor: pointer; 62 | background-color: var(--file-manager-primary-color); 63 | } 64 | } 65 | 66 | .empty-nav-pane { 67 | display: flex; 68 | justify-content: center; 69 | align-items: center; 70 | height: 100%; 71 | } 72 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/Toolbar/LayoutToggler.jsx: -------------------------------------------------------------------------------- 1 | import { BsGridFill } from "react-icons/bs"; 2 | import { FaCheck, FaListUl } from "react-icons/fa6"; 3 | import { useDetectOutsideClick } from "../../hooks/useDetectOutsideClick"; 4 | import { useLayout } from "../../contexts/LayoutContext"; 5 | import { useTranslation } from "../../contexts/TranslationProvider"; 6 | 7 | const LayoutToggler = ({ setShowToggleViewMenu, onLayoutChange }) => { 8 | const toggleViewRef = useDetectOutsideClick(() => { 9 | setShowToggleViewMenu(false); 10 | }); 11 | const { activeLayout, setActiveLayout } = useLayout(); 12 | const t = useTranslation(); 13 | 14 | const layoutOptions = [ 15 | { 16 | key: "grid", 17 | name: t("grid"), 18 | icon: , 19 | }, 20 | { 21 | key: "list", 22 | name: t("list"), 23 | icon: , 24 | }, 25 | ]; 26 | 27 | const handleSelection = (key) => { 28 | setActiveLayout(key); 29 | onLayoutChange(key); 30 | setShowToggleViewMenu(false); 31 | }; 32 | 33 | return ( 34 |
    35 |
      36 | {layoutOptions.map((option) => ( 37 |
    • handleSelection(option.key)} 41 | onKeyDown={() => handleSelection(option.key)} 42 | > 43 | {option.key === activeLayout && } 44 | {option.icon} 45 | {option.name} 46 |
    • 47 | ))} 48 |
    49 |
    50 | ); 51 | }; 52 | 53 | export default LayoutToggler; 54 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Toolbar/Toolbar.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { BsCopy, BsFolderPlus, BsGridFill, BsScissors } from "react-icons/bs"; 3 | import { FiRefreshCw } from "react-icons/fi"; 4 | import { 5 | MdClear, 6 | MdOutlineDelete, 7 | MdOutlineFileDownload, 8 | MdOutlineFileUpload, 9 | } from "react-icons/md"; 10 | import { BiRename } from "react-icons/bi"; 11 | import { FaListUl, FaRegPaste } from "react-icons/fa6"; 12 | import LayoutToggler from "./LayoutToggler"; 13 | import { useFileNavigation } from "../../contexts/FileNavigationContext"; 14 | import { useSelection } from "../../contexts/SelectionContext"; 15 | import { useClipBoard } from "../../contexts/ClipboardContext"; 16 | import { useLayout } from "../../contexts/LayoutContext"; 17 | import { validateApiCallback } from "../../utils/validateApiCallback"; 18 | import { useTranslation } from "../../contexts/TranslationProvider"; 19 | import "./Toolbar.scss"; 20 | 21 | const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => { 22 | const [showToggleViewMenu, setShowToggleViewMenu] = useState(false); 23 | const { currentFolder } = useFileNavigation(); 24 | const { selectedFiles, setSelectedFiles, handleDownload } = useSelection(); 25 | const { clipBoard, setClipBoard, handleCutCopy, handlePasting } = useClipBoard(); 26 | const { activeLayout } = useLayout(); 27 | const t = useTranslation(); 28 | 29 | // Toolbar Items 30 | const toolbarLeftItems = [ 31 | { 32 | icon: , 33 | text: t("newFolder"), 34 | permission: permissions.create, 35 | onClick: () => triggerAction.show("createFolder"), 36 | }, 37 | { 38 | icon: , 39 | text: t("upload"), 40 | permission: permissions.upload, 41 | onClick: () => triggerAction.show("uploadFile"), 42 | }, 43 | { 44 | icon: , 45 | text: t("paste"), 46 | permission: !!clipBoard, 47 | onClick: handleFilePasting, 48 | }, 49 | ]; 50 | 51 | const toolbarRightItems = [ 52 | { 53 | icon: activeLayout === "grid" ? : , 54 | title: t("changeView"), 55 | onClick: () => setShowToggleViewMenu((prev) => !prev), 56 | }, 57 | { 58 | icon: , 59 | title: t("refresh"), 60 | onClick: () => { 61 | validateApiCallback(onRefresh, "onRefresh"); 62 | setClipBoard(null); 63 | }, 64 | }, 65 | ]; 66 | 67 | function handleFilePasting() { 68 | handlePasting(currentFolder); 69 | } 70 | 71 | const handleDownloadItems = () => { 72 | handleDownload(); 73 | setSelectedFiles([]); 74 | }; 75 | 76 | // Selected File/Folder Actions 77 | if (selectedFiles.length > 0) { 78 | return ( 79 |
    80 |
    81 |
    82 | {permissions.move && ( 83 | 87 | )} 88 | {permissions.copy && ( 89 | 93 | )} 94 | {clipBoard?.files?.length > 0 && ( 95 | 103 | )} 104 | {selectedFiles.length === 1 && permissions.rename && ( 105 | 112 | )} 113 | {permissions.download && ( 114 | 118 | )} 119 | {permissions.delete && ( 120 | 127 | )} 128 |
    129 | 140 |
    141 |
    142 | ); 143 | } 144 | // 145 | 146 | return ( 147 |
    148 |
    149 |
    150 | {toolbarLeftItems 151 | .filter((item) => item.permission) 152 | .map((item, index) => ( 153 | 157 | ))} 158 |
    159 |
    160 | {toolbarRightItems.map((item, index) => ( 161 |
    162 | 165 | {index !== toolbarRightItems.length - 1 &&
    } 166 |
    167 | ))} 168 | 169 | {showToggleViewMenu && ( 170 | 174 | )} 175 |
    176 |
    177 |
    178 | ); 179 | }; 180 | 181 | Toolbar.displayName = "Toolbar"; 182 | 183 | export default Toolbar; 184 | -------------------------------------------------------------------------------- /frontend/src/FileManager/Toolbar/Toolbar.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .toolbar { 4 | height: 35px; 5 | min-height: 35px; 6 | max-height: 35px; 7 | border-bottom: 1px solid $border-color; 8 | padding: 5px 10px; 9 | 10 | .file-action-container { 11 | display: flex; 12 | justify-content: space-between; 13 | 14 | >div { 15 | display: flex; 16 | } 17 | 18 | .file-action { 19 | background-color: transparent; 20 | gap: 5px; 21 | 22 | &:hover:not(:disabled) { 23 | cursor: pointer; 24 | background-color: rgb(0, 0, 0, 0.55) !important; 25 | border-radius: 3px; 26 | color: white; 27 | text-shadow: 0px 0px 1px white; 28 | } 29 | 30 | &:hover:disabled { 31 | cursor: default; 32 | background-color: transparent !important; 33 | color: #b0b0b0; 34 | text-shadow: none; 35 | } 36 | } 37 | } 38 | 39 | .fm-toolbar { 40 | display: flex; 41 | justify-content: space-between; 42 | 43 | >div { 44 | display: flex; 45 | position: relative; 46 | } 47 | 48 | .toolbar-left-items { 49 | display: flex; 50 | } 51 | 52 | .toggle-view { 53 | position: absolute; 54 | z-index: 3; 55 | top: 105%; 56 | right: 22%; 57 | background-color: white; 58 | margin: 0; 59 | border: 1px solid #c4c4c4; 60 | border-radius: 5px; 61 | 62 | ul { 63 | list-style: none; 64 | padding-left: 0; 65 | margin: 0.4em 0; 66 | display: flex; 67 | flex-direction: column; 68 | gap: 1px; 69 | 70 | li { 71 | display: flex; 72 | align-items: center; 73 | gap: 8px; 74 | padding: 5px 20px 5px 10px; 75 | 76 | &:hover { 77 | cursor: pointer; 78 | background-color: rgba(0, 0, 0, 0.075); 79 | } 80 | 81 | span:nth-child(1) { 82 | width: 13px; 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | .item-action { 90 | background-color: white; 91 | display: flex; 92 | align-items: center; 93 | gap: 7px; 94 | padding: 8px 12px; 95 | font-size: 14px; 96 | width: fit-content; 97 | border: none; 98 | 99 | &:hover { 100 | cursor: pointer; 101 | background-color: rgb(0 0 0 / 12%) !important; 102 | border-radius: 3px; 103 | } 104 | 105 | .toggle-view-icon { 106 | background-color: transparent; 107 | border: none; 108 | 109 | &:hover { 110 | cursor: pointer; 111 | } 112 | } 113 | } 114 | 115 | .icon-only { 116 | padding: 0 8px !important; 117 | 118 | &:focus { 119 | background-color: rgb(0 0 0 / 12%); 120 | border-radius: 3px; 121 | } 122 | 123 | &:hover { 124 | color: var(--file-manager-primary-color); 125 | } 126 | } 127 | 128 | .item-separator { 129 | height: 36px; 130 | background: $border-color; 131 | width: 1px; 132 | margin: 0 5px; 133 | } 134 | } 135 | 136 | .file-selected { 137 | background-color: $item-hover-color; 138 | } -------------------------------------------------------------------------------- /frontend/src/FileManager/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./FileManager"; 2 | -------------------------------------------------------------------------------- /frontend/src/api/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const api = axios.create({ 4 | baseURL: import.meta.env.VITE_API_BASE_URL, 5 | }); 6 | -------------------------------------------------------------------------------- /frontend/src/api/createFolderAPI.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api"; 2 | 3 | export const createFolderAPI = async (name, parentId) => { 4 | try { 5 | const response = await api.post("/folder", { name, parentId }); 6 | return response; 7 | } catch (error) { 8 | return error; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/api/deleteAPI.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api"; 2 | 3 | export const deleteAPI = async (ids) => { 4 | const response = await api.delete("", { data: { ids: ids } }); 5 | return response; 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/src/api/downloadFileAPI.js: -------------------------------------------------------------------------------- 1 | export const downloadFile = async (files) => { 2 | if (files.length === 0) return; 3 | 4 | try { 5 | const fileQuery = files.map((file) => `files=${encodeURIComponent(file._id)}`).join("&"); 6 | const url = `${import.meta.env.VITE_API_BASE_URL}/download?${fileQuery}`; 7 | 8 | const link = document.createElement("a"); 9 | link.href = url; 10 | 11 | document.body.appendChild(link); 12 | link.click(); 13 | document.body.removeChild(link); 14 | } catch (error) { 15 | return error; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/api/fileTransferAPI.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api"; 2 | 3 | export const copyItemAPI = async (sourceIds, destinationId) => { 4 | const response = await api.post("/copy", { sourceIds, destinationId }); 5 | return response; 6 | }; 7 | 8 | export const moveItemAPI = async (sourceIds, destinationId) => { 9 | const response = await api.put("/move", { sourceIds, destinationId }); 10 | return response; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/api/getAllFilesAPI.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api"; 2 | 3 | export const getAllFilesAPI = async () => { 4 | try { 5 | const response = await api.get(); 6 | return response; 7 | } catch (error) { 8 | return error; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/api/renameAPI.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api"; 2 | 3 | export const renameAPI = async (id, newName) => { 4 | const response = api.patch("/rename", { 5 | id, 6 | newName, 7 | }); 8 | return response; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import "./Button.scss"; 2 | 3 | const Button = ({ onClick, onKeyDown, type = "primary", padding = "0.4rem 0.8rem", children }) => { 4 | return ( 5 | 13 | ); 14 | }; 15 | 16 | export default Button; 17 | -------------------------------------------------------------------------------- /frontend/src/components/Button/Button.scss: -------------------------------------------------------------------------------- 1 | .fm-button { 2 | border-radius: 5px; 3 | font-weight: 600; 4 | border: none; 5 | 6 | &:hover { 7 | cursor: pointer; 8 | } 9 | } 10 | 11 | .fm-button-primary { 12 | background-color: var(--file-manager-primary-color); 13 | color: white; 14 | 15 | &:hover { 16 | background-image: linear-gradient(rgba(0, 0, 0, 0.2), 17 | rgba(0, 0, 0, 0.2)); 18 | } 19 | } 20 | 21 | .fm-button-secondary { 22 | background-color: #f0f0f0; 23 | color: black; 24 | 25 | &:hover { 26 | background-image: linear-gradient(rgba(0, 0, 0, 0.1), 27 | rgba(0, 0, 0, 0.1)); 28 | } 29 | } 30 | 31 | .fm-button-danger { 32 | background-color: #f44336; 33 | color: white; 34 | 35 | &:hover { 36 | background-image: linear-gradient(rgba(0, 0, 0, 0.2), 37 | rgba(0, 0, 0, 0.2)); 38 | } 39 | } -------------------------------------------------------------------------------- /frontend/src/components/Checkbox/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import "./Checkbox.scss"; 2 | 3 | const Checkbox = ({ name, id, checked, onClick, onChange, className = "", title, disabled = false }) => { 4 | return ( 5 | 16 | ); 17 | }; 18 | 19 | export default Checkbox; 20 | -------------------------------------------------------------------------------- /frontend/src/components/Checkbox/Checkbox.scss: -------------------------------------------------------------------------------- 1 | .fm-checkbox { 2 | accent-color: white; 3 | 4 | &:disabled { 5 | cursor: default !important; 6 | } 7 | 8 | &:hover { 9 | cursor: pointer; 10 | } 11 | 12 | } -------------------------------------------------------------------------------- /frontend/src/components/Collapse/Collapse.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { useCollapse } from "react-collapsed"; 3 | 4 | const Collapse = ({ open, children }) => { 5 | const [isExpanded, setExpanded] = useState(open); 6 | const { getCollapseProps } = useCollapse({ 7 | isExpanded, 8 | duration: 500, 9 | }); 10 | 11 | useEffect(() => { 12 | setExpanded(open); 13 | }, [open, setExpanded]); 14 | 15 | return ( 16 | <> 17 |
    {children}
    18 | 19 | ); 20 | }; 21 | 22 | export default Collapse; 23 | -------------------------------------------------------------------------------- /frontend/src/components/ContextMenu/ContextMenu.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { FaChevronRight } from "react-icons/fa6"; 3 | import SubMenu from "./SubMenu"; 4 | import "./ContextMenu.scss"; 5 | 6 | const ContextMenu = ({ filesViewRef, contextMenuRef, menuItems, visible, clickPosition }) => { 7 | const [left, setLeft] = useState(0); 8 | const [top, setTop] = useState(0); 9 | const [activeSubMenuIndex, setActiveSubMenuIndex] = useState(null); 10 | const [subMenuPosition, setSubMenuPosition] = useState("right"); 11 | 12 | const subMenuRef = useRef(null); 13 | 14 | const contextMenuPosition = () => { 15 | const { clickX, clickY } = clickPosition; 16 | 17 | const container = filesViewRef.current; 18 | const containerRect = container.getBoundingClientRect(); 19 | const scrollBarWidth = container.offsetWidth - container.clientWidth; 20 | 21 | // Context menu size 22 | const contextMenuContainer = contextMenuRef.current.getBoundingClientRect(); 23 | const menuWidth = contextMenuContainer.width; 24 | const menuHeight = contextMenuContainer.height; 25 | 26 | // Check if there is enough space at the right for the context menu 27 | const leftToCursor = clickX - containerRect.left; 28 | const right = containerRect.width - (leftToCursor + scrollBarWidth) > menuWidth; 29 | const left = !right; 30 | 31 | const topToCursor = clickY - containerRect.top; 32 | const top = containerRect.height - topToCursor > menuHeight; 33 | const bottom = !top; 34 | 35 | if (right) { 36 | setLeft(`${leftToCursor}px`); 37 | setSubMenuPosition("right"); 38 | } else if (left) { 39 | // Location: -width of the context menu from cursor's position i.e. left side 40 | setLeft(`${leftToCursor - menuWidth}px`); 41 | setSubMenuPosition("left"); 42 | } 43 | 44 | if (top) { 45 | setTop(`${topToCursor + container.scrollTop}px`); 46 | } else if (bottom) { 47 | setTop(`${topToCursor + container.scrollTop - menuHeight}px`); 48 | } 49 | }; 50 | 51 | const handleContextMenu = (e) => { 52 | e.preventDefault(); 53 | e.stopPropagation(); 54 | }; 55 | 56 | const handleMouseOver = (index) => { 57 | setActiveSubMenuIndex(index); 58 | }; 59 | 60 | useEffect(() => { 61 | if (visible && contextMenuRef.current) { 62 | contextMenuPosition(); 63 | } else { 64 | setTop(0); 65 | setLeft(0); 66 | setActiveSubMenuIndex(null); 67 | } 68 | }, [visible]); 69 | 70 | if (visible) { 71 | return ( 72 |
    e.stopPropagation()} 76 | className={`fm-context-menu ${top ? "visible" : "hidden"}`} 77 | style={{ 78 | top: top, 79 | left: left, 80 | }} 81 | > 82 |
    83 |
      84 | {menuItems 85 | .filter((item) => !item.hidden) 86 | .map((item, index) => { 87 | const hasChildren = item.hasOwnProperty("children"); 88 | const activeSubMenu = activeSubMenuIndex === index && hasChildren; 89 | return ( 90 |
      91 |
    • handleMouseOver(index)} 95 | > 96 | {item.icon} 97 | {item.title} 98 | {hasChildren && ( 99 | <> 100 | 101 | {activeSubMenu && ( 102 | 107 | )} 108 | 109 | )} 110 |
    • 111 | {item.divider && 112 | index !== menuItems.filter((item) => !item.hidden).length - 1 && ( 113 |
      114 | )} 115 |
      116 | ); 117 | })} 118 |
    119 |
    120 |
    121 | ); 122 | } 123 | }; 124 | 125 | export default ContextMenu; 126 | -------------------------------------------------------------------------------- /frontend/src/components/ContextMenu/ContextMenu.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .fm-context-menu { 4 | position: absolute; 5 | background-color: white; 6 | border: 1px solid #c6c6c6; 7 | border-radius: 6px; 8 | padding: 4px; 9 | z-index: 1; 10 | transition: opacity 0.1s linear; 11 | 12 | .file-context-menu-list { 13 | font-size: 1.1em; 14 | 15 | ul { 16 | list-style-type: none; 17 | padding-left: 0; 18 | margin: 0; 19 | display: flex; 20 | flex-direction: column; 21 | gap: 3px; 22 | 23 | li { 24 | display: flex; 25 | gap: 9px; 26 | align-items: center; 27 | padding: 3px 13px; 28 | position: relative; 29 | border-radius: 4px; 30 | 31 | &:hover { 32 | cursor: pointer; 33 | background-color: rgb(0, 0, 0, 0.07); 34 | } 35 | } 36 | 37 | li.active { 38 | background-color: rgb(0, 0, 0, 0.07); 39 | } 40 | 41 | li.disable-paste { 42 | opacity: 0.5; 43 | 44 | &:hover { 45 | cursor: default; 46 | background-color: transparent; 47 | } 48 | } 49 | } 50 | 51 | .divider { 52 | border-bottom: 1px solid #c6c6c6; 53 | margin: 5px 0 3px 0; 54 | } 55 | 56 | .list-expand-icon { 57 | margin-left: auto; 58 | color: #444444; 59 | } 60 | 61 | .sub-menu { 62 | position: absolute; 63 | top: 0; 64 | background-color: white; 65 | border: 1px solid #c6c6c6; 66 | border-radius: 6px; 67 | padding: 4px; 68 | z-index: 1; 69 | 70 | .item-selected { 71 | width: 13px; 72 | color: #444444; 73 | } 74 | 75 | li { 76 | 77 | &:hover { 78 | background-color: rgb(0, 0, 0, 0.07) !important; 79 | } 80 | } 81 | } 82 | 83 | .sub-menu.right { 84 | left: calc(100% - 2px); 85 | } 86 | 87 | .sub-menu.left { 88 | left: calc(-83%); 89 | } 90 | } 91 | } 92 | 93 | .fm-context-menu.hidden { 94 | opacity: 0; 95 | pointer-events: none; 96 | visibility: hidden; 97 | } 98 | 99 | .fm-context-menu.visible { 100 | opacity: 1; 101 | pointer-events: all; 102 | visibility: visible; 103 | } -------------------------------------------------------------------------------- /frontend/src/components/ContextMenu/SubMenu.jsx: -------------------------------------------------------------------------------- 1 | import { FaCheck } from "react-icons/fa6"; 2 | 3 | const SubMenu = ({ subMenuRef, list, position = "right" }) => { 4 | return ( 5 |
      6 | {list?.map((item) => ( 7 |
    • 8 | {item.selected && } 9 | {item.icon} 10 | {item.title} 11 |
    • 12 | ))} 13 |
    14 | ); 15 | }; 16 | 17 | export default SubMenu; 18 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorTooltip/ErrorTooltip.jsx: -------------------------------------------------------------------------------- 1 | import "./ErrorTooltip.scss"; 2 | 3 | const ErrorTooltip = ({ message, xPlacement, yPlacement }) => { 4 | return

    {message}

    ; 5 | }; 6 | 7 | export default ErrorTooltip; 8 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorTooltip/ErrorTooltip.scss: -------------------------------------------------------------------------------- 1 | .error-tooltip { 2 | position: absolute; 3 | z-index: 1; 4 | bottom: -68px; 5 | left: 16px; 6 | padding: 8px; 7 | width: 292px; 8 | border-radius: 5px; 9 | background-color: #696969; 10 | text-align: left; 11 | margin: 0; 12 | font-size: 0.9em; 13 | 14 | &::before { 15 | content: ""; 16 | position: absolute; 17 | top: -20%; 18 | rotate: -45deg; 19 | border: 15px solid #696969; 20 | border-color: transparent #696969 transparent transparent; 21 | } 22 | } 23 | 24 | .error-tooltip.right { 25 | left: 16px; 26 | 27 | &::before { 28 | left: 11%; 29 | } 30 | } 31 | 32 | .error-tooltip.left { 33 | left: -180px; 34 | 35 | &::before { 36 | left: 76%; 37 | transform: rotate(90deg) scaleX(-1); 38 | } 39 | } 40 | 41 | .error-tooltip.top { 42 | bottom: unset !important; 43 | top: -68px; 44 | 45 | &::before { 46 | content: none; 47 | } 48 | 49 | &:after { 50 | content: ""; 51 | position: absolute; 52 | bottom: -20%; 53 | left: 11%; 54 | rotate: -45deg; 55 | border: 15px solid #696969; 56 | border-color: transparent transparent #696969 transparent; 57 | } 58 | } 59 | 60 | .error-tooltip.top.left { 61 | &::after { 62 | left: 76%; 63 | transform: rotate(90deg) scaleX(-1); 64 | } 65 | } -------------------------------------------------------------------------------- /frontend/src/components/Loader/Loader.jsx: -------------------------------------------------------------------------------- 1 | import { ImSpinner2 } from "react-icons/im"; 2 | import "./Loader.scss"; 3 | 4 | const Loader = ({ loading = false, className }) => { 5 | if (!loading) return null; 6 | 7 | return ( 8 |
    9 | 10 |
    11 | ); 12 | }; 13 | 14 | export default Loader; 15 | -------------------------------------------------------------------------------- /frontend/src/components/Loader/Loader.scss: -------------------------------------------------------------------------------- 1 | .loader-container { 2 | position: absolute; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | height: -webkit-fill-available; 7 | width: -webkit-fill-available; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | z-index: 1000; 10 | } 11 | 12 | .spinner { 13 | font-size: 3rem; 14 | color: white; 15 | animation: spin 1s linear infinite; 16 | } 17 | 18 | @keyframes spin { 19 | 0% { 20 | transform: rotate(0deg); 21 | } 22 | 23 | 100% { 24 | transform: rotate(360deg); 25 | } 26 | } 27 | 28 | .upload-loading { 29 | position: relative; 30 | display: block; 31 | background-color: transparent; 32 | z-index: 0; 33 | 34 | .spinner { 35 | font-size: .9rem; 36 | color: black; 37 | } 38 | } -------------------------------------------------------------------------------- /frontend/src/components/Modal/Modal.jsx: -------------------------------------------------------------------------------- 1 | import { MdClose } from "react-icons/md"; 2 | import { useEffect, useRef } from "react"; 3 | import { useTranslation } from "../../contexts/TranslationProvider"; 4 | import "./Modal.scss"; 5 | 6 | const Modal = ({ 7 | children, 8 | show, 9 | setShow, 10 | heading, 11 | dialogWidth = "25%", 12 | contentClassName = "", 13 | closeButton = true, 14 | }) => { 15 | const modalRef = useRef(null); 16 | const t = useTranslation(); 17 | 18 | const handleKeyDown = (e) => { 19 | if (e.key === "Escape") { 20 | setShow(false); 21 | } 22 | }; 23 | 24 | useEffect(() => { 25 | if (show) { 26 | modalRef.current.showModal(); 27 | } else { 28 | modalRef.current.close(); 29 | } 30 | }, [show]); 31 | 32 | return ( 33 | 39 |
    40 | {heading} 41 | {closeButton && ( 42 | setShow(false)} 45 | className="close-icon" 46 | title={t("close")} 47 | /> 48 | )} 49 |
    50 | {children} 51 |
    52 | ); 53 | }; 54 | 55 | export default Modal; 56 | -------------------------------------------------------------------------------- /frontend/src/components/Modal/Modal.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/variables"; 2 | 3 | .fm-modal-header { 4 | display: flex; 5 | justify-content: space-between; 6 | align-items: center; 7 | border-bottom: 1px solid $border-color; 8 | padding: 0.3rem 1rem; 9 | 10 | .fm-modal-heading { 11 | margin: 0; 12 | font-weight: bold; 13 | color: black; 14 | } 15 | } 16 | 17 | .dialog[open] { 18 | animation: expand 0.4s forwards; 19 | 20 | &::backdrop { 21 | background: rgba(0, 0, 0, 0.5); 22 | } 23 | } 24 | 25 | @keyframes expand { 26 | from { 27 | transform: scale(0.4); 28 | } 29 | 30 | to { 31 | transform: scale(1); 32 | } 33 | } -------------------------------------------------------------------------------- /frontend/src/components/NameInput/NameInput.jsx: -------------------------------------------------------------------------------- 1 | import "./NameInput.scss"; 2 | 3 | const NameInput = ({ nameInputRef, maxLength, value, onChange, onKeyDown, onClick, rows }) => { 4 | return ( 5 |