diff --git a/frontend/src/components/file-chooser/index.js b/frontend/src/components/file-chooser/index.js index a6a2ee2535..1542b3787f 100644 --- a/frontend/src/components/file-chooser/index.js +++ b/frontend/src/components/file-chooser/index.js @@ -19,7 +19,7 @@ const propTypes = { repoID: PropTypes.string, onDirentItemClick: PropTypes.func, onRepoItemClick: PropTypes.func, - mode: PropTypes.isRequired, + mode: PropTypes.string.isRequired, fileSuffixes: PropTypes.arrayOf(PropTypes.string), currentPath: PropTypes.string, searchResults: PropTypes.array, diff --git a/frontend/src/components/shared-dir-tree-view/index.js b/frontend/src/components/shared-dir-tree-view/index.js new file mode 100644 index 0000000000..91905cb166 --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/index.js @@ -0,0 +1,5 @@ +import TreeHelper from './tree-helper'; +import TreeView from './tree-view'; +import TreeNode from './tree-node'; + +export { TreeHelper, TreeView, TreeNode }; diff --git a/frontend/src/components/shared-dir-tree-view/tree-helper.js b/frontend/src/components/shared-dir-tree-view/tree-helper.js new file mode 100644 index 0000000000..368b19461e --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/tree-helper.js @@ -0,0 +1,150 @@ +import { Utils } from '../../utils/utils'; +import Tree from './tree'; +import TreeNode from './tree-node'; + +class TreeHelper { + + expandNode(tree, node) { // This tree has been cloned + tree.expandNode(node); + } + + collapseNode(tree, node) { + let treeCopy = tree.clone(); + node = treeCopy.getNodeByPath(node.path); + treeCopy.collapseNode(node); + return treeCopy; + } + + findNodeByPath(tree, nodePath) { + let treeCopy = tree.clone(); + let node = treeCopy.getNodeByPath(nodePath); + return node; + } + + getNodeChildrenObject(tree, node, sortType = 'name', order = 'asc') { + let objects = tree.getNodeChildrenObject(node); + objects = Utils.sortDirents(objects, sortType, order); + return objects; + } + + addNodeToParent(tree, node, parentNode) { + tree.addNodeToParentNode(node, parentNode); + return tree; + } + + addNodeListToParent(tree, nodeList, parentNode) { + tree.addNodeListToParent(nodeList, parentNode); + return tree; + } + + addNodeToParentByPath(tree, node, parentPath) { + let treeCopy = tree.clone(); + let parentNode = treeCopy.getNodeByPath(parentPath); + treeCopy.addNodeToParent(node, parentNode); + return treeCopy; + } + + deleteNodeByPath(tree, nodePath) { + let treeCopy = tree.clone(); + let node = treeCopy.getNodeByPath(nodePath); + if (node) { + treeCopy.deleteNode(node); + } + return treeCopy; + } + + deleteNodeListByPaths(tree, nodePaths) { + let treeCopy = tree.clone(); + nodePaths.forEach(nodePath => { + let node = treeCopy.getNodeByPath(nodePath); + if (node) { + treeCopy.deleteNode(node); + } + }); + return treeCopy; + } + + renameNodeByPath(tree, nodePath, newName) { + let treeCopy = tree.clone(); + let node = treeCopy.getNodeByPath(nodePath); + if (!node) { + return treeCopy; + } + treeCopy.renameNode(node, newName); + return treeCopy; + } + + updateNodeByPath(tree, nodePath, keys, newValues) { + let treeCopy = tree.clone(); + let node = treeCopy.getNodeByPath(nodePath); + treeCopy.updateNode(node, keys, newValues); + return treeCopy; + } + + moveNodeByPath(tree, nodePath, destPath, nodeName) { + let treeCopy = tree.clone(); + let node = treeCopy.getNodeByPath(nodePath); + let destNode = treeCopy.getNodeByPath(destPath); + if (destNode && node) { // node has loaded + node.object.name = nodeName; // need not update path + treeCopy.moveNode(node, destNode); + } + if (!destNode && node) { + treeCopy.deleteNode(node); + } + return treeCopy; + } + + moveNodeListByPaths(tree, nodePaths, destPath) { + let treeCopy = tree.clone(); + let destNode = treeCopy.getNodeByPath(destPath); + if (destNode) { + nodePaths.forEach(nodePath => { + let node = treeCopy.getNodeByPath(nodePath); + treeCopy.moveNode(node, destNode); + }); + } else { + nodePaths.forEach(nodePath => { + let node = treeCopy.getNodeByPath(nodePath); + treeCopy.delete(node); + }); + } + return treeCopy; + } + + copyNodeByPath(tree, nodePath, destPath, nodeName) { + let treeCopy = tree.clone(); + let destNode = treeCopy.getNodeByPath(destPath); + let treeNode = treeCopy.getNodeByPath(nodePath); + if (destNode) { + let node = treeNode.clone(); // need a dup + node.object.name = nodeName; // need not update path + treeCopy.copyNode(node, destNode); + } + return treeCopy; + } + + copyNodeListByPaths(tree, nodePaths, destPath) { + let treeCopy = tree.clone(); + let destNode = treeCopy.getNodeByPath(destPath); + if (destNode) { + nodePaths.forEach(nodePath => { + let node = treeCopy.getNodeByPath(nodePath); + treeCopy.copyNode(node, destNode); + }); + } + return treeCopy; + } + + buildTree() { + let tree = new Tree(); + let object = { folder_path: '/', is_dir: true }; + let root = new TreeNode({ object, isLoaded: false, isExpanded: true }); + tree.setRoot(root); + return tree; + } +} + +let treeHelper = new TreeHelper(); + +export default treeHelper; diff --git a/frontend/src/components/shared-dir-tree-view/tree-node-view.js b/frontend/src/components/shared-dir-tree-view/tree-node-view.js new file mode 100644 index 0000000000..9841c6b777 --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/tree-node-view.js @@ -0,0 +1,158 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const LEFT_INDENT = 20; + +const propTypes = { + node: PropTypes.object.isRequired, + currentPath: PropTypes.string.isRequired, + leftIndent: PropTypes.number.isRequired, + onNodeClick: PropTypes.func.isRequired, + onNodeExpanded: PropTypes.func.isRequired, + onNodeCollapse: PropTypes.func.isRequired, +}; + +class TreeNodeView extends React.Component { + + constructor(props) { + super(props); + this.state = { + isHighlight: false, + }; + } + + onMouseEnter = () => { + this.setState({ + isHighlight: true, + }); + }; + + onMouseOver = () => { + this.setState({ + isHighlight: true, + }); + }; + + onMouseLeave = () => { + this.setState({ + isHighlight: false, + }); + }; + + onNodeClick = () => { + const { node } = this.props; + this.props.onNodeClick(node); + }; + + onLoadToggle = (e) => { + e.stopPropagation(); + const { node } = this.props; + if (node.isExpanded) { + this.props.onNodeCollapse(node); + } else { + this.props.onNodeExpanded(node); + } + }; + + getNodeTypeAndIcon = () => { + let { node } = this.props; + let icon = ''; + let type = ''; + if (node.object.is_dir) { + icon = ; + type = 'dir'; + } else { + let index = node.object.file_name.lastIndexOf('.'); + if (index === -1) { + icon = ; + type = 'file'; + } else { + let suffix = node.object.file_name.slice(index).toLowerCase(); + if (suffix === '.png' || suffix === '.jpg' || suffix === '.jpeg' || suffix === '.gif' || suffix === '.bmp') { + icon = ; + type = 'image'; + } + else if (suffix === '.md' || suffix === '.markdown') { + icon = ; + type = 'file'; + } + else { + icon = ; + type = 'file'; + } + } + } + return { icon, type }; + }; + + renderChildren = () => { + let { node } = this.props; + if (!node.hasChildren()) { + return ''; + } + return ( +
+ {node.children.map((item, index) => { + return ( + + ); + })} +
+ ); + }; + + render() { + let { currentPath, node, leftIndent } = this.props; + let { type, icon } = this.getNodeTypeAndIcon(); + let hlClass = this.state.isHighlight ? 'tree-node-inner-hover' : ''; + if (node.path === currentPath) { + hlClass = 'tree-node-hight-light'; + } + + const nodeName = node.object.folder_name || node.object.file_name; + return ( +
+
+
+ {nodeName} +
+
+ {type === 'dir' && (!node.isLoaded || (node.isLoaded && node.hasChildren())) && ( + e.stopPropagation()} + onClick={this.onLoadToggle} + > + + )} + {icon} +
+
+ {node.isExpanded && this.renderChildren()} +
+ ); + } +} + +TreeNodeView.propTypes = propTypes; + +export default TreeNodeView; diff --git a/frontend/src/components/shared-dir-tree-view/tree-node.js b/frontend/src/components/shared-dir-tree-view/tree-node.js new file mode 100644 index 0000000000..3b26000fb3 --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/tree-node.js @@ -0,0 +1,152 @@ +class TreeNode { + + constructor({ path, object, isLoaded, isPreload, isExpanded, parentNode }) { + // this.path = path || object.name; // The default setting is the object name, which is set to a relative path when the father is set. + this.path = object.folder_path; + this.object = object; + this.isLoaded = isLoaded || false; + this.isPreload = isPreload || false; + this.isExpanded = isExpanded || false; + this.children = []; + this.parentNode = parentNode || null; + } + + clone(parentNode) { + let treeNode = new TreeNode({ + path: this.path, + // object: this.object.clone(), + object: this.object, + isLoaded: this.isLoaded, + isPreload: this.isPreload, + isExpanded: this.isExpanded, + parentNode: parentNode || null, + }); + treeNode.children = this.children.map(child => { + let newChild = child.clone(treeNode); + return newChild; + }); + + return treeNode; + } + + setParent(parentNode) { + this.path = this.generatePath(parentNode); + this.parentNode = parentNode; + this.isLoaded = false; // update parentNode need loaded data again; + } + + hasChildren() { + return this.children.length !== 0; + } + + addChild(node) { + node.setParent(this); + let children = this.children; + if (node.object.isDir()) { + this.children.unshift(node); + } else { + let index = -1; + for (let i = 0; i < children.length; i++) { + if (!children[i].object.isDir()) { + index = i; + break; + } + } + if (index === -1) { // -1: all the node object is dir; + this.children.push(node); + } else if (index === 0) { // 0: all the node object is file + this.children.unshift(node); + } else { + this.children.splice(index, 0, node); + } + } + } + + addChildren(nodeList) { + nodeList.forEach(node => { + node.setParent(this); + }); + this.children = nodeList; + } + + deleteChild(node) { + let children = this.children.filter(item => { + return item !== node; + }); + this.children = children; + } + + rename(newName) { + this.object.name = newName; + this.path = this.generatePath(this.parentNode); + if (this.isExpanded) { + this.updateChildrenPath(this); + } else { + this.isLoaded = false; + } + } + + updateChildrenPath(node) { + let children = node.children; + children.forEach(child => { + child.path = child.generatePath(child.parentNode); + if (child.isExpanded) { + child.updateChildrenPath(child); + } else { + child.isLoaded = false; + } + }); + } + + updateObjectProperties(keys, newValues) { + if (Array.isArray(keys) && Array.isArray(newValues)) { + keys.forEach((key, index) => { + this.object[key] = newValues[index]; + }); + } else { + this.object[keys] = newValues; + } + } + + generatePath(parentNode) { + // return parentNode.path === '/' ? parentNode.path + this.object.name : parentNode.path + '/' + this.object.name; + return this.path; + } + + serializeToJson() { + let children = []; + if (this.hasChildren) { + children = this.children.map(m => m.serializeToJson()); + } + + const treeNode = { + path: this.path, + object: this.object.clone(), + isLoaded: this.isLoaded, + isPreload: this.isPreload, + isExpanded: this.isExpanded, + parentNode: this.parentNode, + children: children, + }; + + return treeNode; + } + + static deserializefromJson(json) { + let { path, object, isLoaded, isPreload, isExpanded, parentNode, children = [] } = json; + object = object.clone(); + + const treeNode = new TreeNode({ + path, + object, + isLoaded, + isPreload, + isExpanded, + parentNode, + children: children.map(item => TreeNode.deserializefromJson(item)) + }); + return treeNode; + } +} + +export default TreeNode; diff --git a/frontend/src/components/shared-dir-tree-view/tree-view.js b/frontend/src/components/shared-dir-tree-view/tree-view.js new file mode 100644 index 0000000000..29c9f42070 --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/tree-view.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import TreeNodeView from './tree-node-view'; + +const propTypes = { + treeData: PropTypes.object.isRequired, + currentPath: PropTypes.string.isRequired, + onNodeClick: PropTypes.func.isRequired, + onNodeExpanded: PropTypes.func.isRequired, + onNodeCollapse: PropTypes.func.isRequired, +}; + +const LEFT_INDENT = 20; + +class TreeView extends React.Component { + + /* + onNodeClick = (node) => { + this.props.onNodeClick(node); + }; + */ + + render() { + return ( +
+ +
+ ); + } +} + +TreeView.propTypes = propTypes; + +export default TreeView; diff --git a/frontend/src/components/shared-dir-tree-view/tree.js b/frontend/src/components/shared-dir-tree-view/tree.js new file mode 100644 index 0000000000..f6c1ddc8e8 --- /dev/null +++ b/frontend/src/components/shared-dir-tree-view/tree.js @@ -0,0 +1,139 @@ +import TreeNode from './tree-node'; + +class Tree { + + constructor() { + this.root = null; + } + + clone() { + let tree = new Tree(); + if (this.root) { + tree.root = this.root.clone(); + } + return tree; + } + + setRoot(node) { + this.root = node; + } + + getNodeByPath(path) { + let findNode = null; + function callback(currentNode) { + if (currentNode.path === path) { + findNode = currentNode; + return true; + } + return false; + } + this.traverseDF(callback); + return findNode; + } + + getNodeChildrenObject(node) { + let objects = node.children.map(item => { + let object = item.object; + return object; + }); + return objects; + } + + addNodeToParent(node, parentNode) { + parentNode.addChild(node); + } + + addNodeListToParent(nodeList, parentNode) { + nodeList.forEach(node => { + parentNode.addChild(node); + }); + } + + deleteNode(node) { + let parentNode = this.getNodeByPath(node.parentNode.path); + parentNode.deleteChild(node); + } + + deleteNodeList(nodeList) { + nodeList.forEach(node => { + this.deleteNode(node); + }); + } + + renameNode(node, newName) { + node.rename(newName); + } + + updateNode(node, keys, newValues) { + node.updateObjectParam(keys, newValues); + } + + moveNode(node, destNode) { + this.deleteNode(node); + destNode.addChild(node); + } + + copyNode(node, destNode) { + destNode.addChild(node); + } + + traverseDF(callback) { + let stack = []; + let found = false; + stack.unshift(this.root); + let currentNode = stack.shift(); + while (!found && currentNode) { + found = callback(currentNode) == true ? true : false; + if (!found) { + stack.unshift(...currentNode.children); + currentNode = stack.shift(); + } + } + } + + traverseBF(callback) { + let queue = []; + let found = false; + queue.push(this.root); + let currentNode = queue.shift(); + while (!found && currentNode) { + found = callback(currentNode) === true ? true : false; + if (!found) { + queue.push(...currentNode.children); + currentNode = queue.shift(); + } + } + } + + expandNode(node) { + node.isExpanded = true; + while (node.parentNode) { + node.parentNode.isExpanded = true; + node = node.parentNode; + } + } + + collapseNode(node) { + node.isExpanded = false; + } + + isNodeChild(node, parentNode) { + return parentNode.children.some(item => { + return item.path === node.path; + }); + } + + serializeToJson() { + return this.root.serializeToJson(); + } + + deserializefromJson(json) { + let root = TreeNode.deserializefromJson(json); + let tree = new Tree(); + tree.setRoot(root); + return tree; + } + +} + +export default Tree; diff --git a/frontend/src/components/sort-menu.js b/frontend/src/components/sort-menu.js index 4817fa2867..9eb126953f 100644 --- a/frontend/src/components/sort-menu.js +++ b/frontend/src/components/sort-menu.js @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import { Dropdown, DropdownMenu, DropdownToggle, DropdownItem } from 'reactstrap'; import { gettext } from '../utils/constants'; +import '../css/item-dropdown-menu.css'; + const propTypes = { sortBy: PropTypes.string, sortOrder: PropTypes.string, diff --git a/frontend/src/css/shared-dir-view.css b/frontend/src/css/shared-dir-view.css index df86123dc1..5b0cbb9fc7 100644 --- a/frontend/src/css/shared-dir-view.css +++ b/frontend/src/css/shared-dir-view.css @@ -6,60 +6,131 @@ body { height: 100%; } -.top-header { - background-color: #f8fafd; +#shared-dir-view .side-panel { + border-right: 1px solid #eee; +} + +#shared-dir-view .meta-info { border-bottom: 1px solid #eee; - padding: 8px 16px 4px; - height: 53px; +} + +/* tree view */ +.tree-node-hight-light { + border-radius: 4px; + background-color: #f5f5f5 !important; +} + +.tree-node-hight-light::before { + content: ''; + position: absolute; + display: block; + width: 4px; + height: 24px; + left: -8px; + top: 2px; + background-color: #ff9800; + border-radius: 2px; + z-index: 0; +} + +.tree-node-inner { + position: relative; + height: 28px; + padding: 2px; + cursor: pointer; + line-height: 1.625; +} + +.tree-node-inner-hover { + background-color: #f0f0f0; + border-radius: 0.25rem; +} + +.tree-node-inner .tree-node-text { + padding-left: 2.8rem; + width: calc(100% - 1.5rem); + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 24px; +} + +.tree-node-inner .left-icon { + position: absolute; + display: flex; + align-items: center; + top: 1px; + left: 0; + padding-left: 1.5rem; +} + +.folder-toggle-icon { + position: absolute; + left: 0; + color: #666666; + line-height: 1.625; + width: 1.5rem; + display: flex; + align-content: center; + justify-content: center; +} + +.tree-node-icon { + display: inline-block; + width: 1rem; + text-align: center; + color: #666666; +} + +/* toolbar */ +#shared-dir-view .path-container .path-item { + display: inline-block; + min-width: 0; + padding: 0 6px; + font-size: 1rem; + color: inherit; + border-radius: 3px; + text-decoration: none; +} + +#shared-dir-view .path-container .path-item:hover { + background: #efefef; +} + +#shared-dir-view .path-container .path-split { + padding: 0 2px; flex-shrink: 0; } -.shared-dir-view-main { - width: calc(100% - 40px); - max-width: 950px; - padding: 15px 0 40px; - margin: 0 auto; +#shared-dir-view .path-item-dropdown-toggle .sf3-font { + color: #666; } -.op-bar { - padding: 9px 10px; - background: #f2f2f2; - border-radius: 2px; +#shared-dir-view .path-item-dropdown-toggle .main-icon { + margin-right: 2px; } -.sf-view-mode-btn { - padding: 0; - height: 30px; - min-width: 2rem; - color: #aaa; - background-color: #fff; - border: 1px solid #ccc; - line-height: 29px; - font-size: 18px; - border-radius: 2px; +@keyframes displaySelectedToolbar { + from { + top: 24px; + } + to { + top: 0; + } } -.sf-view-mode-btn.current-mode { - background-color: #ccc; - color: #fff; - box-shadow: none; -} - -.shared-dir-op-btn { - height: 30px; - line-height: 30px; - padding: 0 10px; -} - -.shared-dir-upload-btn { - border: 1px solid #ccc; +.cur-view-path .selected-items-toolbar { + height: 24px; + position: relative; + animation: displaySelectedToolbar .3s ease-in-out 1; } .grid-item .action-icon { position: absolute; top: 10px; right: 24px; - padding: 3px 5px; + padding: 5px; background: #fff; border: 1px solid #eee; border-radius: 3px; diff --git a/frontend/src/shared-dir-view.js b/frontend/src/shared-dir-view.js index 5328ec4e93..28764a7f40 100644 --- a/frontend/src/shared-dir-view.js +++ b/frontend/src/shared-dir-view.js @@ -2,7 +2,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import MD5 from 'MD5'; import { createRoot } from 'react-dom/client'; -import { Button, Dropdown, DropdownToggle, DropdownItem, UncontrolledTooltip } from 'reactstrap'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem, UncontrolledTooltip } from 'reactstrap'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import Account from './components/common/account'; @@ -20,9 +20,18 @@ import SaveSharedDirDialog from './components/dialog/save-shared-dir-dialog'; import CopyMoveDirentProgressDialog from './components/dialog/copy-move-dirent-progress-dialog'; import RepoInfoBar from './components/repo-info-bar'; import RepoTag from './models/repo-tag'; -import { GRID_MODE, LIST_MODE } from './components/dir-view-mode/constants'; +import { LIST_MODE } from './components/dir-view-mode/constants'; import { MetadataAIOperationsProvider } from './hooks/metadata-ai-operation'; +import ViewModes from './components/view-modes'; +import SortMenu from './components/sort-menu'; +import { TreeHelper, TreeNode, TreeView } from './components/shared-dir-tree-view'; +import ResizeBar from './components/resize-bar'; +import { + DRAG_HANDLER_HEIGHT, INIT_SIDE_PANEL_RATE, MAX_SIDE_PANEL_RATE, MIN_SIDE_PANEL_RATE +} from './components/resize-bar/constants'; +import './css/layout.css'; +import './css/header.css'; import './css/shared-dir-view.css'; import './css/grid-view.css'; @@ -33,7 +42,7 @@ let loginUser = window.app.pageOptions.name; let { token, dirName, dirPath, sharedBy, repoID, relativePath, - mode, thumbnailSize, zipped, + mode, thumbnailSize, trafficOverLimit, canDownload, noQuota, canUpload, enableVideoThumbnail, enablePDFThumbnail } = window.shared.pageOptions; @@ -45,10 +54,19 @@ class SharedDirView extends React.Component { constructor(props) { super(props); this.state = { + isTreeDataLoading: true, + treeData: TreeHelper.buildTree(), + + // for resizing side/main panels + inResizing: false, + sidePanelRate: parseFloat(localStorage.getItem('sf_side_panel_rate') || INIT_SIDE_PANEL_RATE), + isLoading: true, errorMsg: '', items: [], + path: relativePath, + isDropdownMenuOpen: false, currentMode: mode, isAllItemsSelected: false, @@ -61,7 +79,6 @@ class SharedDirView extends React.Component { zipFolderPath: '', usedRepoTags: [], - isRepoInfoBarShow: false, isSaveSharedDirDialogShow: false, itemsForSave: [], @@ -75,6 +92,9 @@ class SharedDirView extends React.Component { imageItems: [], imageIndex: 0 }; + + this.resizeBarRef = React.createRef(); + this.dragHandlerRef = React.createRef(); } componentDidMount() { @@ -84,12 +104,54 @@ class SharedDirView extends React.Component { }); } - this.listItems(thumbnailSize); + this.loadTreePanel(); + this.listItems(); this.getShareLinkRepoTags(); } - listItems = (thumbnailSize) => { - seafileAPI.listSharedDir(token, relativePath, thumbnailSize).then((res) => { + loadTreePanel = () => { + seafileAPI.listSharedDir(token, '/', thumbnailSize).then((res) => { + const { dirent_list } = res.data; + let tree = this.state.treeData; + this.addResponseListToNode(dirent_list, tree.root); + this.setState({ + isTreeDataLoading: false, + treeData: tree + }); + }).catch(() => { + this.setState({ isTreeDataLoading: false }); + }); + /* keep it for now + if (relativePath == '/') { + } else { + this.loadNodeAndParentsByPath(relativePath); + } + */ + }; + + /* + loadNodeAndParentsByPath = (path) => { + }; + */ + + addResponseListToNode = (list, node) => { + node.isLoaded = true; + node.isExpanded = true; + + // only display folders in the tree + const dirList = list.filter(item => item.is_dir); + const direntList = Utils.sortDirentsInSharedDir(dirList, this.state.sortBy, this.state.sortOrder); + + const nodeList = direntList.map(object => { + return new TreeNode({ object }); + }); + node.addChildren(nodeList); + }; + + listItems = () => { + const { path, currentMode } = this.state; + const thumbnailSize = currentMode == LIST_MODE ? thumbnailDefaultSize : thumbnailSizeForGrid; + seafileAPI.listSharedDir(token, path, thumbnailSize).then((res) => { const items = res.data['dirent_list'].map(item => { item.isSelected = false; return item; @@ -107,6 +169,21 @@ class SharedDirView extends React.Component { errorMsg: errorMsg }); }); + + // update the URL + let normalizedPath = ''; + if (path == '/') { + normalizedPath = path; + } else { + normalizedPath = path[path.length - 1] === '/' ? path.slice(0, path.length - 1) : path; + } + let url = new URL(location.href); + let searchParams = new URLSearchParams(url.search); + searchParams.set('p', normalizedPath); + searchParams.set('mode', currentMode); + url.search = searchParams.toString(); + url = url.toString(); + window.history.pushState({ url: url, path: path }, path, url); }; sortItems = (sortBy, sortOrder) => { @@ -151,22 +228,136 @@ class SharedDirView extends React.Component { getThumbnail(0); }; + toggleDropdownMenu = () => { + this.setState({ + isDropdownMenuOpen: !this.state.isDropdownMenuOpen + }); + }; + + onDropdownToggleKeyDown = (e) => { + if (e.key == 'Enter' || e.key == 'Space') { + this.toggleDropdownMenu(); + } + }; + + onMenuItemKeyDown = (item, e) => { + if (e.key == 'Enter' || e.key == 'Space') { + item.onClick(); + } + }; + + visitFolder = (folderPath) => { + this.setState({ + path: folderPath + }, () => { + this.listItems(); + }); + }; + renderPath = () => { + let opList = []; + if (showDownloadIcon) { + opList.push({ + 'icon': 'download1', + 'text': gettext('ZIP'), + 'onClick': this.zipDownloadFolder.bind(this, this.state.path) + }); + + if (canDownload && loginUser && (loginUser !== sharedBy)) { + opList.push({ + 'icon': 'save', + 'text': gettext('Save'), + 'onClick': this.saveAllItems + }); + } + } + + if (canUpload) { + opList.push({ + 'icon': 'upload-files', + 'disabled': noQuota, + 'title': noQuota ? gettext('The owner of this library has run out of space.') : '', + 'text': gettext('Upload'), + 'onClick': this.onUploadFile + }); + } + + const zipped = []; // be compatible with the old code + const rootItem = { + path: '/', + name: dirName + }; + zipped.push(rootItem); + const { path } = this.state; + if (path != '/') { + const normalizedPath = path[path.length - 1] === '/' ? path.slice(0, path.length - 1) : path; + const pathList = normalizedPath.split('/'); + pathList.shift(); + let itemPath = ''; + const subItems = pathList.map((item, index) => { + itemPath += '/' + item; + return { + path: itemPath + '/', // the ending '/' is necessary + name: item + }; + }); + zipped.push(...subItems); + } + return ( {zipped.map((item, index) => { if (index != zipped.length - 1) { return ( - {item.name} - / + {item.name} + / ); } return null; - }) + })} + {(!showDownloadIcon && !canUpload) + ? {zipped[zipped.length - 1].name} + : ( + + + {zipped[zipped.length - 1].name} + {canUpload + ? <> + : + } + + + {opList.map((item, index) => { + if (item == 'Divider') { + return ; + } else { + return ( + + + {item.text} + + ); + } + })} + + + ) } - {zipped[zipped.length - 1].name} ); }; @@ -193,17 +384,18 @@ class SharedDirView extends React.Component { }; zipDownloadSelectedItems = () => { + const { path } = this.state; if (!useGoFileserver) { this.setState({ isZipDialogOpen: true, - zipFolderPath: relativePath, + zipFolderPath: path, selectedItems: this.state.items.filter(item => item.isSelected) .map(item => item.file_name || item.folder_name) }); } else { let target = this.state.items.filter(item => item.isSelected).map(item => item.file_name || item.folder_name); - seafileAPI.getShareLinkDirentsZipTask(token, relativePath, target).then((res) => { + seafileAPI.getShareLinkDirentsZipTask(token, path, target).then((res) => { const zipToken = res.data['zip_token']; location.href = `${fileServerRoot}zip/${zipToken}`; }).catch((error) => { @@ -277,10 +469,8 @@ class SharedDirView extends React.Component { }; handleSaveSharedDir = (destRepoID, dstPath) => { - - const itemsForSave = this.state.itemsForSave; - - seafileAPI.saveSharedDir(destRepoID, dstPath, token, relativePath, itemsForSave).then((res) => { + const { path, itemsForSave } = this.state; + seafileAPI.saveSharedDir(destRepoID, dstPath, token, path, itemsForSave).then((res) => { this.setState({ isSaveSharedDirDialogShow: false, itemsForSave: [], @@ -376,6 +566,16 @@ class SharedDirView extends React.Component { })); }; + unselectItems = () => { + this.setState({ + isAllItemsSelected: false, + items: this.state.items.map((item) => { + item.isSelected = false; + return item; + }) + }); + }; + toggleAllSelected = () => { this.setState((prevState) => ({ isAllItemsSelected: !prevState.isAllItemsSelected, @@ -407,11 +607,12 @@ class SharedDirView extends React.Component { }; onFileUploadSuccess = (direntObject) => { + const { path } = this.state; const { name, size } = direntObject; const newItem = { isSelected: false, file_name: name, - file_path: Utils.joinPath(relativePath, name), + file_path: Utils.joinPath(path, name), is_dir: false, last_modified: dayjs().format(), size: size @@ -434,9 +635,6 @@ class SharedDirView extends React.Component { } }); this.setState({ usedRepoTags: usedRepoTags }); - if (Utils.isDesktop() && usedRepoTags.length != 0 && relativePath == '/') { - this.setState({ isRepoInfoBarShow: true }); - } }).catch(error => { let errMessage = Utils.getErrorMsg(error); toaster.danger(errMessage); @@ -452,76 +650,203 @@ class SharedDirView extends React.Component { currentMode: mode, isLoading: true }, () => { - const thumbnailSize = mode == LIST_MODE ? thumbnailDefaultSize : thumbnailSizeForGrid; - this.listItems(thumbnailSize); + this.listItems(); }); } }; + onSelectSortOption = (item) => { + const [sortBy, sortOrder] = item.value.split('-'); + this.sortItems(sortBy, sortOrder); + }; + + onTreeNodeCollapse = (node) => { + const tree = TreeHelper.collapseNode(this.state.treeData, node); + this.setState({ treeData: tree }); + }; + + onTreeNodeExpanded = (node) => { + let tree = this.state.treeData.clone(); + node = tree.getNodeByPath(node.path); + if (!node.isLoaded) { + seafileAPI.listSharedDir(token, node.path, thumbnailSize).then((res) => { + const { dirent_list } = res.data; + this.addResponseListToNode(dirent_list, node); + this.setState({ treeData: tree }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } else { + tree.expandNode(node); + this.setState({ treeData: tree }); + } + }; + + onTreeNodeClick = (node) => { + if (node.object.is_dir) { + if (node.isLoaded && node.path === this.state.path) { + if (node.isExpanded) { + let tree = TreeHelper.collapseNode(this.state.treeData, node); + this.setState({ treeData: tree }); + } else { + let tree = this.state.treeData.clone(); + node = tree.getNodeByPath(node.path); + tree.expandNode(node); + this.setState({ treeData: tree }); + } + } + + if (!node.isLoaded) { + let tree = this.state.treeData.clone(); + node = tree.getNodeByPath(node.path); + seafileAPI.listSharedDir(token, node.path, thumbnailSize).then((res) => { + const { dirent_list } = res.data; + this.addResponseListToNode(dirent_list, node); + tree.collapseNode(node); + this.setState({ treeData: tree }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + if (node.path === this.state.path) { + return; + } + this.visitFolder(node.path); + } + }; + + onResizeMouseUp = () => { + if (this.state.inResizing) { + this.setState({ + inResizing: false + }); + } + localStorage.setItem('sf_side_panel_rate', this.state.sidePanelRate); + }; + + onResizeMouseDown = () => { + this.setState({ + inResizing: true + }); + }; + + onResizeMouseMove = (e) => { + let rate = e.nativeEvent.clientX / window.innerWidth; + this.setState({ + sidePanelRate: Math.max(Math.min(rate, MAX_SIDE_PANEL_RATE), MIN_SIDE_PANEL_RATE), + }); + }; + + onResizeMouseOver = (event) => { + if (!this.dragHandlerRef.current) return; + const { top } = this.resizeBarRef.current.getBoundingClientRect(); + const dragHandlerRefTop = event.pageY - top - DRAG_HANDLER_HEIGHT / 2; + this.setDragHandlerTop(dragHandlerRefTop); + }; + + setDragHandlerTop = (top) => { + this.dragHandlerRef.current.style.top = top + 'px'; + }; + render() { - const { currentMode: mode } = this.state; + const { + usedRepoTags, currentMode: mode, + sortBy, sortOrder, isTreeDataLoading, treeData, path, + sidePanelRate, inResizing + } = this.state; + + const mainPanelStyle = { + userSelect: inResizing ? 'none' : '', + flex: sidePanelRate ? `1 0 ${(1 - sidePanelRate) * 100}%` : `0 0 ${100 - INIT_SIDE_PANEL_RATE * 100}%`, + }; + const sidePanelStyle = { + userSelect: inResizing ? 'none' : '', + flex: sidePanelRate ? `0 0 ${sidePanelRate * 100}%` : `0 0 ${INIT_SIDE_PANEL_RATE * 100}%`, + }; + const isDesktop = Utils.isDesktop(); - const modeBaseClass = 'btn btn-secondary btn-icon sf-view-mode-btn'; + const selectedItemsLength = this.state.items.filter(item => item.isSelected).length; + const isRepoInfoBarShown = isDesktop && path == '/' && usedRepoTags.length != 0; + return ( -
-
+
+
logo {loginUser && }
-
-
-

{dirName}

-

{gettext('Shared by: ')}{sharedBy}

-
-

{gettext('Current path: ')}{this.renderPath()}

-
- {isDesktop && -
- - -
+
+
+
+

{dirName}

+

{gettext('Shared by: ')}{sharedBy}

+
+
+ {isTreeDataLoading ? : ( + + )} +
+
+ {isDesktop && + + } +
+
+
+ {(showDownloadIcon && this.state.items.some(item => item.isSelected)) + ? ( +
+ + + {`${selectedItemsLength} ${gettext('selected')}`} + + + + + {(canDownload && loginUser && (loginUser !== sharedBy)) && + + + + } +
+ ) + : ( +
+ {gettext('Current path: ')} + {this.renderPath()} +
+ ) } - {canUpload && ( - +
+
+ {isDesktop && ( + <> + + + )} - {showDownloadIcon && - - {this.state.items.some(item => item.isSelected) ? - - - {(canDownload && loginUser && (loginUser !== sharedBy)) && - - } - - : - - - {(canDownload && loginUser && (loginUser !== sharedBy)) && - - } - - } - - }
{!noQuota && canUpload && ( @@ -530,38 +855,40 @@ class SharedDirView extends React.Component { dragAndDrop={false} token={token} path={dirPath === '/' ? dirPath : dirPath.replace(/\/+$/, '')} - relativePath={relativePath === '/' ? relativePath : relativePath.replace(/\/+$/, '')} + relativePath={path === '/' ? path : path.replace(/\/+$/, '')} repoID={repoID} onFileUploadSuccess={this.onFileUploadSuccess} /> )} - {this.state.isRepoInfoBarShow && ( - - )} +
+ {isRepoInfoBarShown && ( + + )} - + +
@@ -578,7 +905,7 @@ class SharedDirView extends React.Component { {this.state.isSaveSharedDirDialogShow && ; return mode == LIST_MODE ? ( - - - - {showDownloadIcon && - - } - - - - - - - - - {tbody} -
- - {gettext('Name')} {sortBy == 'name' && sortIcon}{gettext('Size')} {sortBy == 'size' && sortIcon}{gettext('Last Update')} {sortBy == 'time' && sortIcon}
+ ) : (
    {items.map((item, index) => { @@ -710,6 +1040,7 @@ class Content extends React.Component { key={index} mode={mode} item={item} + visitFolder={this.props.visitFolder} zipDownloadFolder={this.props.zipDownloadFolder} showImagePopup={this.props.showImagePopup} />; @@ -733,6 +1064,7 @@ Content.propTypes = { toggleItemSelected: PropTypes.func, zipDownloadFolder: PropTypes.func, showImagePopup: PropTypes.func, + visitFolder: PropTypes.func.isRequired }; class Item extends React.Component { @@ -776,6 +1108,13 @@ class Item extends React.Component { this.props.toggleItemSelected(this.props.item, e.target.checked); }; + onFolderItemClick = (e) => { + e.preventDefault(); + const { item } = this.props; + const { folder_path } = item; + this.props.visitFolder(folder_path); + }; + render() { const { item, isDesktop, mode } = this.props; const { isIconShown } = this.state; @@ -797,14 +1136,14 @@ class Item extends React.Component { } - {item.folder_name} + {item.folder_name} {dayjs(item.last_modified).fromNow()} {showDownloadIcon && - + } @@ -813,7 +1152,7 @@ class Item extends React.Component { - {item.folder_name} + {item.folder_name}
    {dayjs(item.last_modified).fromNow()} @@ -879,7 +1218,7 @@ class Item extends React.Component { {dayjs(item.last_modified).fromNow()} {showDownloadIcon && - + } @@ -934,6 +1273,7 @@ Item.propTypes = { toggleItemSelected: PropTypes.func, zipDownloadFolder: PropTypes.func, showImagePopup: PropTypes.func, + visitFolder: PropTypes.func.isRequired }; class GridItem extends React.Component { @@ -968,6 +1308,13 @@ class GridItem extends React.Component { this.props.showImagePopup(item); }; + onFolderItemClick = (e) => { + e.preventDefault(); + const { item } = this.props; + const { folder_path } = item; + this.props.visitFolder(folder_path); + }; + render() { const { item, mode } = this.props; const { isIconShown } = this.state; @@ -976,12 +1323,12 @@ class GridItem extends React.Component { const folderURL = `?p=${encodeURIComponent(item.folder_path.substr(0, item.folder_path.length - 1))}&mode=${mode}`; return (
  • - + - {item.folder_name} + {item.folder_name} {showDownloadIcon && - + }
  • @@ -999,7 +1346,7 @@ class GridItem extends React.Component { {item.file_name} {showDownloadIcon && - + } @@ -1013,6 +1360,7 @@ GridItem.propTypes = { item: PropTypes.object, zipDownloadFolder: PropTypes.func, showImagePopup: PropTypes.func, + visitFolder: PropTypes.func.isRequired }; const root = createRoot(document.getElementById('wrapper')); diff --git a/seahub/templates/view_shared_dir_react.html b/seahub/templates/view_shared_dir_react.html index 8df4fac53c..075344e797 100644 --- a/seahub/templates/view_shared_dir_react.html +++ b/seahub/templates/view_shared_dir_react.html @@ -24,16 +24,6 @@ sharedBy: '{{ username|email2nickname|escapejs }}', repoID: '{{repo.id}}', relativePath: '{{ path|escapejs }}', - zipped: (function() { - var list = []; - {% for name, path in zipped %} - list.push({ - 'name': '{{ name|escapejs }}', - 'path': '{{ path|escapejs }}' - }); - {% endfor %} - return list; - })(), token: '{{ token }}', mode: '{{ mode }}', thumbnailSize: {{ thumbnail_size }}, diff --git a/seahub/views/repo.py b/seahub/views/repo.py index ffd05811b1..4efc142003 100644 --- a/seahub/views/repo.py +++ b/seahub/views/repo.py @@ -296,16 +296,6 @@ def view_shared_dir(request, fileshare): else: dir_name = os.path.basename(real_path[:-1]) - current_commit = seaserv.get_commits(repo_id, 0, 1)[0] - file_list, dir_list, dirent_more = get_repo_dirents(request, repo, - current_commit, real_path) - - # generate dir navigator - if fileshare.path == '/': - zipped = gen_path_link(req_path, repo.name) - else: - zipped = gen_path_link(req_path, os.path.basename(fileshare.path[:-1])) - if req_path == '/': # When user view the root of shared dir.. # increase shared link view_cnt, fileshare = FileShare.objects.get(token=token) @@ -320,20 +310,6 @@ def view_shared_dir(request, fileshare): mode = 'grid' thumbnail_size = THUMBNAIL_DEFAULT_SIZE if mode == 'list' else THUMBNAIL_SIZE_FOR_GRID - for f in file_list: - file_type, file_ext = get_file_type_and_ext(f.obj_name) - if file_type == IMAGE: - f.is_img = True - if file_type == VIDEO: - f.is_video = True - - if file_type in (IMAGE, XMIND) or \ - (file_type == VIDEO and ENABLE_VIDEO_THUMBNAIL): - if os.path.exists(os.path.join(THUMBNAIL_ROOT, str(thumbnail_size), f.obj_id)): - req_image_path = posixpath.join(req_path, f.obj_name) - src = get_share_link_thumbnail_src(token, thumbnail_size, req_image_path) - f.encoded_thumbnail_src = quote(src) - # for 'upload file' no_quota = True if seaserv.check_quota(repo_id) < 0 else False @@ -355,9 +331,6 @@ def view_shared_dir(request, fileshare): 'username': username, 'dir_name': dir_name, 'dir_path': real_path, - 'file_list': file_list, - 'dir_list': dir_list, - 'zipped': zipped, 'traffic_over_limit': False, 'max_upload_file_size': max_upload_file_size, 'no_quota': no_quota,