diff --git a/package-lock.json b/package-lock.json index 33193cc..98b0702 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "file-manager", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-icons": "^5.0.1" + "react-icons": "^5.0.1", + "react-redux": "^9.2.0" }, "devDependencies": { "@types/react": "^18.2.43", @@ -927,6 +929,32 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.10.1.tgz", + "integrity": "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.2.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.9.6", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", @@ -1096,6 +1124,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1147,13 +1187,13 @@ "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.2.48", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -1173,7 +1213,13 @@ "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "devOptional": true + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", @@ -1537,7 +1583,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.3.4", @@ -2428,6 +2474,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -3325,6 +3381,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -3334,6 +3413,21 @@ "node": ">=0.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -3371,6 +3465,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -3874,6 +3974,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", diff --git a/package.json b/package.json index b4f4da5..50c0ceb 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@reduxjs/toolkit": "^2.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-icons": "^5.0.1" + "react-icons": "^5.0.1", + "react-redux": "^9.2.0" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/src/App.jsx b/src/App.jsx index f5949bb..d58610b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,39 +1,16 @@ -import { useState } from "react"; +import { useSelector } from "react-redux"; import "./App.css"; -import folderData from "./data/folderData"; import Folder from "./components/Folder"; -import useTraverseTree from "./hooks/use-traverse-tree"; +import { selectRootId } from "./Repository/Slices/explorerSlice"; function App() { - const [explorerData, setExplorerData] = useState(folderData); - const { insertNode, deleteNode, updateNode } = useTraverseTree(); - const handleInsertNode = (folderId, itemName, isFolder) => { - const finalItem = insertNode(explorerData, folderId, itemName, isFolder); - return finalItem; - }; - const handleDeleteNode = (folderId) => { - // Call deleteNode to get the modified tree - const finalItem = deleteNode(explorerData, folderId); - // Update the explorerData state with the modified tree - setExplorerData(finalItem); - }; - - const handleUpdateFolder = (id, updatedValue, isFolder) => { - const finalItem = updateNode(explorerData, id, updatedValue, isFolder); - // Update the explorerData state with the modified tree - setExplorerData(finalItem); - }; + const rootId = useSelector(selectRootId); return (
- + {rootId ? : null}
Your content will be here
diff --git a/src/Repository/Slices/explorerSlice.js b/src/Repository/Slices/explorerSlice.js new file mode 100644 index 0000000..e9ca841 --- /dev/null +++ b/src/Repository/Slices/explorerSlice.js @@ -0,0 +1,102 @@ +import { createSlice } from "@reduxjs/toolkit"; +import folderData from "../../data/folderData"; + +const generateId = () => + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now()); + +function normalizeTree(node) { + const byId = {}; + function walk(n) { + const id = n.id ?? generateId(); + const children = Array.isArray(n.items) ? n.items : []; + const childIds = children.map((c) => { + const childId = walk(c); + return childId; + }); + byId[id] = { + id, + name: n.name, + isFolder: !!n.isFolder, + childrenIds: childIds, + }; + return id; + } + const rootId = walk(folderData); // seed from existing data + return { byId, rootId }; +} + +const initial = normalizeTree(folderData); + +const explorerSlice = createSlice({ + name: "explorer", + initialState: { + nodes: { byId: initial.byId }, + rootId: initial.rootId, + }, + reducers: { + insertNode: { + prepare: (parentId, name, isFolder) => ({ + payload: { parentId, name: String(name).trim(), isFolder: !!isFolder }, + }), + reducer: (state, action) => { + const { parentId, name, isFolder } = action.payload; + if (!name) return; + const parent = state.nodes.byId[parentId]; + if (!parent || !parent.isFolder) return; + + const id = generateId(); + state.nodes.byId[id] = { + id, + name, + isFolder, + childrenIds: [], + }; + parent.childrenIds.unshift(id); + }, + }, + renameNode(state, action) { + const { id, name } = action.payload; + const node = state.nodes.byId[id]; + if (node && String(name).trim()) { + node.name = String(name).trim(); + } + }, + deleteNode(state, action) { + const { id } = action.payload; + if (!state.nodes.byId[id]) return; + + // remove id from any parent list (O(n) but simple) + Object.values(state.nodes.byId).forEach((n) => { + if (n.childrenIds?.length) { + n.childrenIds = n.childrenIds.filter((cid) => cid !== id); + } + }); + + function removeRecursively(nodeId) { + const node = state.nodes.byId[nodeId]; + if (!node) return; + if (node.childrenIds?.length) { + node.childrenIds.forEach(removeRecursively); + } + delete state.nodes.byId[nodeId]; + } + // if deleting root, just clear everything but keep empty root + if (id === state.rootId) { + const root = state.nodes.byId[state.rootId]; + root.childrenIds.forEach(removeRecursively); + root.childrenIds = []; + return; + } + removeRecursively(id); + }, + }, +}); + +export const { insertNode, renameNode, deleteNode } = explorerSlice.actions; + +export const selectRootId = (state) => state.explorer.rootId; +export const selectNodeById = (state, id) => state.explorer.nodes.byId[id]; + +export default explorerSlice.reducer; \ No newline at end of file diff --git a/src/Repository/Store.js b/src/Repository/Store.js new file mode 100644 index 0000000..fc3d172 --- /dev/null +++ b/src/Repository/Store.js @@ -0,0 +1,37 @@ +import { configureStore } from "@reduxjs/toolkit"; +import explorerReducer from "./Slices/explorerSlice"; + +const LS_KEY = "explorerState"; + +function loadState() { + try { + const raw = localStorage.getItem(LS_KEY); + return raw ? JSON.parse(raw) : undefined; + } catch { + return undefined; + } +} + +function saveState(state) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(state)); + } catch { + // ignore + } +} + +const preloadedState = loadState(); + +export const store = configureStore({ + reducer: { + explorer: explorerReducer, + }, + preloadedState, +}); + +store.subscribe(() => { + const state = store.getState(); + saveState({ + explorer: state.explorer, + }); +}); \ No newline at end of file diff --git a/src/components/Folder.jsx b/src/components/Folder.jsx index caf8dd5..0e2e396 100644 --- a/src/components/Folder.jsx +++ b/src/components/Folder.jsx @@ -1,4 +1,5 @@ -import { useState } from "react"; +import { useState, memo, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { VscChevronRight, VscChevronDown, @@ -9,16 +10,17 @@ import { VscEdit, VscTrash, } from "react-icons/vsc"; +import { + insertNode, + deleteNode, + renameNode, + selectNodeById, +} from "../Repository/Slices/explorerSlice"; -const Folder = ({ - handleInsertNode, - handleDeleteNode, - handleUpdateFolder, - explorerData, -}) => { - const [nodeName, setNodeName] = useState( - explorerData?.name ? explorerData.name : "" - ); +const Folder = ({ nodeId }) => { + const dispatch = useDispatch(); + const explorerData = useSelector((s) => selectNodeById(s, nodeId)); + const [nodeName, setNodeName] = useState(explorerData?.name ?? ""); const [expand, setExpand] = useState(false); const [showInput, setShowInput] = useState({ visible: false, @@ -29,47 +31,46 @@ const Folder = ({ isFolder: null, }); - const handleNewFolderButton = (e, isFolder) => { + if (!explorerData) return null; + + const handleNewFolderButton = useCallback((e, isFolder) => { e.stopPropagation(); setExpand(true); setShowInput({ visible: true, isFolder, }); - }; + }, []); - const handleUpdateFolderButton = (e, isFolder, nodeValue) => { + const handleUpdateFolderButton = useCallback((e, isFolder, nodeValue) => { setNodeName(nodeValue); e.stopPropagation(); setUpdateInput({ visible: true, isFolder, }); - }; + }, []); - const handleDeleteFolder = (e, isFolder) => { + const handleDeleteFolder = useCallback((e) => { e.stopPropagation(); - handleDeleteNode(explorerData.id); - }; - const onAdd = (e) => { - if (e.keyCode === 13 && e.target.value) { - // Lets add logic for add folder - handleInsertNode(explorerData.id, e.target.value, showInput.isFolder); + dispatch(deleteNode({ id: explorerData.id })); + }, [dispatch, explorerData?.id]); + const onAdd = useCallback((e) => { + if (e.key === "Enter" && e.target.value.trim()) { + dispatch(insertNode(explorerData.id, e.target.value.trim(), !!showInput.isFolder)); setShowInput({ ...showInput, visible: false }); } - }; - const onUpdate = (e) => { - if (e.keyCode === 13 && e.target.value) { - // Lets add logic for update folder - handleUpdateFolder(explorerData.id, e.target.value, true); + }, [dispatch, explorerData?.id, showInput]); + const onUpdate = useCallback((e) => { + if (e.key === "Enter" && e.target.value.trim()) { + dispatch(renameNode({ id: explorerData.id, name: e.target.value.trim() })); setUpdateInput({ ...updateInput, visible: false }); } - }; + }, [dispatch, explorerData?.id, updateInput]); const handleChange = (event) => { setNodeName(event.target.value); }; if (explorerData.isFolder) { - console.log("nodeName", nodeName); return (
)} - {explorerData.items.map((item, index) => { + {explorerData.childrenIds.map((childId) => { return ( - + ); })}
@@ -178,4 +173,4 @@ const Folder = ({ } }; -export default Folder; +export default memo(Folder); diff --git a/src/hooks/use-traverse-tree.js b/src/hooks/use-traverse-tree.js index 3cdb5de..aa561e0 100644 --- a/src/hooks/use-traverse-tree.js +++ b/src/hooks/use-traverse-tree.js @@ -1,21 +1,23 @@ const useTraverseTree = () => { function insertNode(tree, folderId, itemName, isFolder) { if (tree.id === folderId && tree.isFolder) { - tree.items.unshift({ - id: new Date().getTime(), - name: itemName, - isFolder, - items: [], - }); - return tree; + const newChild = { + id: Date.now(), + name: itemName, + isFolder, + items: [] + }; + + return {...tree, items: [newChild, ...(tree.items || [] )]}; } - tree.items.map((obj) => { - let res = insertNode(obj, folderId, itemName, isFolder); - if (res) { - return res; - } - }); + if(!tree.items) return tree; + + return { + ...tree, + items: tree.items.map((child) => + insertNode(child, folderId, itemName, isFolder) + )}; } function deleteNode(tree, folderId) { // base case: found the node to be deleted diff --git a/src/main.jsx b/src/main.jsx index 54b39dd..a86cf6f 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,9 +2,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' import './index.css' +import { Provider } from 'react-redux' +import { store } from './Repository/Store.js' ReactDOM.createRoot(document.getElementById('root')).render( - + + + , )