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 (
-
-
+
+
{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 ? (
-
+
) : (
{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,