diff --git a/backend/controllers/file.ts b/backend/controllers/file.ts index 6a42d5b..d518645 100644 --- a/backend/controllers/file.ts +++ b/backend/controllers/file.ts @@ -486,6 +486,27 @@ class FileController { } }; + trashFile = async (req: RequestType, res: Response) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const fileID = req.body.id; + + const trashedFile = await fileService.trashFile(userID, fileID); + + res.send(trashedFile.toObject()); + } catch (e: unknown) { + if (e instanceof Error) { + console.log("\nTrash File Error File Route:", e.message); + } + + res.status(500).send("Server error trashing file"); + } + }; + deleteFile = async (req: RequestType, res: Response) => { if (!req.user) { return; @@ -508,6 +529,27 @@ class FileController { res.status(500).send("Server error deleting file"); } }; + + trashMulti = async (req: RequestType, res: Response) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const items = req.body.items; + + await this.chunkService.trashMulti(userID, items); + + res.send(); + } catch (e: unknown) { + if (e instanceof Error) { + console.log("\nTrash Multi Error File Route:", e.message); + } + + res.status(500).send("Server error trashing multi"); + } + }; } export default FileController; diff --git a/backend/controllers/folder.ts b/backend/controllers/folder.ts index e6d8a69..4fe9ba3 100644 --- a/backend/controllers/folder.ts +++ b/backend/controllers/folder.ts @@ -220,6 +220,27 @@ class FolderController { } }; + trashFolder = async (req: RequestType, res: Response) => { + if (!req.user) { + return; + } + + try { + const userID = req.user._id; + const folderID = req.body.id; + + await folderService.trashFolder(userID, folderID); + + res.send(); + } catch (e: unknown) { + if (e instanceof Error) { + console.log("\nTrash Folder Error Folder Route:", e.message); + } + + res.status(500).send("Server error trashing folder"); + } + }; + renameFolder = async (req: RequestType, res: Response) => { if (!req.user) { return; diff --git a/backend/db/utils/fileUtils/index.ts b/backend/db/utils/fileUtils/index.ts index 9683590..683907c 100644 --- a/backend/db/utils/fileUtils/index.ts +++ b/backend/db/utils/fileUtils/index.ts @@ -72,6 +72,21 @@ class DbUtil { return file; }; + trashFile = async (fileID: string, userID: string) => { + const result = await File.findByIdAndUpdate( + { + _id: new ObjectId(fileID), + "metadata.owner": userID, + }, + { + $set: { + "metadata.trashed": true, + }, + } + ); + return result; + }; + getFileInfo = async (fileID: string, userID: string) => { //TODO: Using mongoose like this causes the object to be returned in raw form // const file = await File.findOne({ @@ -87,7 +102,7 @@ class DbUtil { }; getQuickList = async (userID: string, limit: number) => { - let query: any = { "metadata.owner": userID }; + let query: any = { "metadata.owner": userID, "metadata.trashed": null }; const fileList = (await conn.db .collection("fs.files") @@ -133,6 +148,21 @@ class DbUtil { return fileList; }; + trashFilesByParent = async (parentList: string, userID: string) => { + const result = await File.updateMany( + { + "metadata.owner": userID, + "metadata.parentList": { $regex: `.*${parentList}.*` }, // REGEX + }, + { + $set: { + "metadata.trashed": true, + }, + } + ); + return result; + }; + renameFile = async (fileID: string, userID: string, title: string) => { const updateFileResponse = await File.findOneAndUpdate( { _id: new ObjectId(fileID), "metadata.owner": userID }, diff --git a/backend/db/utils/folderUtils/index.ts b/backend/db/utils/folderUtils/index.ts index 379faec..8ae52ef 100644 --- a/backend/db/utils/folderUtils/index.ts +++ b/backend/db/utils/folderUtils/index.ts @@ -30,7 +30,8 @@ class DbUtil { s3Enabled: boolean, type: string, storageType: string, - itemType: string + itemType: string, + trashMode: boolean ) => { let query: any = { owner: userID, parent: parent }; @@ -52,6 +53,12 @@ class DbUtil { query = { ...query, personalFolder: null }; } + if (trashMode) { + query = { ...query, trashed: true }; + } else { + query = { ...query, trashed: null }; + } + const folderList = (await Folder.find(query).sort( sortBy )) as FolderInterface[]; @@ -136,6 +143,21 @@ class DbUtil { return folderList; }; + + trashFoldersByParent = async (parentList: string[], userID: string) => { + const result = await Folder.updateMany( + { + owner: userID, + parentList: { $all: parentList }, + }, + { + $set: { + trashed: true, + }, + } + ); + return result; + }; } export default DbUtil; diff --git a/backend/express-routers/file.ts b/backend/express-routers/file.ts index 28ae204..5ffa9fe 100644 --- a/backend/express-routers/file.ts +++ b/backend/express-routers/file.ts @@ -99,6 +99,10 @@ router.patch("/file-service/rename", auth, fileController.renameFile); router.patch("/file-service/move", auth, fileController.moveFile); +router.patch("/file-service/trash", auth, fileController.trashFile); + +router.patch("/file-service/trash-multi", auth, fileController.trashMulti); + router.delete("/file-service/remove-link/:id", auth, fileController.removeLink); router.delete( diff --git a/backend/express-routers/folder.ts b/backend/express-routers/folder.ts index 3de4d6c..b1640b2 100644 --- a/backend/express-routers/folder.ts +++ b/backend/express-routers/folder.ts @@ -37,6 +37,8 @@ router.patch("/folder-service/rename", auth, folderController.renameFolder); router.patch("/folder-service/move", auth, folderController.moveFolder); +router.patch("/folder-service/trash", auth, folderController.trashFolder); + router.get( "/folder-service/subfolder-list-full", auth, diff --git a/backend/models/file.ts b/backend/models/file.ts index 24c9ee1..bd67ac6 100644 --- a/backend/models/file.ts +++ b/backend/models/file.ts @@ -53,6 +53,7 @@ const fileSchema = new mongoose.Schema({ filePath: String, s3ID: String, personalFile: Boolean, + trashed: Boolean, }, required: true, }, @@ -79,6 +80,7 @@ export interface FileInterface filePath?: string; s3ID?: string; personalFile?: boolean; + trashed?: boolean; }; } diff --git a/backend/models/folder.ts b/backend/models/folder.ts index 5c48817..06df161 100644 --- a/backend/models/folder.ts +++ b/backend/models/folder.ts @@ -22,6 +22,7 @@ const folderSchema = new mongoose.Schema( required: true, }, personalFolder: Boolean, + trashed: Boolean, }, { timestamps: true, @@ -38,6 +39,7 @@ export interface FolderInterface parentList: string[]; _doc?: any; personalFolder?: boolean; + trashed?: boolean; } const Folder = mongoose.model("Folder", folderSchema); diff --git a/backend/services/ChunkService/FileSystemService.ts b/backend/services/ChunkService/FileSystemService.ts index d4b0c16..fa72392 100644 --- a/backend/services/ChunkService/FileSystemService.ts +++ b/backend/services/ChunkService/FileSystemService.ts @@ -29,9 +29,13 @@ import addToStoageSize from "./utils/addToStorageSize"; import subtractFromStorageSize from "./utils/subtractFromStorageSize"; import ForbiddenError from "../../utils/ForbiddenError"; import { ObjectId } from "mongodb"; +import FolderService from "../FolderService"; +import MongoFileService from "../FileService"; const dbUtilsFile = new DbUtilFile(); const dbUtilsFolder = new DbUtilFolder(); +const folderService = new FolderService(); +const fileService = new MongoFileService(); class FileSystemService implements ChunkInterface { constructor() {} @@ -466,6 +470,33 @@ class FileSystemService implements ChunkInterface { } }; + trashMulti = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => { + const fileList = items.filter( + (item) => item.type === "file" || item.type === "quick-item" + ); + const folderList = items + .filter((item) => item.type === "folder") + .sort((a, b) => { + if (!a.folder || !b.folder) return 0; + return b.folder.parentList.length - a.folder.parentList.length; + }); + + for (const file of fileList) { + await fileService.trashFile(userID, file.id); + } + for (const folder of folderList) { + await folderService.trashFolder(userID, folder.id); + } + }; + deleteFile = async (userID: string, fileID: string) => { const file = await dbUtilsFile.getFileInfo(fileID, userID); diff --git a/backend/services/ChunkService/S3Service.ts b/backend/services/ChunkService/S3Service.ts index 8559478..5f73664 100644 --- a/backend/services/ChunkService/S3Service.ts +++ b/backend/services/ChunkService/S3Service.ts @@ -604,6 +604,16 @@ class S3Service implements ChunkInterface { } } }; + + trashMulti = async ( + userID: string, + items: { + type: "file" | "folder" | "quick-item"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => {}; } export default S3Service; diff --git a/backend/services/ChunkService/utils/ChunkInterface.ts b/backend/services/ChunkService/utils/ChunkInterface.ts index 920d4af..adb9568 100644 --- a/backend/services/ChunkService/utils/ChunkInterface.ts +++ b/backend/services/ChunkService/utils/ChunkInterface.ts @@ -1,15 +1,39 @@ import { UserInterface } from "../../../models/user"; import { FileInterface } from "../../../models/file"; import { Request, Response } from "express"; +import { FolderInterface } from "../../../models/folder"; interface ChunkInterface { - uploadFile: (user: UserInterface, busboy: any, req: Request) => Promise; - downloadFile: (user: UserInterface, fileID: string, res: Response) => void; - getThumbnail: (user: UserInterface, id: string) => Promise; - getFullThumbnail: (user: UserInterface, fileID: string, res: Response) => void; - getPublicDownload: (fileID: string, tempToken: any, res: Response) => void; - streamVideo: (user: UserInterface, fileID: string, headers: any, res: Response, req: Request) => void; - getFileReadStream: (user: UserInterface, fileID: string) => any + uploadFile: ( + user: UserInterface, + busboy: any, + req: Request + ) => Promise; + downloadFile: (user: UserInterface, fileID: string, res: Response) => void; + getThumbnail: (user: UserInterface, id: string) => Promise; + getFullThumbnail: ( + user: UserInterface, + fileID: string, + res: Response + ) => void; + getPublicDownload: (fileID: string, tempToken: any, res: Response) => void; + streamVideo: ( + user: UserInterface, + fileID: string, + headers: any, + res: Response, + req: Request + ) => void; + getFileReadStream: (user: UserInterface, fileID: string) => any; + trashMulti: ( + userID: string, + items: { + type: "file" | "folder"; + id: string; + file?: FileInterface; + folder?: FolderInterface; + }[] + ) => Promise; } -export default ChunkInterface; \ No newline at end of file +export default ChunkInterface; diff --git a/backend/services/FileService/index.ts b/backend/services/FileService/index.ts index 627d71b..a515243 100644 --- a/backend/services/FileService/index.ts +++ b/backend/services/FileService/index.ts @@ -2,7 +2,7 @@ import NotAuthorizedError from "../../utils/NotAuthorizedError"; import NotFoundError from "../../utils/NotFoundError"; import env from "../../enviroment/env"; import jwt from "jsonwebtoken"; -import Folder from "../../models/folder"; +import Folder, { FolderInterface } from "../../models/folder"; import sortBySwitch from "../../utils/sortBySwitch"; import createQuery from "../../utils/createQuery"; import DbUtilFile from "../../db/utils/fileUtils/index"; @@ -127,6 +127,7 @@ class MongoFileService { const startAtName = query.startAtName || ""; const storageType = query.storageType || undefined; const folderSearch = query.folder_search || undefined; + const trashMode = query.trashMode === "true"; sortBy = sortBySwitch(sortBy); limit = parseInt(limit); console.log("sortBy", sortBy, query.sortBy); @@ -143,7 +144,8 @@ class MongoFileService { s3Enabled, startAtName, storageType, - folderSearch + folderSearch, + trashMode ); const fileList = await dbUtilsFile.getList(queryObj, sortBy, limit); @@ -219,6 +221,12 @@ class MongoFileService { }; }; + trashFile = async (userID: string, fileID: string) => { + const trashedFile = await dbUtilsFile.trashFile(fileID, userID); + if (!trashedFile) throw new NotFoundError("Trash File Not Found Error"); + return trashedFile; + }; + renameFile = async (userID: string, fileID: string, title: string) => { const file = await dbUtilsFile.renameFile(fileID, userID, title); diff --git a/backend/services/FolderService/index.ts b/backend/services/FolderService/index.ts index a65d6d0..f7b851f 100644 --- a/backend/services/FolderService/index.ts +++ b/backend/services/FolderService/index.ts @@ -122,6 +122,7 @@ class FolderService { const storageType = query.storageType || undefined; const folderSearch = query.folder_search || undefined; const itemType = query.itemType || undefined; + const trashMode = query.trashMode === "true"; sortBy = sortBySwitch(sortBy); const s3Enabled = user.s3Enabled ? true : false; @@ -134,7 +135,8 @@ class FolderService { s3Enabled, type, storageType, - itemType + itemType, + trashMode ); if (!folderList) throw new NotFoundError("Folder List Not Found Error"); @@ -207,6 +209,21 @@ class FolderService { return folderList; }; + trashFolder = async (userID: string, folderID: string) => { + const folder = await utilsFolder.getFolderInfo(folderID, userID); + + if (!folder) throw new NotFoundError("Trash Folder Not Found Error"); + + folder.trashed = true; + await folder.save(); + + const parentList = [...folder.parentList, folder._id!.toString()]; + + await utilsFolder.trashFoldersByParent(parentList, userID); + + await utilsFile.trashFilesByParent(parentList.toString(), userID); + }; + moveFolder = async (userID: string, folderID: string, parentID: string) => { let parentList = ["/"]; diff --git a/backend/utils/createQuery.ts b/backend/utils/createQuery.ts index 16d66a7..d6acc31 100644 --- a/backend/utils/createQuery.ts +++ b/backend/utils/createQuery.ts @@ -16,6 +16,7 @@ export interface QueryInterface { $gt?: Date; }; "metadata.personalFile"?: boolean | null; + "metadata.trashed"?: boolean | null; } const createQuery = ( @@ -28,7 +29,8 @@ const createQuery = ( s3Enabled: boolean, startAtName: string, storageType: string, - folderSearch: boolean + folderSearch: boolean, + trashMode: boolean ) => { let query: QueryInterface = { "metadata.owner": owner }; @@ -63,6 +65,12 @@ const createQuery = ( query = { ...query, "metadata.personalFile": null }; } + if (trashMode) { + query = { ...query, "metadata.trashed": true }; + } else { + query = { ...query, "metadata.trashed": null }; + } + // if (storageType === "s3") { // query = {...query, "metadata.personalFile": true} // } diff --git a/public/assets/checkbox-multiple-outline.svg b/public/assets/checkbox-multiple-outline.svg new file mode 100644 index 0000000..e14d49a --- /dev/null +++ b/public/assets/checkbox-multiple-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/api/filesAPI.ts b/src/api/filesAPI.ts index 139dd67..86c2c0d 100644 --- a/src/api/filesAPI.ts +++ b/src/api/filesAPI.ts @@ -10,6 +10,7 @@ interface QueryKeyParams { startAtDate?: string; startAtName?: string; startAt?: boolean; + trashMode?: boolean; } // GET @@ -20,7 +21,7 @@ export const getFilesListAPI = async ({ }: QueryFunctionContext<[string, QueryKeyParams]>) => { const [ _key, - { parent = "/", search = "", sortBy = "date_desc", limit = 50 }, + { parent = "/", search = "", sortBy = "date_desc", limit = 50, trashMode }, ] = queryKey; const queryParams: QueryKeyParams = { @@ -28,6 +29,7 @@ export const getFilesListAPI = async ({ search, sortBy, limit, + trashMode, }; if (pageParam?.startAtDate && pageParam?.startAtName) { @@ -93,6 +95,21 @@ export const getSuggestedListAPI = async ({ }; // PATCH + +export const trashFileAPI = async (fileID: string) => { + const response = await axios.patch(`/file-service/trash`, { + id: fileID, + }); + return response.data; +}; + +export const trashMultiAPI = async (items: any) => { + const response = await axios.patch(`/file-service/trash-multi`, { + items, + }); + return response.data; +}; + export const renameFileAPI = async (fileID: string, name: string) => { const response = await axios.patch(`/file-service/rename`, { id: fileID, diff --git a/src/api/foldersAPI.ts b/src/api/foldersAPI.ts index 13bf966..bb4a621 100644 --- a/src/api/foldersAPI.ts +++ b/src/api/foldersAPI.ts @@ -6,6 +6,7 @@ interface QueryKeyParams { search?: string; sortBy?: string; limit?: number; + trashMode?: boolean; } // GET @@ -13,13 +14,14 @@ interface QueryKeyParams { export const getFoldersListAPI = async ({ queryKey, }: QueryFunctionContext<[string, QueryKeyParams]>) => { - const [_key, { parent, search, sortBy, limit }] = queryKey; + const [_key, { parent, search, sortBy, limit, trashMode }] = queryKey; const response = await axios.get(`/folder-service/list`, { params: { parent, search, sortBy, limit, + trashMode, }, }); return response.data; @@ -54,6 +56,13 @@ export const renameFolder = async (folderID: string, name: string) => { return response.data; }; +export const trashFolderAPI = (folderID: string) => { + const response = axios.patch("/folder-service/trash", { + id: folderID, + }); + return response.data; +}; + // DELETE export const deleteFolder = async (folderID: string, parentList: string[]) => { diff --git a/src/components/ContextMenu/index.jsx b/src/components/ContextMenu/index.jsx index e4b4b35..b7e4f6c 100644 --- a/src/components/ContextMenu/index.jsx +++ b/src/components/ContextMenu/index.jsx @@ -5,24 +5,31 @@ import { deleteFileAPI, renameFileAPI, downloadFileAPI, + trashFileAPI, } from "../../api/filesAPI"; import { useFilesClient, useQuickFilesClient } from "../../hooks/files"; import { useFoldersClient } from "../../hooks/folders"; import { useDispatch } from "react-redux"; import { setMoverID } from "../../actions/mover"; import { setShareSelected } from "../../actions/selectedItem"; -import { deleteFolder, renameFolder } from "../../api/foldersAPI"; +import { + deleteFolder, + renameFolder, + trashFolderAPI, +} from "../../api/foldersAPI"; import { useClickOutOfBounds } from "../../hooks/utils"; +import { useAppDispatch } from "../../hooks/store"; +import { setMultiSelectMode } from "../../reducers/selected"; const ContextMenu = (props) => { const { invalidateFilesCache } = useFilesClient(); const { invalidateFoldersCache } = useFoldersClient(); const { invalidateQuickFilesCache } = useQuickFilesClient(); const { wrapperRef } = useClickOutOfBounds(props.closeContext); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const liClassname = "flex w-full px-[20px] py-[12px] items-center font-normal text-[#637381] justify-start no-underline transition-all duration-400 ease-in-out text- hover:bg-[#f6f5fd] hover:text-[#3c85ee] hover:font-medium"; - const spanClassname = "inline-flex mr-[18px]"; + const spanClassname = "flex items-center justify-center mr-[18px]"; const renameItem = async () => { props.closeContext(); @@ -67,32 +74,32 @@ const ContextMenu = (props) => { props.closeContext(); if (!props.folderMode) { const result = await Swal.fire({ - title: "Confirm Deletion", - text: "You cannot undo this action", + title: "Move to trash?", + text: "Items in the trash will eventually be deleted.", icon: "warning", showCancelButton: true, confirmButtonColor: "#3085d6", cancelButtonColor: "#d33", - confirmButtonText: "Yes, delete", + confirmButtonText: "Yes", }); if (result.value) { - await deleteFileAPI(props.file._id); + await trashFileAPI(props.file._id); invalidateFilesCache(); invalidateQuickFilesCache(); } } else { const result = await Swal.fire({ - title: "Confirm Deletion", - text: "You cannot undo this action", + title: "Move to trash?", + text: "Items in the trash will eventually be deleted.", icon: "warning", showCancelButton: true, confirmButtonColor: "#3085d6", cancelButtonColor: "#d33", - confirmButtonText: "Yes, delete", + confirmButtonText: "Yes", }); if (result.value) { - await deleteFolder(props.folder._id, props.folder.parentList); + await trashFolderAPI(props.folder._id); invalidateFoldersCache(); } } @@ -117,6 +124,29 @@ const ContextMenu = (props) => { downloadFileAPI(props.file._id); }; + const selectItemMultiSelect = () => { + props.closeContext(); + if (props.folderMode) { + dispatch( + setMultiSelectMode({ + type: "folder", + id: props.folder._id, + file: null, + folder: props.folder, + }) + ); + } else { + dispatch( + setMultiSelectMode({ + type: props.quickItemMode ? "quick-item" : "file", + id: props.file._id, + file: props.file, + folder: null, + }) + ); + } + }; + return (
{ } > diff --git a/src/components/Dataform/index.jsx b/src/components/Dataform/index.jsx index a91da1f..050764f 100644 --- a/src/components/Dataform/index.jsx +++ b/src/components/Dataform/index.jsx @@ -3,9 +3,15 @@ import Folders from "../Folders"; import { useFiles } from "../../hooks/files"; import { useInfiniteScroll } from "../../hooks/infiniteScroll"; import Files from "../Files"; -import { memo, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import SpinnerPage from "../SpinnerPage"; import SearchBar from "../SearchBar"; +import { useAppDispatch } from "../../hooks/store"; +import { startAddFile } from "../../actions/files"; +import { useParams } from "react-router-dom"; +import classNames from "classnames"; +import { useDragAndDrop } from "../../hooks/utils"; +import MultiSelectBar from "../MultiSelectBar"; const DataForm = memo(() => { const { @@ -13,8 +19,10 @@ const DataForm = memo(() => { isFetchingNextPage, data: fileList, } = useFiles(); + const dispatch = useAppDispatch(); const { sentinelRef, reachedIntersect } = useInfiniteScroll(); const [initialLoad, setInitialLoad] = useState(true); + const params = useParams(); useEffect(() => { if (initialLoad) { @@ -28,8 +36,34 @@ const DataForm = memo(() => { } }, [reachedIntersect, initialLoad, isFetchingNextPage]); + const addFile = useCallback( + (file) => { + dispatch(startAddFile(file, params.id)); + }, + [params.id] + ); + + const { + isDraggingFile, + onDragDropEvent, + onDragEvent, + onDragEnterEvent, + stopDrag, + } = useDragAndDrop(addFile); + return ( -
+
+ + diff --git a/src/components/FileItem/index.jsx b/src/components/FileItem/index.jsx index 75c3191..550aac4 100644 --- a/src/components/FileItem/index.jsx +++ b/src/components/FileItem/index.jsx @@ -12,7 +12,7 @@ import { startSetSelectedItem } from "../../actions/selectedItem"; import { setPopupFile } from "../../actions/popupFile"; import bytes from "bytes"; import { useAppDispatch, useAppSelector } from "../../hooks/store"; -import { setMainSelect } from "../../reducers/selected"; +import { setMainSelect, setMultiSelectMode } from "../../reducers/selected"; const FileItem = React.memo((props) => { const { file } = props; @@ -20,6 +20,14 @@ const FileItem = React.memo((props) => { if (state.selected.mainSection.type !== "file") return false; return state.selected.mainSection.id === file._id; }); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + const selected = state.selected.multiSelectMap[file._id]; + return selected && selected.type === "file"; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); const listView = useSelector((state) => state.filter.listView); const { image, hasThumbnail, imageOnError } = useThumbnail( file.metadata.hasThumbnail, @@ -58,6 +66,18 @@ const FileItem = React.memo((props) => { // TODO: See if we can memoize this const fileClick = () => { + if (multiSelectMode) { + dispatch( + setMultiSelectMode({ + type: "file", + id: file._id, + file: file, + folder: null, + }) + ); + return; + } + const currentDate = Date.now(); if (!elementSelected) { @@ -82,7 +102,7 @@ const FileItem = React.memo((props) => { {
{
{
{

{formattedFilename} @@ -237,7 +263,9 @@ const FileItem = React.memo((props) => {

{
{

{folder.name} @@ -127,7 +150,9 @@ const FolderItem = React.memo((props) => {

; + + return ( +
+
+
+ +

{multiSelectCount} selected

+
+ +
+ + + + + + + + + + + +
+
+
+ ); +}; + +export default MultiSelectBar; diff --git a/src/components/QuickAccessItem/index.tsx b/src/components/QuickAccessItem/index.tsx index c4edd0b..503a51d 100644 --- a/src/components/QuickAccessItem/index.tsx +++ b/src/components/QuickAccessItem/index.tsx @@ -12,7 +12,7 @@ import { startSetSelectedItem } from "../../actions/selectedItem"; import { setPopupFile } from "../../actions/popupFile"; import { FileInterface } from "../../types/file"; import { useAppDispatch, useAppSelector } from "../../hooks/store"; -import { setMainSelect } from "../../reducers/selected"; +import { setMainSelect, setMultiSelectMode } from "../../reducers/selected"; interface QuickAccessItemProps { file: FileInterface; @@ -24,10 +24,18 @@ const QuickAccessItem = memo((props: QuickAccessItemProps) => { if (state.selected.mainSection.type !== "quick-item") return false; return state.selected.mainSection.id === file._id; }); - console.log("ele selected 2", elementSelected); + const elementMultiSelected = useAppSelector((state) => { + if (!state.selected.multiSelectMode) return false; + const selected = state.selected.multiSelectMap[file._id]; + return selected && selected.type === "quick-item"; + }); + const multiSelectMode = useAppSelector( + (state) => state.selected.multiSelectMode + ); const { image, hasThumbnail, imageOnError } = useThumbnail( file.metadata.hasThumbnail, - file.metadata.thumbnailID + file.metadata.thumbnailID, + true ); const dispatch = useAppDispatch(); const lastSelected = useRef(0); @@ -54,6 +62,17 @@ const QuickAccessItem = memo((props: QuickAccessItemProps) => { // TODO: See if we can memoize this const quickItemClick = () => { + if (multiSelectMode) { + dispatch( + setMultiSelectMode({ + type: "quick-item", + id: file._id, + file: file, + folder: null, + }) + ); + return; + } const currentDate = Date.now(); if (!elementSelected) { @@ -77,8 +96,10 @@ const QuickAccessItem = memo((props: QuickAccessItemProps) => { return (
{
{

{capitalize(file.filename)} @@ -152,7 +175,9 @@ const QuickAccessItem = memo((props: QuickAccessItemProps) => {

folderClick(folder)} > @@ -48,7 +48,7 @@ const SearchBarItem = (props: SearchBarItemProps) => { >
- + {folder.name}
@@ -56,7 +56,7 @@ const SearchBarItem = (props: SearchBarItemProps) => { } else if (type === "file" && file) { return (
fileClick(file)} > diff --git a/src/hooks/files.ts b/src/hooks/files.ts index ea04239..b63d5f5 100644 --- a/src/hooks/files.ts +++ b/src/hooks/files.ts @@ -11,13 +11,16 @@ import { getQuickFilesListAPI, getSuggestedListAPI, } from "../api/filesAPI"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSelector } from "react-redux"; +import { useAppSelector } from "./store"; +import { useUtils } from "./utils"; export const useFiles = () => { const params = useParams(); // TODO: Remove any const sortBy = useSelector((state: any) => state.filter.sortBy); + const trashMode = false; const filesReactQuery = useInfiniteQuery( [ "files", @@ -26,6 +29,7 @@ export const useFiles = () => { search: params.query || "", sortBy, limit: undefined, + trashMode, }, ], getFilesListAPI, @@ -53,6 +57,7 @@ export const useFilesClient = () => { // TODO: Remove any const sortBy = useSelector((state: any) => state.filter.sortBy); const filesReactClientQuery = useQueryClient(); + const trashMode = false; const invalidateFilesCache = () => { filesReactClientQuery.invalidateQueries({ @@ -63,6 +68,7 @@ export const useFilesClient = () => { search: "", sortBy, limit: undefined, + trashMode, }, ], }); @@ -94,11 +100,18 @@ interface thumbnailState { image: undefined | string; } -export const useThumbnail = (hasThumbnail: boolean, thumbnailID?: string) => { +export const useThumbnail = ( + hasThumbnail: boolean, + thumbnailID?: string, + isQuickFile?: boolean +) => { + const requestedThumbnail = useRef(false); const [state, setState] = useState({ hasThumbnail: false, image: undefined, }); + const { isHome } = useUtils(); + const listView = useAppSelector((state) => state.filter.listView); const imageOnError = useCallback(() => { setState({ @@ -108,7 +121,11 @@ export const useThumbnail = (hasThumbnail: boolean, thumbnailID?: string) => { }, []); const getThumbnail = useCallback(async () => { try { - if (!thumbnailID) return; + if (!thumbnailID || requestedThumbnail.current) return; + if (isQuickFile && !isHome) return; + if (!isQuickFile && listView) return; + console.log("getting thumbnail", thumbnailID); + requestedThumbnail.current = true; const thumbnailData = await getFileThumbnailAPI(thumbnailID); setState({ hasThumbnail: true, @@ -118,7 +135,13 @@ export const useThumbnail = (hasThumbnail: boolean, thumbnailID?: string) => { console.log("error getting thumbnail data", e); imageOnError(); } - }, [thumbnailID, getFileThumbnailAPI, imageOnError]); + }, [ + thumbnailID, + getFileThumbnailAPI, + imageOnError, + listView, + requestedThumbnail.current, + ]); useEffect(() => { if (!hasThumbnail || !thumbnailID) return; diff --git a/src/hooks/folders.ts b/src/hooks/folders.ts index 1536d22..98ad97c 100644 --- a/src/hooks/folders.ts +++ b/src/hooks/folders.ts @@ -6,6 +6,7 @@ import { useSelector } from "react-redux"; export const useFolders = () => { const params = useParams(); const sortBy = useSelector((state: any) => state.filter.sortBy); + const trashMode = false; const foldersReactQuery = useQuery( [ "folders", @@ -14,6 +15,7 @@ export const useFolders = () => { search: params.query || "", sortBy, limit: undefined, + trashMode, }, ], getFoldersListAPI @@ -26,6 +28,7 @@ export const useFoldersClient = () => { const params = useParams(); const sortBy = useSelector((state: any) => state.filter.sortBy); const foldersReactClientQuery = useQueryClient(); + const trashMode = false; const invalidateFoldersCache = () => { foldersReactClientQuery.invalidateQueries({ @@ -36,6 +39,7 @@ export const useFoldersClient = () => { search: "", sortBy, limit: undefined, + trashMode, }, ], }); diff --git a/src/hooks/utils.ts b/src/hooks/utils.ts index 3e7b9c9..9e6d43d 100644 --- a/src/hooks/utils.ts +++ b/src/hooks/utils.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; export const useUtils = () => { @@ -39,3 +39,57 @@ export const useClickOutOfBounds = (outOfBoundsCallback: () => any) => { wrapperRef, }; }; + +export const useDragAndDrop = (fileDroppedCallback: (file: any) => any) => { + const [isDraggingFile, setIsDraggingFile] = useState(false); + const isDraggingFileRef = useRef(false); + + const onDragDropEvent = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingFileRef.current = false; + setIsDraggingFile(false); + + const fileInput = e.dataTransfer; + + fileDroppedCallback(fileInput); + }, []); + const onDragEvent = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + const onDragEnterEvent = useCallback((e: any) => { + e.preventDefault(); + e.stopPropagation(); + + if (isDraggingFileRef.current) return; + isDraggingFileRef.current = true; + setIsDraggingFile(true); + }, []); + const stopDrag = useCallback((e: any) => { + e?.preventDefault(); + e?.stopPropagation(); + + if (!isDraggingFileRef.current) return; + isDraggingFileRef.current = false; + setIsDraggingFile(false); + }, []); + + useEffect(() => { + window.addEventListener("dragover", stopDrag); + window.addEventListener("focus", stopDrag); + + return () => { + window.removeEventListener("dragover", stopDrag); + window.removeEventListener("focus", stopDrag); + }; + }, []); + + return { + isDraggingFile, + onDragDropEvent, + onDragEvent, + onDragEnterEvent, + stopDrag, + }; +}; diff --git a/src/reducers/selected.ts b/src/reducers/selected.ts index 4a8f73b..e2a97f7 100644 --- a/src/reducers/selected.ts +++ b/src/reducers/selected.ts @@ -12,6 +12,11 @@ interface MainSecionType { export interface SelectedStateType { mainSection: MainSecionType; popupModal: FileInterface | null; + multiSelectMode: boolean; + multiSelectMap: { + [key: string]: MainSecionType; + }; + multiSelectCount: number; } const initialState: SelectedStateType = { @@ -22,6 +27,9 @@ const initialState: SelectedStateType = { folder: null, }, popupModal: null, + multiSelectMode: false, + multiSelectMap: {}, + multiSelectCount: 0, }; const selectedSlice = createSlice({ @@ -32,9 +40,40 @@ const selectedSlice = createSlice({ state.mainSection = action.payload; }, resetSelected: () => initialState, + setMultiSelectMode: (state, action: PayloadAction) => { + state.mainSection = { type: "", id: "", file: null, folder: null }; + if ( + state.multiSelectMap[action.payload.id] && + state.multiSelectMap[action.payload.id].type !== action.payload.type + ) { + state.multiSelectMode = true; + state.multiSelectMap[action.payload.id] = action.payload; + } else if (state.multiSelectMap[action.payload.id]) { + delete state.multiSelectMap[action.payload.id]; + const newCount = state.multiSelectCount - 1; + if (newCount === 0) { + state.multiSelectMode = false; + } + state.multiSelectCount = newCount; + } else { + state.multiSelectMode = true; + state.multiSelectMap[action.payload.id] = action.payload; + state.multiSelectCount++; + } + }, + resetMultiSelect: (state) => { + state.multiSelectMode = false; + state.multiSelectMap = {}; + state.multiSelectCount = 0; + }, }, }); -export const { setMainSelect, resetSelected } = selectedSlice.actions; +export const { + setMainSelect, + resetSelected, + setMultiSelectMode, + resetMultiSelect, +} = selectedSlice.actions; export default selectedSlice.reducer;