diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b324b7285..4be8a6df48 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,8 @@ "react-app-polyfill": "^2.0.0", "react-chartjs-2": "^2.8.0", "react-cookies": "^0.1.0", + "react-dnd": "^2.6.0", + "react-dnd-html5-backend": "^2.6.0", "react-dom": "17.0.0", "react-i18next": "^10.12.2", "react-responsive": "9.0.2", @@ -9888,12 +9890,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/disposables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.2.tgz", + "integrity": "sha512-q1XTvs/XGdfubRSemB2+QRhJjIX4PerKkSom+i8Nkw3hCv6xISNrgaN442n2BunyBI4x77Om4ZAzSlqmhM9pwA==" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "node_modules/dnd-core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.6.0.tgz", + "integrity": "sha512-5BfQHIp0XVd4ioF0q4GyUeHQQNCbqP+0SnUiP9TssoQ50wrP1NgSzDqZkjD5pFngsVz9txGin6rvTQD7w0qC3w==", + "dependencies": { + "asap": "^2.0.6", + "invariant": "^2.0.0", + "lodash": "^4.2.0", + "redux": "^3.7.1" + } + }, "node_modules/dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -21972,6 +21990,35 @@ "node": ">=8" } }, + "node_modules/react-dnd": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.6.0.tgz", + "integrity": "sha512-2KHNpeg2SyaxXYq+xO1TM+tOtN9hViI41otJuiYiu6DRYGw+WMvDFDMP4aw7zIKRRm1xd0gizXuKWhb8iJYHBw==", + "dependencies": { + "disposables": "^1.0.1", + "dnd-core": "^2.6.0", + "hoist-non-react-statics": "^2.1.0", + "invariant": "^2.1.0", + "lodash": "^4.2.0", + "prop-types": "^15.5.10" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz", + "integrity": "sha512-8gOfBfqFikWmXvAGSZz1mgoctwkcsKdUC9POt/WGnMoZwGB4ivB0Ex5D6pwHTNjvAs0ixqqWdJKy57CzjDg5Sg==", + "dependencies": { + "lodash": "^4.2.0" + } + }, + "node_modules/react-dnd/node_modules/hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + }, "node_modules/react-dom": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.0.tgz", @@ -22236,6 +22283,17 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", + "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", + "dependencies": { + "lodash": "^4.2.1", + "lodash-es": "^4.2.1", + "loose-envify": "^1.1.0", + "symbol-observable": "^1.0.3" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -25485,6 +25543,14 @@ "boolbase": "~1.0.0" } }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -35327,12 +35393,28 @@ "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==" }, + "disposables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.2.tgz", + "integrity": "sha512-q1XTvs/XGdfubRSemB2+QRhJjIX4PerKkSom+i8Nkw3hCv6xISNrgaN442n2BunyBI4x77Om4ZAzSlqmhM9pwA==" + }, "dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "dnd-core": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.6.0.tgz", + "integrity": "sha512-5BfQHIp0XVd4ioF0q4GyUeHQQNCbqP+0SnUiP9TssoQ50wrP1NgSzDqZkjD5pFngsVz9txGin6rvTQD7w0qC3w==", + "requires": { + "asap": "^2.0.6", + "invariant": "^2.0.0", + "lodash": "^4.2.0", + "redux": "^3.7.1" + } + }, "dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", @@ -44192,6 +44274,34 @@ } } }, + "react-dnd": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.6.0.tgz", + "integrity": "sha512-2KHNpeg2SyaxXYq+xO1TM+tOtN9hViI41otJuiYiu6DRYGw+WMvDFDMP4aw7zIKRRm1xd0gizXuKWhb8iJYHBw==", + "requires": { + "disposables": "^1.0.1", + "dnd-core": "^2.6.0", + "hoist-non-react-statics": "^2.1.0", + "invariant": "^2.1.0", + "lodash": "^4.2.0", + "prop-types": "^15.5.10" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" + } + } + }, + "react-dnd-html5-backend": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.6.0.tgz", + "integrity": "sha512-8gOfBfqFikWmXvAGSZz1mgoctwkcsKdUC9POt/WGnMoZwGB4ivB0Ex5D6pwHTNjvAs0ixqqWdJKy57CzjDg5Sg==", + "requires": { + "lodash": "^4.2.0" + } + }, "react-dom": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.0.tgz", @@ -44403,6 +44513,17 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", + "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", + "requires": { + "lodash": "^4.2.1", + "lodash-es": "^4.2.1", + "loose-envify": "^1.1.0", + "symbol-observable": "^1.0.3" + } + }, "reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -46931,6 +47052,11 @@ } } }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9d91c872da..aa874ffd5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,8 @@ "react-app-polyfill": "^2.0.0", "react-chartjs-2": "^2.8.0", "react-cookies": "^0.1.0", + "react-dnd": "^2.6.0", + "react-dnd-html5-backend": "^2.6.0", "react-dom": "17.0.0", "react-i18next": "^10.12.2", "react-responsive": "9.0.2", diff --git a/frontend/src/assets/icons/copy.svg b/frontend/src/assets/icons/copy.svg new file mode 100644 index 0000000000..1ce0b4ea1a --- /dev/null +++ b/frontend/src/assets/icons/copy.svg @@ -0,0 +1,17 @@ + + + + +copy +Created with Sketch. + + + + + + + diff --git a/frontend/src/assets/icons/delete.svg b/frontend/src/assets/icons/delete.svg new file mode 100644 index 0000000000..2244eeb2ae --- /dev/null +++ b/frontend/src/assets/icons/delete.svg @@ -0,0 +1,18 @@ + + + + +delete + + + + + + diff --git a/frontend/src/assets/icons/drag.svg b/frontend/src/assets/icons/drag.svg new file mode 100644 index 0000000000..e3e6c7be96 --- /dev/null +++ b/frontend/src/assets/icons/drag.svg @@ -0,0 +1,15 @@ + + + + +drag +Created with Sketch. + + + + + + diff --git a/frontend/src/assets/icons/drop-down.svg b/frontend/src/assets/icons/drop-down.svg new file mode 100644 index 0000000000..7222fa9f16 --- /dev/null +++ b/frontend/src/assets/icons/drop-down.svg @@ -0,0 +1,15 @@ + + + + +drop-down + + + + + + diff --git a/frontend/src/assets/icons/edit.svg b/frontend/src/assets/icons/edit.svg new file mode 100644 index 0000000000..352aa56d5c --- /dev/null +++ b/frontend/src/assets/icons/edit.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/assets/icons/file.svg b/frontend/src/assets/icons/file.svg new file mode 100644 index 0000000000..447119c1fc --- /dev/null +++ b/frontend/src/assets/icons/file.svg @@ -0,0 +1,19 @@ + + + + +file1 +Created with Sketch. + + + + + + + + diff --git a/frontend/src/assets/icons/folders.svg b/frontend/src/assets/icons/folders.svg new file mode 100644 index 0000000000..38ce0749ae --- /dev/null +++ b/frontend/src/assets/icons/folders.svg @@ -0,0 +1,16 @@ + + + + +folders + + + + + + diff --git a/frontend/src/assets/icons/left-slide.svg b/frontend/src/assets/icons/left-slide.svg new file mode 100644 index 0000000000..e2e99eebf7 --- /dev/null +++ b/frontend/src/assets/icons/left-slide.svg @@ -0,0 +1,16 @@ + + + + +left-slide + + + + + + + diff --git a/frontend/src/assets/icons/main-view.svg b/frontend/src/assets/icons/main-view.svg new file mode 100644 index 0000000000..3716845987 --- /dev/null +++ b/frontend/src/assets/icons/main-view.svg @@ -0,0 +1,23 @@ + + + + +main-view +Created with Sketch. + + + + + + + + diff --git a/frontend/src/assets/icons/more-level.svg b/frontend/src/assets/icons/more-level.svg new file mode 100644 index 0000000000..539e9efc93 --- /dev/null +++ b/frontend/src/assets/icons/more-level.svg @@ -0,0 +1,15 @@ + + + + +more +Created with Sketch. + + + + diff --git a/frontend/src/assets/icons/more-vertical.svg b/frontend/src/assets/icons/more-vertical.svg new file mode 100644 index 0000000000..74bffcfa0b --- /dev/null +++ b/frontend/src/assets/icons/more-vertical.svg @@ -0,0 +1,15 @@ + + + + +more +Created with Sketch. + + + + diff --git a/frontend/src/assets/icons/move-to.svg b/frontend/src/assets/icons/move-to.svg new file mode 100644 index 0000000000..67ffa69066 --- /dev/null +++ b/frontend/src/assets/icons/move-to.svg @@ -0,0 +1,20 @@ + + + + +move-to + + + + + + diff --git a/frontend/src/assets/icons/remove-from-folder.svg b/frontend/src/assets/icons/remove-from-folder.svg new file mode 100644 index 0000000000..5d1747218a --- /dev/null +++ b/frontend/src/assets/icons/remove-from-folder.svg @@ -0,0 +1,17 @@ + + + + +remove-from-folder + + + + + + diff --git a/frontend/src/assets/icons/right-slide.svg b/frontend/src/assets/icons/right-slide.svg new file mode 100644 index 0000000000..452c82f40c --- /dev/null +++ b/frontend/src/assets/icons/right-slide.svg @@ -0,0 +1,16 @@ + + + + +right-slide + + + + + + + diff --git a/frontend/src/assets/icons/table.svg b/frontend/src/assets/icons/table.svg new file mode 100644 index 0000000000..f618d83302 --- /dev/null +++ b/frontend/src/assets/icons/table.svg @@ -0,0 +1,16 @@ + + + + +table + + + + + + diff --git a/frontend/src/assets/icons/upward.svg b/frontend/src/assets/icons/upward.svg new file mode 100644 index 0000000000..c6cdab3e60 --- /dev/null +++ b/frontend/src/assets/icons/upward.svg @@ -0,0 +1,16 @@ + + + + +upward + + + + + + + diff --git a/frontend/src/assets/icons/wiki-preview.svg b/frontend/src/assets/icons/wiki-preview.svg new file mode 100644 index 0000000000..5c154af2d2 --- /dev/null +++ b/frontend/src/assets/icons/wiki-preview.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/frontend/src/assets/icons/wiki-settings.svg b/frontend/src/assets/icons/wiki-settings.svg new file mode 100644 index 0000000000..1e72819a0d --- /dev/null +++ b/frontend/src/assets/icons/wiki-settings.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/src/components/file-chooser/file-chooser.js b/frontend/src/components/file-chooser/file-chooser.js index 4ff59807ca..e32fa7ea0e 100644 --- a/frontend/src/components/file-chooser/file-chooser.js +++ b/frontend/src/components/file-chooser/file-chooser.js @@ -14,6 +14,7 @@ import '../../css/file-chooser.css'; const propTypes = { isShowFile: PropTypes.bool, + hideLibraryName: PropTypes.bool, repoID: PropTypes.string, onDirentItemClick: PropTypes.func, onRepoItemClick: PropTypes.func, @@ -371,7 +372,6 @@ class FileChooser extends React.Component { }; renderRepoListView = () => { - return (
{this.props.mode === 'current_repo_and_other_repos' && ( @@ -421,10 +421,12 @@ class FileChooser extends React.Component { )} {this.props.mode === 'only_current_library' && (
+ {!this.props.hideLibraryName &&
{gettext('Current Library')}
+ } { this.state.isCurrentRepoShow && this.state.currentRepoInfo && }
diff --git a/frontend/src/components/file-chooser/repo-list-item.js b/frontend/src/components/file-chooser/repo-list-item.js index 50877f3ea5..97e78366cb 100644 --- a/frontend/src/components/file-chooser/repo-list-item.js +++ b/frontend/src/components/file-chooser/repo-list-item.js @@ -21,6 +21,7 @@ const propTypes = { onRepoItemClick: PropTypes.func.isRequired, fileSuffixes: PropTypes.array, selectedItemInfo: PropTypes.object, + hideLibraryName: PropTypes.bool, }; class RepoListItem extends React.Component { @@ -199,17 +200,19 @@ class RepoListItem extends React.Component { return (
  • -
    -
    - {this.props.repo.repo_name} + {!this.props.hideLibraryName && +
    +
    + {this.props.repo.repo_name} +
    +
    + + + + +
    -
    - - - - -
    -
    + } {this.state.isShowChildren && ( +
      {repoList.length > 0 && repoList.map((repoItem, index) => { return ( ); })} diff --git a/frontend/src/components/wiki-list-view/wiki-list-item.js b/frontend/src/components/wiki-list-view/wiki-list-item.js index cd9df55b45..a3f70a6834 100644 --- a/frontend/src/components/wiki-list-view/wiki-list-item.js +++ b/frontend/src/components/wiki-list-view/wiki-list-item.js @@ -1,4 +1,4 @@ -import React, { Component, Fragment } from 'react'; +import React, { Component } from 'react'; import { Dropdown, DropdownToggle, DropdownItem } from 'reactstrap'; import PropTypes from 'prop-types'; import moment from 'moment'; @@ -136,10 +136,14 @@ class WikiListItem extends Component { let wiki = this.props.wiki; let userProfileURL = `${siteRoot}profile/${encodeURIComponent(wiki.owner)}/`; let fileIconUrl = Utils.getDefaultLibIconUrl(false); - let deleteIcon = `action-icon sf2-icon-x3 ${this.state.highlight ? '' : 'invisible'}`; const desktopItem = ( - + {wiki.name} @@ -151,13 +155,27 @@ class WikiListItem extends Component { {wiki.owner_nickname} {moment(wiki.updated_at).fromNow()} - + window.open(wiki.link.replace('/published/', '/edit-wiki/'))} + title={gettext('Edit')} + aria-label={gettext('Edit')} + style={{color: '#999'}} + > + ); const mobileItem = ( - + {wiki.name}
      @@ -186,7 +204,7 @@ class WikiListItem extends Component { ); return ( - + <> {Utils.isDesktop() ? desktopItem : mobileItem} {this.state.isShowDeleteDialog && @@ -196,7 +214,7 @@ class WikiListItem extends Component { /> } - + ); } } diff --git a/frontend/src/pages/wiki/css/add-page-dialog.css b/frontend/src/pages/wiki/css/add-page-dialog.css new file mode 100644 index 0000000000..062e0aa4d5 --- /dev/null +++ b/frontend/src/pages/wiki/css/add-page-dialog.css @@ -0,0 +1,90 @@ +.add-page-dialog { + width: 506px; + max-width: 506px; +} + +.add-page-dialog .modal-content { + height: 100%; + max-height: inherit; +} + +.add-page-dialog .modal-content .modal-body { + height: 100%; + overflow: auto; +} + +.add-page-dialog .app-select-pages { + margin: -8px 0; +} + +.add-page-dialog .app-select-pages .app-select-page-item { + flex-direction: column; + height: auto; + width: 88px; + flex-shrink: 0; + margin: 8px 8px 8px 0px; +} + +.add-page-dialog .app-select-pages .app-select-page-item:nth-child(5n) { + margin-right: 0; +} + +.add-page-dialog .app-select-pages .app-select-page-item:hover { + cursor: pointer; +} + +.add-page-dialog .app-select-page-item .app-select-page-item-image-container { + height: 64px; + width: 100%; + border: 1px solid #e9e9e9; + border-radius: 3px; +} + +.add-page-dialog .app-select-page-item .app-select-page-item-image-container .app-select-page-item-image { + width: 86px; + height: 62px; +} + +.add-page-dialog .app-select-page-item.selected .app-select-page-item-image-container, +.add-page-dialog .app-select-page-item.selected .app-select-page-item-image-container:hover { + border-color: #ff8000; +} + +.add-page-dialog .app-select-page-item .app-select-page-item-image-container:hover { + border-color: #bdbdbd; +} + +.add-page-dialog .app-select-page-item .app-select-page-item-name { + text-align: center; + color: #666; + font-size: 13px; +} + +.add-page-dialog .app-select-page-item.selected .app-select-page-item-name { + color: #ff8000; +} + +.app-select-page-item-popover .popover { + width: 204px; +} + +.app-select-page-item-popover .popover .app-select-page-item-popover-container { + padding-left: 13px; + padding-right: 13px; +} + +.app-select-page-item-popover .popover .app-select-page-item-popover-name { + font-size: 14px; + font-weight: 500; +} + +.app-select-page-item-popover .popover .app-select-page-item-popover-tip { + font-size: 12px; + margin-top: 5px; +} + +.app-select-page-item-popover .popover .app-select-page-item-popover-image-container { + border: 2px solid #ff8000; + border-radius: 3px; + overflow: hidden; +} diff --git a/frontend/src/pages/wiki/css/view-edit-popover.css b/frontend/src/pages/wiki/css/view-edit-popover.css new file mode 100644 index 0000000000..89df24146d --- /dev/null +++ b/frontend/src/pages/wiki/css/view-edit-popover.css @@ -0,0 +1,76 @@ +.view-edit-popover .popover { + max-width: 460px; + width: 460px; + left: 140px !important; +} + +.view-edit-popover .view-edit-content { + padding: 8px 12px; +} + +.view-edit-popover .view-name-editor { + margin-bottom: 7px; +} + +.view-edit-popover .view-name-editor-input { + height: 36px; +} + +.view-edit-popover .view-icon-editor { + display: flex; + flex-wrap: wrap; + margin-left: 1px; +} + +.view-edit-popover .view-icon-item-editor { + overflow: hidden; + height: 36px; + width: 36px; + border-radius: 6px; +} + +.view-edit-popover .view-icon-item-editor .svg-item { + width: 1em; + height: 1em; + font-size: 16px; + color: #999; +} + +.view-edit-popover .view-icon-item-editor .view-icon-color-white { + color: #ffffff !important; +} + +.view-edit-popover .view-icon-item-editor:hover { + background-color: #f5f5f5; +} + +.view-edit-popover .view-icon-item-editor .view-icon-item-container { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + cursor: pointer; +} + +.view-edit-popover-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 12px; + border-bottom: 1px solid rgba(0, 40, 100, 0.12); + height: 40px; + font-size: 14px; +} + +.view-edit-popover-header .header-text { + color: #212529; +} + +.view-edit-popover-header .remove-icon-button { + color: #ff8000; +} + +.view-edit-popover-header .remove-icon-button:hover { + cursor: pointer; +} diff --git a/frontend/src/pages/wiki/css/view-structure.css b/frontend/src/pages/wiki/css/view-structure.css new file mode 100644 index 0000000000..4b23f6ee2e --- /dev/null +++ b/frontend/src/pages/wiki/css/view-structure.css @@ -0,0 +1,402 @@ +.view-structure { + height: calc(100% - 50px); + display: flex; + flex-direction: column; +} + +.views-structure-header { + height: 30px; + min-height: 30px; + padding: 0.25rem 10px; + display: flex; +} + +.view-structure-body { + margin-top: 10px; + padding-bottom: 0.5rem; + overflow: auto; + user-select: none; +} + +.view-structure .view-folder { + position: relative; +} + +.view-structure .view-folder.can-drop::after, +.view-structure .view-folder.can-drop-top::after { + content: ''; + width: 100%; + height: 1px; + position: absolute; + left: 0; + top: 40px; + background-color: #666; +} + +.view-structure .view-folder .view-folder-children { + transition: height 0.25s ease-in-out 0s; +} + +.view-structure .view-drop-target { + position: absolute; + left: 0; + width: 100%; + z-index: 1; + border-bottom: 1px solid #666; +} + +.view-structure .view-folder.can-drop::after { + top: unset; + bottom: 0; +} + +.view-structure .view-folder.can-drop-top::after { + top: 0; +} + +.view-structure .view-folder-wrapper, +.view-structure .view-item { + position: relative; + display: flex; + width: 100%; + height: 40px; + padding: 0 8px 0 0; + font-size: 14px; + font-weight: 500; + align-items: center; + justify-content: center; + background-color: #fff; +} + +.view-structure .view-item.selected-view, +.view-structure .view-folder-wrapper:hover, +.view-structure .view-item:hover { + background-color: #f5f5f5; +} + +.view-structure .folder-main, +.view-structure .view-item-main { + flex: 1; + display: flex; + align-items: center; + height: 100%; + overflow: hidden; +} + +.view-structure .more-views .folder-main { + padding-left: 20px; +} + +.view-structure .rdg-drag-handle { + width: 20px; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; +} + +.view-structure .rdg-drag-handle:hover { + cursor: move; + cursor: grab; +} + +.view-structure .view-folder-wrapper:hover .rdg-drag-handle, +.view-structure .view-item:hover .rdg-drag-handle { + visibility: visible; +} + +.view-structure .view-folder-wrapper .icon-expand-folder { + display: inline-block; + font-size: 12px; + transform: scale(0.8); + color: #b5b5b5; + cursor: pointer; +} + +.view-structure .folder-content, +.view-structure .view-content { + height: 100%; + padding-right: 8px; + line-height: 1; + display: flex; + align-items: center; + flex: 1; + overflow: hidden; +} + +.view-structure .in-folder .view-content { + padding-left: calc(12 * 0.8px + 0.5rem); +} + +.view-structure .folder-content:hover, +.view-structure .view-content:hover { + cursor: pointer; +} + +.view-structure .folder-content .dtable-font, +.view-structure .view-content .dtable-font { + margin-right: 6px; + font-size: 14px; +} + +.view-structure .folder-content .folder-name, +.view-structure .view-content .view-title { + height: 40px; + line-height: 40px; + flex: 1; +} + +.view-structure .more-view-folder-operation, +.view-structure .more-view-operation { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.view-structure .view-folder-wrapper .more-view-folder-operation .seafile-multicolor-icon-more-level, +.view-structure .view-item .more-view-operation .seafile-multicolor-icon-more-level { + opacity: 0; +} + +.view-structure .view-folder-wrapper:hover .more-view-folder-operation .seafile-multicolor-icon-more-level, +.view-structure .view-item:hover .more-view-operation .seafile-multicolor-icon-more-level { + opacity: 1; +} + +.view-structure .more-view-folder-operation:hover, +.view-structure .more-view-operation:hover { + cursor: pointer; +} + +.view-structure .more-view-folder-operation .dtable-font, +.view-structure .more-view-operation .dtable-font { + font-size: 14px; + margin-right: 10px; +} + +.view-structure .folder-list .view-folder.fold-freezed .btn-folder-operation, +.view-structure .view-item.view-freezed .seafile-multicolor-icon-more-level { + opacity: 1; +} + +.view-structure-footer { + user-select: none; +} + +.view-structure-footer.return-to-app { + position: absolute; + width: 100%; + bottom: 0; +} + +.add-view-wrapper { + position: relative; +} + +.view-structure-footer .dropdown { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.add-view-dropdown-menu { + margin-top: 0; +} + +.view-structure-footer .dropdown button { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + background-color: transparent !important; +} + +.view-structure-footer .dropdown button::after { + display: none !important; +} + +.view-structure-footer .dropdown button:focus { + box-shadow: none !important; +} + +.view-structure-footer .add-view-btn { + padding-left: 20px; + border-top: none; + height: 40px; +} + +.view-structure-footer .add-view-btn .dtable-icon-add-table { + margin-left: 1px; + margin-right: 0.5rem; + transform: unset; +} + +.view-sidebar .view-structure-footer .add-view-btn { + border-top: none; +} + +/* view operation dropdown */ +.view-structure .view-operation-dropdown-toggle { + display: inline-block; + width: 0; + height: 0; + opacity: 0; +} + +.view-structure .view-operation-dropdown .view-operation-dropdown-menu { + margin-left: -15px; + margin-top: 4px; +} + +/* folders dropdown */ +.view-structure .more-view-operation .btn-move-to-folder { + display: flex; + align-items: center; +} + +.view-structure .more-view-operation .move-to-folders-toggle { + opacity: 0; + width: 0; + min-width: 0; + margin-left: -12px; + padding: 0; +} + +.view-structure .more-view-operation .folders-dropdown-menu { + margin-top: -16px; + margin-left: -12px; +} + +.view-structure .folders-dropdown-menu .dropdown-item { + display: flex; + align-items: center; +} + +.view-structure .more-view-operation .folders-dropdown .dtable-icon-right-slide { + display: inline-flex; + font-size: 12px; + transform: scale(0.8); +} + +.view-structure .more-view-operation .btn-move-to-folder:focus .dtable-icon-insert-right, +.view-structure .more-view-operation .btn-move-to-folder:focus .dtable-icon-right-slide { + color: #fff; +} + +.view-structure .folders-dropdown { + width: 100%; +} + +.view-structure .folders-dropdown-toggle { + display: flex; + align-items: center; +} + +.view-structure .folders-dropdown .item-text { + flex: 1; +} + +.view-structure .folders-dropdown .dropdown-menu { + max-width: 180px; + max-height: 300px; + overflow-y: scroll; +} + +.view-structure .folders-dropdown .dropdown-menu .folder-name { + display: inline-block; + width: 100%; +} + +.view-structure .folders-dropdown .icon-dropdown-toggle { + display: inline-flex; + align-items: center; + width: 12px; + height: 12px; + margin-right: 12px; +} + +.view-structure .folders-dropdown .icon-dropdown-toggle .item-icon { + transform: scale(0.8); +} + +.view-structure .view-item.view-can-drop::after, +.view-structure .view-item.view-can-drop-top::after { + content: ''; + width: 100%; + height: 1px; + position: absolute; + left: 0; + top: 39px; + background-color: #666 !important; +} + +.view-structure .view-item.view-can-drop-top::after { + top: 0; +} + +/* dropdown icon */ +.dtable-dropdown-menu .dropdown-item .item-icon, +.dtable-dropdown-menu .dropdown-item .seafile-multicolor-icon { + font-size: 14px; + margin-right: 10px; + color: #8c8c8c; +} + +.dtable-dropdown-menu .dropdown-item:hover .item-icon, +.dtable-dropdown-menu .dropdown-item:hover .seafile-multicolor-icon { + color: #fff; +} + +/* dark mode */ +.view-structure-dark.view-structure, +.view-structure-dark.view-structure .view-folder .icon-expand-folder { + color: #fff; +} + +/* light mode */ +.view-structure-light.view-structure, +.view-structure-light.view-structure .view-item .seafile-multicolor-icon-more-level:hover, +.view-structure-light.view-structure .view-folder .seafile-multicolor-icon-more-level:hover, +.view-structure-light.view-structure .view-folder .icon-expand-folder:hover { + color: #212529; +} + +.view-structure-light.view-structure .view-item .seafile-multicolor-icon-more-level, +.view-structure-light.view-structure .view-folder .seafile-multicolor-icon-more-level, +.view-structure-light.view-structure .view-folder .icon-expand-folder { + color: #666; + margin-left: 4px; +} + +.view-structure .view-folder-wrapper.can-drop-top::before { + content: ''; + width: 100%; + height: 1px; + position: absolute; + left: 0; + top: 0; + background-color: #666; +} + +.view-structure .view-folder-wrapper.can-drop::after { + content: ''; + width: 100%; + height: 1px; + position: absolute; + left: 0; + background-color: #666; + top: unset; + bottom: 0; +} + +.svg-item { + width: 1em; + height: 1em; + font-size: 16px; +} diff --git a/frontend/src/pages/wiki/index.js b/frontend/src/pages/wiki/index.js index e8a85f81d1..0cee477e70 100644 --- a/frontend/src/pages/wiki/index.js +++ b/frontend/src/pages/wiki/index.js @@ -3,13 +3,17 @@ import moment from 'moment'; import MediaQuery from 'react-responsive'; import { Modal } from 'reactstrap'; import { Utils } from '../../utils/utils'; -import { slug, siteRoot, initialPath, isDir, sharedToken, hasIndex, lang } from '../../utils/constants'; -import { seafileAPI } from '../../utils/seafile-api'; +import wikiAPI from '../../utils/wiki-api'; +import { slug, siteRoot, initialPath, isDir, sharedToken, hasIndex, lang, isEditWiki } from '../../utils/constants'; import Dirent from '../../models/dirent'; +import WikiConfig from './models/wiki-config'; import TreeNode from '../../components/tree-view/tree-node'; import treeHelper from '../../components/tree-view/tree-helper'; +import toaster from '../../components/toast'; import SidePanel from './side-panel'; import MainPanel from './main-panel'; +import WikiLeftBar from './wiki-left-bar/wiki-left-bar'; +import PageUtils from './view-structure/page-utils'; import '../../css/layout.css'; import '../../css/side-panel.css'; @@ -34,10 +38,14 @@ class Wiki extends Component { lastModified: '', latestContributor: '', isTreeDataLoading: true, + isConfigLoading: true, treeData: treeHelper.buildTree(), currentNode: null, indexNode: null, indexContent: '', + currentPageId: '', + config: {}, + repoId: '', }; window.onpopstate = this.onpopstate; @@ -53,6 +61,7 @@ class Wiki extends Component { } componentDidMount() { + this.getWikiConfig(); this.loadSidePanel(initialPath); this.loadWikiData(initialPath); @@ -64,6 +73,40 @@ class Wiki extends Component { this.links.forEach(link => link.removeEventListener('click', this.onConentLinkClick)); } + handlePath = () => { + return isEditWiki ? 'edit-wiki/' : 'published/'; + }; + + getWikiConfig = () => { + wikiAPI.getWikiConfig(slug).then(res => { + const { wiki_config, repo_id } = res.data.wiki; + this.setState({ + config: new WikiConfig(JSON.parse(wiki_config) || {}), + isConfigLoading: false, + repoId: repo_id, + }); + }).catch((error) => { + let errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + this.setState({ + isConfigLoading: false, + }); + }); + }; + + saveWikiConfig = (wikiConfig, onSuccess, onError) => { + wikiAPI.updateWikiConfig(slug, JSON.stringify(wikiConfig)).then(res => { + this.setState({ + config: new WikiConfig(wikiConfig || {}), + }); + onSuccess && onSuccess(); + }).catch((error) => { + let errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); + onError && onError(); + }); + }; + loadSidePanel = (initialPath) => { if (hasIndex) { this.loadIndexNode(); @@ -103,17 +146,17 @@ class Wiki extends Component { if (isDir === 'None') { this.setState({pathExist: false}); - let fileUrl = siteRoot + 'published/' + slug + Utils.encodePath(initialPath); + let fileUrl = siteRoot + this.handlePath() + slug + Utils.encodePath(initialPath); window.history.pushState({url: fileUrl, path: initialPath}, initialPath, fileUrl); } }; loadIndexNode = () => { - seafileAPI.listWikiDir(slug, '/').then(res => { + wikiAPI.listWikiDir(slug, '/').then(res => { let tree = this.state.treeData; this.addFirstResponseListToNode(res.data.dirent_list, tree.root); let indexNode = tree.getNodeByPath(this.indexPath); - seafileAPI.getWikiFileContent(slug, indexNode.path).then(res => { + wikiAPI.getWikiFileContent(slug, indexNode.path).then(res => { this.setState({ treeData: tree, indexNode: indexNode, @@ -131,7 +174,7 @@ class Wiki extends Component { this.loadDirentList(dirPath); // update location url - let fileUrl = siteRoot + 'published/' + slug + Utils.encodePath(dirPath); + let fileUrl = siteRoot + this.handlePath() + slug + Utils.encodePath(dirPath); window.history.pushState({url: fileUrl, path: dirPath}, dirPath, fileUrl); }; @@ -143,7 +186,7 @@ class Wiki extends Component { }); this.removePythonWrapper(); - seafileAPI.getWikiFileContent(slug, filePath).then(res => { + wikiAPI.getWikiFileContent(slug, filePath).then(res => { let data = res.data; this.setState({ isDataLoading: false, @@ -152,10 +195,13 @@ class Wiki extends Component { lastModified: moment.unix(data.last_modified).fromNow(), latestContributor: data.latest_contributor, }); + }).catch(error => { + let errorMsg = Utils.getErrorMsg(error); + toaster.danger(errorMsg); }); const hash = window.location.hash; - let fileUrl = siteRoot + 'published/' + slug + Utils.encodePath(filePath) + hash; + let fileUrl = `${siteRoot}${this.handlePath()}${slug}${Utils.encodePath(filePath)}${hash}`; if (filePath === '/home.md') { window.history.replaceState({url: fileUrl, path: filePath}, filePath, fileUrl); } else { @@ -165,7 +211,7 @@ class Wiki extends Component { loadDirentList = (dirPath) => { this.setState({isDataLoading: true}); - seafileAPI.listWikiDir(slug, dirPath).then(res => { + wikiAPI.listWikiDir(slug, dirPath).then(res => { let direntList = res.data.dirent_list.map(item => { let dirent = new Dirent(item); return dirent; @@ -195,7 +241,7 @@ class Wiki extends Component { let tree = this.state.treeData.clone(); let node = tree.getNodeByPath(path); if (!node.isLoaded) { - seafileAPI.listWikiDir(slug, node.path).then(res => { + wikiAPI.listWikiDir(slug, node.path).then(res => { this.addResponseListToNode(res.data.dirent_list, node); let parentNode = tree.getNodeByPath(node.parentNode.path); parentNode.isExpanded = true; @@ -216,7 +262,7 @@ class Wiki extends Component { if (Utils.isMarkdownFile(path)) { path = Utils.getDirName(path); } - seafileAPI.listWikiDir(slug, path, true).then(res => { + wikiAPI.listWikiDir(slug, path, true).then(res => { let direntList = res.data.dirent_list; let results = {}; for (let i = 0; i < direntList.length; i++) { @@ -379,7 +425,7 @@ class Wiki extends Component { if (!node.isLoaded) { let tree = this.state.treeData.clone(); node = tree.getNodeByPath(node.path); - seafileAPI.listWikiDir(slug, node.path).then(res => { + wikiAPI.listWikiDir(slug, node.path).then(res => { this.addResponseListToNode(res.data.dirent_list, node); tree.collapseNode(node); this.setState({treeData: tree}); @@ -427,7 +473,7 @@ class Wiki extends Component { let tree = this.state.treeData.clone(); node = tree.getNodeByPath(node.path); if (!node.isLoaded) { - seafileAPI.listWikiDir(slug, node.path).then(res => { + wikiAPI.listWikiDir(slug, node.path).then(res => { this.addResponseListToNode(res.data.dirent_list, node); this.setState({treeData: tree}); }); @@ -472,11 +518,45 @@ class Wiki extends Component { node.addChildren(nodeList); }; + setCurrentPage = (pageId, callback) => { + const { currentPageId, config } = this.state; + if (pageId === currentPageId) { + callback && callback(); + return; + } + const { pages } = config; + const currentPage = PageUtils.getPageById(pages, pageId); + const path = currentPage.path; + if (Utils.isMarkdownFile(path) || Utils.isSdocFile(path)) { + if (path !== this.state.path) { + this.showFile(path); + } + this.onCloseSide(); + } else { + const w = window.open('about:blank'); + const url = siteRoot + 'd/' + sharedToken + '/files/?p=' + Utils.encodePath(path); + w.location.href = url; + } + this.setState({ + currentPageId: pageId, + path: path, + }, () => { + callback && callback(); + }); + }; + render() { return (
      + {isEditWiki && + this.saveWikiConfig(Object.assign({}, this.state.config, data))} + /> + } {gettext('Folder does not exist.')}
      ); const isViewingFile = this.props.pathExist && !this.props.isDataLoading && this.props.isViewFile; return ( -
      +
      {this.props.content}
      {!username && diff --git a/frontend/src/pages/wiki/models/folder.js b/frontend/src/pages/wiki/models/folder.js new file mode 100644 index 0000000000..80235303cd --- /dev/null +++ b/frontend/src/pages/wiki/models/folder.js @@ -0,0 +1,9 @@ +export default class Folder { + constructor(object) { + this.type = 'folder'; + this.id = object.id; + this.name = object.name; + this.icon = object.icon; + this.children = object.children || []; + } +} diff --git a/frontend/src/pages/wiki/models/page.js b/frontend/src/pages/wiki/models/page.js new file mode 100644 index 0000000000..3be59f17a0 --- /dev/null +++ b/frontend/src/pages/wiki/models/page.js @@ -0,0 +1,8 @@ +export default class Page { + constructor(object) { + this.id = object.id; + this.name = object.name; + this.path = object.path; + this.icon = object.icon; + } +} diff --git a/frontend/src/pages/wiki/models/wiki-config.js b/frontend/src/pages/wiki/models/wiki-config.js new file mode 100644 index 0000000000..a2a913617d --- /dev/null +++ b/frontend/src/pages/wiki/models/wiki-config.js @@ -0,0 +1,9 @@ +export default class WikiConfig { + constructor(object) { + this.version = object.version || 1; + this.wiki_name = object.wiki_name || ''; + this.wiki_icon = object.wiki_icon || ''; + this.navigation = object.navigation || []; + this.pages = object.pages || []; + } +} diff --git a/frontend/src/pages/wiki/side-panel.js b/frontend/src/pages/wiki/side-panel.js index 3388a0c90c..8bf6fd248f 100644 --- a/frontend/src/pages/wiki/side-panel.js +++ b/frontend/src/pages/wiki/side-panel.js @@ -1,14 +1,26 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { gettext, siteRoot, repoID, slug, username, permission } from '../../utils/constants'; -import Logo from '../../components/logo'; +import deepCopy from 'deep-copy'; +import { gettext, siteRoot, repoID, slug, username, permission, mediaUrl, isEditWiki } from '../../utils/constants'; +import toaster from '../../components/toast'; import Loading from '../../components/loading'; -import TreeView from '../../components/tree-view/tree-view'; +// import TreeView from '../../components/tree-view/tree-view'; +import ViewStructure from './view-structure'; import IndexMdViewer from './index-md-viewer'; +import PageUtils from './view-structure/page-utils'; +import NewFolderDialog from './view-structure/new-folder-dialog'; +import AddPageDialog from './view-structure/add-page-dialog'; +import ViewStructureFooter from './view-structure/view-structure-footer'; +import { generateUniqueId, getIconURL, isObjectNotEmpty } from './utils'; +import Folder from './models/folder'; +import Page from './models/page'; + +export const FOLDER = 'folder'; +export const PAGE = 'page'; const propTypes = { closeSideBar: PropTypes.bool.isRequired, - isTreeDataLoading: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, treeData: PropTypes.object.isRequired, indexNode: PropTypes.object, indexContent: PropTypes.string.isRequired, @@ -18,6 +30,10 @@ const propTypes = { onNodeCollapse: PropTypes.func.isRequired, onNodeExpanded: PropTypes.func.isRequired, onLinkClick: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + saveWikiConfig: PropTypes.func.isRequired, + setCurrentPage: PropTypes.func.isRequired, + currentPageId: PropTypes.string, }; class SidePanel extends Component { @@ -25,6 +41,10 @@ class SidePanel extends Component { constructor(props) { super(props); this.isNodeMenuShow = false; + this.state = { + isShowNewFolderDialog: false, + isShowAddPageDialog: false, + }; } renderIndexView = () => { @@ -42,7 +62,7 @@ class SidePanel extends Component { renderTreeView = () => { return (
      - {this.props.treeData && ( + {/* {this.props.treeData && ( - )} + )} */} + {isEditWiki && + + } + {this.state.isShowNewFolderDialog && + + } + {this.state.isShowAddPageDialog && + + }
      ); }; + confirmDeletePage = (pageId) => { + const config = deepCopy(this.props.config); + const { pages, navigation } = config; + const index = PageUtils.getPageIndexById(pageId, pages); + config.pages.splice(index, 1); + PageUtils.deletePage(navigation, pageId); + this.props.saveWikiConfig(config); + if (config.pages.length > 0) { + this.props.setCurrentPage(config.pages[0].id); + } else { + this.props.setCurrentPage(''); + } + }; + + onAddNewPage = async ({name, icon, path, successCallback, errorCallback}) => { + const { config } = this.props; + const navigation = config.navigation; + const pageId = generateUniqueId(navigation); + const newPage = new Page({ id: pageId, name, icon, path}); + this.addPage(newPage, successCallback, errorCallback); + }; + + duplicatePage = async (fromPageConfig, successCallback, errorCallback) => { + const { config } = this.props; + const { name, from_page_id } = fromPageConfig; + const { navigation, pages } = config; + const fromPage = PageUtils.getPageById(pages, from_page_id); + const newPageId = generateUniqueId(navigation); + let newPageConfig = { + ...fromPage, + id: newPageId, + name, + }; + const newPage = new Page({ ...newPageConfig }); + this.addPage(newPage, successCallback, errorCallback); + }; + + addPage = (page, successCallback, errorCallback) => { + const { config } = this.props; + const navigation = config.navigation; + const pageId = page.id; + config.pages.push(page); + PageUtils.addPage(navigation, pageId, this.current_folder_id); + config.navigation = navigation; + const onSuccess = () => { + this.props.setCurrentPage(pageId, successCallback); + successCallback(); + }; + this.props.saveWikiConfig(config, onSuccess, errorCallback); + }; + + onUpdatePage = (pageId, newPage) => { + if (newPage.name === '') { + toaster.danger(gettext('Page name cannot be empty')); + return; + } + const { config } = this.props; + let pages = config.pages; + let currentPage = pages.find(page => page.id === pageId); + Object.assign(currentPage, newPage); + config.pages = pages; + this.props.saveWikiConfig(config); + }; + + movePage = ({ moved_view_id, target_view_id, source_view_folder_id, target_view_folder_id, move_position }) => { + let config = deepCopy(this.props.config); + let { navigation } = config; + PageUtils.movePage(navigation, moved_view_id, target_view_id, source_view_folder_id, target_view_folder_id, move_position); + config.navigation = navigation; + this.props.saveWikiConfig(config); + }; + + movePageOut = (moved_page_id, source_folder_id, target_folder_id, move_position) => { + let config = deepCopy(this.props.config); + let { navigation } = config; + PageUtils.movePageOut(navigation, moved_page_id, source_folder_id, target_folder_id, move_position); + config.navigation = navigation; + this.props.saveWikiConfig(config); + }; + + // Create a new folder in the root directory (not supported to create a new subfolder in the folder) + addPageFolder = (folder_data, parent_folder_id) => { + const { config } = this.props; + const { navigation } = config; + let folder_id = generateUniqueId(navigation); + let newFolder = new Folder({ id: folder_id, ...folder_data }); + // No parent folder, directly add to the root directory + if (!parent_folder_id) { + config.navigation.push(newFolder); + } else { // Recursively find the parent folder and add + navigation.forEach(item => { + if (item.type === FOLDER) { + this._addFolder(item, newFolder, parent_folder_id); + } + }); + } + this.props.saveWikiConfig(config); + }; + + _addFolder(folder, newFolder, parent_folder_id) { + if (folder.id === parent_folder_id) { + folder.children.push(newFolder); + return; + } + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._addFolder(item, newFolder, parent_folder_id); + } + }); + } + + onModifyFolder = (folder_id, folder_data) => { + const { config } = this.props; + const { navigation } = config; + PageUtils.modifyFolder(navigation, folder_id, folder_data); + config.navigation = navigation; + this.props.saveWikiConfig(config); + }; + + onDeleteFolder = (page_folder_id) => { + const { config } = this.props; + const { navigation, pages } = config; + PageUtils.deleteFolder(navigation, pages, page_folder_id); + config.navigation = navigation; + this.props.saveWikiConfig(config); + }; + + // Drag a folder to the front and back of another folder + onMoveFolder = (moved_folder_id, target_folder_id, move_position) => { + const { config } = this.props; + const { navigation } = config; + let updatedNavigation = deepCopy(navigation); + + // Get the moved folder first and delete the original location + let moved_folder; + let moved_folder_index = PageUtils.getFolderIndexById(updatedNavigation, moved_folder_id); + if (moved_folder_index === -1) { + updatedNavigation.forEach(item => { + if (item.type === FOLDER) { + moved_folder_index = PageUtils.getFolderIndexById(item.children, moved_folder_id); + if (moved_folder_index > -1) { + moved_folder = item.children[moved_folder_index]; + item.children.splice(moved_folder_index, 1); + } + } + }); + } else { + moved_folder = updatedNavigation[moved_folder_index]; + updatedNavigation.splice(moved_folder_index, 1); + } + let indexOffset = 0; + if (move_position === 'move_below') { + indexOffset++; + } + // Get the location of the release + let target_folder_index = PageUtils.getFolderIndexById(updatedNavigation, target_folder_id); + if (target_folder_index === -1) { + updatedNavigation.forEach(item => { + if (item.type === FOLDER) { + target_folder_index = PageUtils.getFolderIndexById(item.children, target_folder_id); + if (target_folder_index > -1) { + item.children.splice(target_folder_index + indexOffset, 0, moved_folder); + } + } else { + // not changed + updatedNavigation = navigation; + } + }); + } else { + updatedNavigation.splice(target_folder_index + indexOffset, 0, moved_folder); + } + config.navigation = updatedNavigation; + this.props.saveWikiConfig(config); + }; + + // Not support yet: Move a folder into another folder + moveFolderToFolder = (moved_folder_id, target_folder_id) => { + let { config } = this.props; + let { navigation } = config; + + // Find the folder and move it to this new folder + let target_folder = PageUtils.getFolderById(navigation, target_folder_id); + if (!target_folder) { + toaster.danger('Only_support_two_level_folders'); + return; + } + + let moved_folder; + let moved_folder_index = PageUtils.getFolderIndexById(navigation, moved_folder_id); + + // The original directory is in the root directory + if (moved_folder_index > -1) { + moved_folder = PageUtils.getFolderById(navigation, moved_folder_id); + // If moved folder There are other directories under the ID, and dragging is not supported + if (moved_folder.children.some(item => item.type === FOLDER)) { + toaster.danger('Only_support_two_level_folders'); + return; + } + target_folder.children.push(moved_folder); + navigation.splice(moved_folder_index, 1); + } else { // The original directory is not in the root directory + navigation.forEach(item => { + if (item.type === FOLDER) { + let moved_folder_index = PageUtils.getFolderIndexById(item.children, moved_folder_id); + if (moved_folder_index > -1) { + moved_folder = item.children[moved_folder_index]; + target_folder.children.push(moved_folder); + item.children.splice(moved_folder_index, 1); + } + } + }); + } + config.navigation = navigation; + this.props.saveWikiConfig(config); + }; + + onToggleAddFolder = () => { + this.setState({ isShowNewFolderDialog: !this.state.isShowNewFolderDialog }); + }; + + openAddPageDialog = (folder_id) => { + this.current_folder_id = folder_id; + this.setState({ isShowAddPageDialog: true }); + }; + + closeAddPageDialog = () => { + this.current_folder_id = null; + this.setState({ isShowAddPageDialog: false }); + }; + + onSetFolderId = (folder_id) => { + this.current_folder_id = folder_id; + }; + + renderFolderView = () => { + const { config } = this.props; + const { pages, navigation } = config; + return ( +
      + + {this.state.isShowNewFolderDialog && + + } + {this.state.isShowAddPageDialog && + + } +
      + ); + }; + + renderContent = () => { + const { isLoading, indexNode, config } = this.props; + if (isLoading) { + return ; + } + if (indexNode) { + return this.renderIndexView(); + } + if (isObjectNotEmpty(config)) { + return this.renderFolderView(); + } + return this.renderTreeView(); + }; + render() { + const { wiki_name, wiki_icon } = this.props.config; + const src = wiki_icon ? getIconURL(repoID, wiki_icon) : `${mediaUrl}img/wiki/default.png`; return (
      - + {wiki_icon && } +

      {wiki_name || slug}

      ); diff --git a/frontend/src/pages/wiki/utils/index.js b/frontend/src/pages/wiki/utils/index.js new file mode 100644 index 0000000000..4a322b0509 --- /dev/null +++ b/frontend/src/pages/wiki/utils/index.js @@ -0,0 +1,40 @@ +import {serviceURL} from '../../../utils/constants'; + +const generatorBase64Code = (keyLength = 4) => { + let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789'; + let key = ''; + for (let i = 0; i < keyLength; i++) { + key += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return key; +}; + +const generateUniqueId = (navigation, length = 4) => { + let idMap = {}; + function recurseItem(item) { + if (!item) return; + idMap[item.id] = true; + if (Array.isArray(item.children)) { + item.children.forEach(item => { + recurseItem(item); + }); + } + } + navigation.forEach(item => recurseItem(item)); + + let _id = generatorBase64Code(length); + while (idMap[_id]) { + _id = generatorBase64Code(length); + } + return _id; +}; + +const isObjectNotEmpty = (obj) => { + return obj && Object.keys(obj).length > 0; +}; + +const getIconURL = (repoId, fileName) => { + return serviceURL + '/lib/' + repoId + '/file/_Internal/Wiki/Icon/' + fileName + '?raw=1'; +}; + +export { generatorBase64Code, generateUniqueId, isObjectNotEmpty, getIconURL }; diff --git a/frontend/src/pages/wiki/view-structure/add-page-dialog.js b/frontend/src/pages/wiki/view-structure/add-page-dialog.js new file mode 100644 index 0000000000..df9bcd4f14 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/add-page-dialog.js @@ -0,0 +1,233 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input, Button } from 'reactstrap'; +import { gettext, repoID } from '../../../utils/constants'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; +import Loading from '../../../components/loading'; +import { SeahubSelect } from '../../../components/common/select'; +import FileChooser from '../../../components/file-chooser/file-chooser'; + +import '../css/add-page-dialog.css'; + +const propTypes = { + toggle: PropTypes.func.isRequired, + onAddNewPage: PropTypes.func, +}; + +const DIALOG_MAX_HEIGHT = window.innerHeight - 56; // Dialog margin is 3.5rem (56px) + +class AddPageDialog extends React.Component { + + constructor(props) { + super(props); + this.options = this.getOptions(); + this.state = { + pageName: '', + iconClassName: '', + isLoading: false, + repo: null, + selectedPath: '', + errMessage: '', + newFileName: '', + selectedOption: this.options[0], + }; + } + + getOptions = () => { + return ( + [ + { + value: 'existing', + label: gettext('Select an existing file'), + }, + { + value: '.md', + label: gettext('Create a markdown file'), + }, + { + value: '.sdoc', + label: gettext('Create a sdoc file'), + }, + ] + ); + }; + + handleChange = (event) => { + let value = event.target.value; + if (value === this.state.pageName) { + return; + } + this.setState({ pageName: value }); + }; + + onFileNameChange = (event) => { + this.setState({ newFileName: event.target.value }); + }; + + toggle = () => { + this.props.toggle(); + }; + + onIconChange = (className) => { + this.setState({ iconClassName: className }); + }; + + onSubmit = () => { + let { + iconClassName, + selectedPath, + selectedOption, + } = this.state; + const pageName = this.state.pageName.trim(); + if (pageName === '') { + toaster.danger(gettext('Page name cannot be empty')); + return; + } + if (selectedOption.value === 'existing') { + if (selectedPath.endsWith('.sdoc') === false && selectedPath.endsWith('.md') === false) { + toaster.danger(gettext('Please select an existing sdoc or markdown file')); + return; + } + this.props.onAddNewPage({ + name: pageName, + icon: iconClassName, + path: selectedPath, + successCallback: this.onSuccess, + errorCallback: this.onError, + }); + this.setState({ isLoading: true }); + } + else { + const newFileName = this.state.newFileName.trim(); + if (newFileName === '') { + toaster.danger(gettext('New file name cannot be empty')); + return; + } + if (newFileName.includes('/')) { + toaster.danger(gettext('Name cannot contain slash')); + return; + } + if (newFileName.includes('\\')) { + toaster.danger(gettext('Name cannot contain backslash')); + return; + } + this.setState({ isLoading: true }); + seafileAPI.createFile(repoID, `${selectedPath}/${newFileName}${selectedOption.value}`).then(res => { + const { obj_name, parent_dir } = res.data; + this.props.onAddNewPage({ + name: pageName, + icon: iconClassName, + path: parent_dir === '/' ? `/${obj_name}` : `${parent_dir}/${obj_name}`, + successCallback: this.onSuccess, + errorCallback: this.onError, + }); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + this.onError(); + }); + } + }; + + onSuccess = () => { + this.toggle(); + }; + + onError = () => { + this.setState({ isLoading: false }); + }; + + onDirentItemClick = (repo, selectedPath) => { + this.setState({ + repo: repo, + selectedPath: selectedPath, + errMessage: '' + }); + }; + + onRepoItemClick = (repo) => { + this.setState({ + repo: repo, + selectedPath: '/', + errMessage: '' + }); + }; + + handleSelectChange = (selectedOption) => { + this.setState({ + selectedOption, + selectedPath: '', + }); + }; + + render() { + return ( + + {gettext('Add page')} + +
      + + + + + + + + + {this.state.selectedOption.value !== 'existing' && + <> + + + + + + + + + + } + {this.state.selectedOption.value === 'existing' && + + + + + } +
      +
      + + + {this.state.isLoading ? + : + + } + +
      + ); + } +} + +AddPageDialog.propTypes = propTypes; + +export default AddPageDialog; diff --git a/frontend/src/pages/wiki/view-structure/add-view-dropdownmenu.js b/frontend/src/pages/wiki/view-structure/add-view-dropdownmenu.js new file mode 100644 index 0000000000..e648606c50 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/add-view-dropdownmenu.js @@ -0,0 +1,53 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownMenu, DropdownItem, DropdownToggle } from 'reactstrap'; +import { gettext } from '../../../utils/constants'; +import Icon from '../../../components/icon'; + +class AddViewDropdownMenu extends Component { + + toggle = event => { + this.onStopPropagation(event); + this.props.onToggleAddViewDropdown(); + }; + + onToggleAddView = event => { + this.onStopPropagation(event); + this.props.onToggleAddView(); + }; + + onToggleAddFolder = event => { + this.onStopPropagation(event); + this.props.onToggleAddFolder(); + }; + + onStopPropagation = event => { + event && event.nativeEvent && event.nativeEvent.stopImmediatePropagation(); + }; + + render() { + return ( + + + + + + {gettext('Add page')} + + + + {gettext('Add folder')} + + + + ); + } +} + +AddViewDropdownMenu.propTypes = { + onToggleAddViewDropdown: PropTypes.func, + onToggleAddView: PropTypes.func, + onToggleAddFolder: PropTypes.func, +}; + +export default AddViewDropdownMenu; diff --git a/frontend/src/pages/wiki/view-structure/constant.js b/frontend/src/pages/wiki/view-structure/constant.js new file mode 100644 index 0000000000..e5e433186b --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/constant.js @@ -0,0 +1,2 @@ +export const DRAGGED_FOLDER_MODE = 'view-folder'; +export const DRAGGED_VIEW_MODE = 'view'; diff --git a/frontend/src/pages/wiki/view-structure/folders/dragged-folder-item.js b/frontend/src/pages/wiki/view-structure/folders/dragged-folder-item.js new file mode 100644 index 0000000000..c53ff7506a --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/folders/dragged-folder-item.js @@ -0,0 +1,103 @@ +import { DragSource, DropTarget } from 'react-dnd'; +import { DRAGGED_FOLDER_MODE, DRAGGED_VIEW_MODE } from '../constant'; +import FolderItem from './folder-item'; + +const dragSource = { + beginDrag(props, monitor) { + const { folderIndex, folder } = props; + return { + idx: folderIndex, + data: folder, + mode: DRAGGED_FOLDER_MODE, + }; + }, + endDrag(props, monitor) { + const sourceRow = monitor.getItem(); + const didDrop = monitor.didDrop(); + let targetRow = {}; + if (!didDrop) { + return { sourceRow, targetRow }; + } + }, + isDragging(props, monitor) { + const { folderIndex: currentIndex, draggedRow } = props; + const { idx } = draggedRow; + return idx > currentIndex; + }, +}; + +const dropTarget = { + drop(props, monitor) { + const sourceRow = monitor.getItem(); + const { folder: targetFolder } = props; + const targetFolderId = targetFolder.id; + const className = props.getClassName(); + if (!className) return; + let move_position; + if (className.includes('can-drop')) { + move_position = 'move_below'; + } + if (className.includes('can-drop-top')) { + move_position = 'move_above'; + } + let moveInto = className.includes('dragged-view-over'); + + // 1. drag source is page + if (sourceRow.mode === DRAGGED_VIEW_MODE) { + const sourceFolderId = sourceRow.folderId; + const draggedViewId = sourceRow.data.id; + // 1.1 move page into folder + if (moveInto) { + props.onMoveView({ + moved_view_id: draggedViewId, + target_view_id: null, + source_view_folder_id: sourceFolderId, + target_view_folder_id: targetFolderId, + move_position, + }); + return; + } else { // 1.2 Drag the page above or below the folder + props.movePageOut(draggedViewId, sourceFolderId, targetFolderId, move_position); + return; + } + } + // 2. drag source is folder + if (sourceRow.mode === DRAGGED_FOLDER_MODE) { + const draggedFolderId = sourceRow.data.id; + // 2.0 If dragged folder and target folder are the same folder, return + if (targetFolderId === draggedFolderId) { + return; + } + // 2.1 Do not support drag folder into another folder + if (moveInto) { + // props.moveFolderToFolder(draggedFolderId, targetFolderId); + return; + } else { + // 2.2 Drag folder above or below another folder + props.onMoveFolder(draggedFolderId, targetFolderId, move_position); + } + return; + } + // 3. Drag other dom + return; + } +}; + +const dragCollect = (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging(), +}); + +const dropCollect = (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + draggedRow: monitor.getItem(), + connect, + monitor, +}); + +export default DropTarget('ViewStructure', dropTarget, dropCollect)( + DragSource('ViewStructure', dragSource, dragCollect)(FolderItem) +); diff --git a/frontend/src/pages/wiki/view-structure/folders/folder-item.js b/frontend/src/pages/wiki/view-structure/folders/folder-item.js new file mode 100644 index 0000000000..9d0a018cd5 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/folders/folder-item.js @@ -0,0 +1,298 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import FolderOperationDropdownMenu from './folder-operation-dropdownmenu'; +import ViewItem from '../views/view-item'; +import DraggedFolderItem from './dragged-folder-item'; +import ViewEditPopover from '../../view-structure/views/view-edit-popover'; +import Icon from '../../../../components/icon'; + +const FOLDER = 'folder'; + +class FolderItem extends Component { + + constructor(props) { + super(props); + const { name, icon } = props.folder; + this.state = { + isEditing: false, + icon: icon || '', + name: name || '', + }; + } + + onToggleExpandFolder = (e) => { + e.nativeEvent.stopImmediatePropagation(); + this.props.onToggleExpandFolder(this.props.folder.id); + this.forceUpdate(); + }; + + onToggleExpandSubfolder = (subfolderId) => { + this.props.onToggleExpandFolder(subfolderId); + this.forceUpdate(); + }; + + onClickFolderChildren = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }; + + openFolderEditor = () => { + this.setState({ isEditing: true }); + }; + + closeFolderEditor = () => { + if (this.state.isEditing) { + const { name, icon } = this.state; + this.props.onModifyFolder(this.props.folder.id, { name, icon }); + this.setState({ isEditing: false }); + } + }; + + onChangeName = (name) => { + this.setState({ name }); + }; + + onChangeIcon = (icon) => { + this.setState({ icon }); + }; + + changeItemFreeze = (isFreeze) => { + this.isFreeze = true; + // if (isFreeze) { + // this.foldRef.classList.add('fold-freezed'); + // } else { + // this.foldRef.classList.remove('fold-freezed'); + // } + }; + + renderFolder = (folder, index, tableGridsLength, isOnlyOneView, id_view_map) => { + const { isEditMode, views, foldersStr } = this.props; + const { id: folderId } = folder; + return ( + + ); + }; + + renderView = (view, index, tableGridsLength, isOnlyOneView) => { + const { isEditMode, views, folder, foldersStr } = this.props; + const id = view.id; + if (!views.find(item => item.id === id)) return; + return ( + item.id === id)} + viewIndex={index} + folderId={folder.id} + isEditMode={isEditMode} + renderFolderMenuItems={this.props.renderFolderMenuItems} + duplicatePage={this.props.duplicatePage} + onSetFolderId={this.props.onSetFolderId} + onSelectView={() => this.props.onSelectView(id)} + onUpdatePage={this.props.onUpdatePage} + onDeleteView={this.props.onDeleteView.bind(this, id)} + onMoveViewToFolder={(targetFolderId) => { + this.props.onMoveViewToFolder(folder.id, view.id, targetFolderId); + }} + onMoveView={this.props.onMoveView} + onMoveFolder={this.props.onMoveFolder} + views={views} + foldersStr={foldersStr} + currentPageId={this.props.currentPageId} + /> + ); + }; + + getFolderClassName = (layerDragProps, state) => { + if (!state || ! layerDragProps || !layerDragProps.clientOffset) { + return 'view-folder-wrapper'; + } + let y = layerDragProps.clientOffset.y; + let top = this.foldWrapprRef.getBoundingClientRect().y; + let className = ''; + // middle + if (top + 10 < y && y < top + 30) { + className += ' dragged-view-over '; + } + // top + if (top + 10 > y) { + className += ' can-drop-top '; + } + // bottom + if (top + 30 < y) { + className += ' can-drop '; + } + this.props.setClassName(className); + return className + 'view-folder-wrapper'; + }; + + getFolderChildrenHeight = () => { + const { id: folderId, children } = this.props.folder; + const folded = this.props.getFolderState(folderId); + if (folded) return 0; + + let height = 0; + children.forEach((child) => { + // just support two levels + const { type, id: childId, children } = child; + if (type === FOLDER) { + height += (this.props.getFolderState(childId) || !Array.isArray(children)) + ? 40 : (children.length + 1) * 40; + } else { + height += 40; + } + }); + return height; + }; + + render() { + const { + connectDropTarget, connectDragPreview, connectDragSource, isOver, canDrop, + isEditMode, folder, tableGridsLength, id_view_map, isOnlyOneView, layerDragProps, + } = this.props; + const { isEditing } = this.state; + const { id: folderId, name, children, icon } = folder; + const folded = this.props.getFolderState(folderId); + let viewEditorId = `folder-item-${folderId}`; + return ( +
      this.foldRef = ref} + onClick={this.onToggleExpandFolder} + > + {connectDropTarget( + connectDragPreview( +
      this.foldWrapprRef = ref} + > +
      + {isEditMode ? + connectDragSource( +
      + +
      + ) + : +
      + } +
      + {icon && } + {name} + {isEditing && + + } +
      +
      + {isEditMode && + + } + +
      + ) + )} +
      + {!folded && children && + children.map((item, index) => { + return item.type === 'folder' ? this.renderFolder(item, index, tableGridsLength, isOnlyOneView, id_view_map) : this.renderView(item, index, tableGridsLength, isOnlyOneView); + }) + } +
      +
      + ); + } +} + +FolderItem.propTypes = { + isEditMode: PropTypes.bool, + folder: PropTypes.object, + folderIndex: PropTypes.number, + tableGridsLength: PropTypes.number, + id_view_map: PropTypes.object, + isOver: PropTypes.bool, + canDrop: PropTypes.bool, + isDragging: PropTypes.bool, + draggedRow: PropTypes.object, + connectDropTarget: PropTypes.func, + connectDragPreview: PropTypes.func, + connectDragSource: PropTypes.func, + renderFolderMenuItems: PropTypes.func, + duplicatePage: PropTypes.func, + onSetFolderId: PropTypes.func, + onToggleExpandFolder: PropTypes.func, + onToggleAddView: PropTypes.func, + onModifyFolder: PropTypes.func, + onDeleteFolder: PropTypes.func, + onSelectView: PropTypes.func, + onUpdatePage: PropTypes.func, + onDeleteView: PropTypes.func, + onMoveViewToFolder: PropTypes.func, + onMoveView: PropTypes.func, + isOnlyOneView: PropTypes.bool, + views: PropTypes.array, + onMoveFolder: PropTypes.func, + moveFolderToFolder: PropTypes.func, + foldersStr: PropTypes.string, + setClassName: PropTypes.func, + getClassName: PropTypes.func, + movePageOut: PropTypes.func, + layerDragProps: PropTypes.object, + getFolderState: PropTypes.func, + currentPageId: PropTypes.string, +}; + +export default FolderItem; diff --git a/frontend/src/pages/wiki/view-structure/folders/folder-operation-dropdownmenu.js b/frontend/src/pages/wiki/view-structure/folders/folder-operation-dropdownmenu.js new file mode 100644 index 0000000000..5e26834265 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/folders/folder-operation-dropdownmenu.js @@ -0,0 +1,112 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; +// import DeleteTip from '@/common/delete-tip'; +import { gettext } from '../../../../utils/constants'; +import Icon from '../../../../components/icon'; + +export default class FolderOperationDropdownMenu extends Component { + + static propTypes = { + changeItemFreeze: PropTypes.func, + openFolderEditor: PropTypes.func, + onDeleteFolder: PropTypes.func, + onToggleAddView: PropTypes.func, + onToggleAddArchiveView: PropTypes.func, + folderId: PropTypes.string, + }; + + constructor(props) { + super(props); + this.state = { + isMenuShow: false, + showTip: false, + }; + // this.isDesktop = checkDesktop(); + this.position = {}; + } + + onDropdownToggle = (e) => { + e.stopPropagation(); + const isMenuShow = !this.state.isMenuShow; + this.props.changeItemFreeze(isMenuShow); + this.setState({ isMenuShow }); + }; + + openFolderEditor = (evt) => { + evt.nativeEvent.stopImmediatePropagation(); + this.props.openFolderEditor(); + }; + + onDeleteFolder = (evt) => { + evt.nativeEvent.stopImmediatePropagation(); + this.props.onDeleteFolder(this.props.folderId); + }; + + // onClickDelete = (e) => { + // if (this.isDesktop) { + // e.stopPropagation(); + // const { top, left } = this.iconRef.getBoundingClientRect(); + // this.position = { + // top: top, + // left: left, + // }; + // setTimeout(() => { + // this.setState({ showTip: true }); + // }, 100); + // } else { + // this.onDeleteFolder(e); + // } + // }; + + closeTip = () => { + this.setState({ showTip: false }); + }; + + render() { + return ( + <> + + + + + + + + {gettext('Add page')} + + + + {gettext('Modify name')} + + + + {gettext('Delete folder')} + + + + {/* {this.isDesktop && this.state.showTip && + + } */} + + ); + } +} diff --git a/frontend/src/pages/wiki/view-structure/html5DragDropContext.js b/frontend/src/pages/wiki/view-structure/html5DragDropContext.js new file mode 100644 index 0000000000..a9708ef978 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/html5DragDropContext.js @@ -0,0 +1,4 @@ +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; + +export default DragDropContext(HTML5Backend); diff --git a/frontend/src/pages/wiki/view-structure/index.js b/frontend/src/pages/wiki/view-structure/index.js new file mode 100644 index 0000000000..e05d8b40e9 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/index.js @@ -0,0 +1,3 @@ +import ViewStructure from './view-structure'; + +export default ViewStructure; diff --git a/frontend/src/pages/wiki/view-structure/new-folder-dialog.js b/frontend/src/pages/wiki/view-structure/new-folder-dialog.js new file mode 100644 index 0000000000..38f41b3ed4 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/new-folder-dialog.js @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input, Button, Alert } from 'reactstrap'; +import { gettext } from '../../../utils/constants'; + +export default class NewFolderDialog extends Component { + + static propTypes = { + onAddFolder: PropTypes.func, + onToggleAddFolderDialog: PropTypes.func, + }; + + constructor(props) { + super(props); + this.state = { + folderName: '', + errMessage: '', + iconClassName: '', + }; + } + + componentDidMount() { + document.addEventListener('keydown', this.onHotKey); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onHotKey); + } + + onHotKey = (e) => { + if (e.keyCode === 13) { + e.preventDefault(); + this.handleSubmit(); + } + }; + + handleChange = (event) => { + let { folderName } = this.state; + let value = event.target.value; + if (value === folderName) { + return; + } + this.setState({ folderName: value }); + }; + + handleSubmit = () => { + let { folderName, iconClassName } = this.state; + if (!folderName) { + this.setState({ errMessage: gettext('Name_is_required') }); + return; + } + this.props.onAddFolder({ name: folderName, icon: iconClassName }); + this.props.onToggleAddFolderDialog(); + }; + + toggle = () => { + this.props.onToggleAddFolderDialog(); + }; + + onIconChange = (className) => { + this.setState({ iconClassName: className }); + }; + + render() { + const { folderName, errMessage } = this.state; + return ( + + {gettext('New folder')} + +
      + + + { + this.newInput = input; + }} + onChange={this.handleChange} + autoFocus={true} + /> + +
      + {errMessage && {errMessage}} +
      + + + + +
      + ); + } +} diff --git a/frontend/src/pages/wiki/view-structure/page-utils.js b/frontend/src/pages/wiki/view-structure/page-utils.js new file mode 100644 index 0000000000..0182f06c7a --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/page-utils.js @@ -0,0 +1,232 @@ +const FOLDER = 'folder'; +const PAGE = 'page'; + +export default class PageUtils { + + static getPageById = (pages, page_id) => { + if (!page_id || !Array.isArray(pages)) return null; + return pages.find((page) => page.id === page_id) || null; + }; + + static getPageFromNavigationById = (navigation, page_id) => { + if (!page_id || !Array.isArray(navigation)) return null; + let page_index = navigation.indexOf(item => item.id === page_id); + if (page_index > -1) { + return navigation[page_index]; + } + for (let i = 0; i < navigation.length; i++) { + const currNavigation = navigation[i]; + if (currNavigation.id === page_id) { + return currNavigation; + } + + if (Array.isArray(currNavigation.children)) { + for (let j = 0; j < currNavigation.children.length; j++) { + if (currNavigation.children[j].id === page_id) { + return currNavigation.children[j]; + } + } + } + } + return null; + }; + + static getPageIndexById = (pageId, pages) => { + return pages.findIndex(page => page.id === pageId); + }; + + static getFolderById = (list, folder_id) => { + if (!folder_id || !Array.isArray(list)) return null; + return list.find(item => item.type === FOLDER && item.id === folder_id); + }; + + static getFolderIndexById = (list, folder_id) => { + if (!folder_id || !Array.isArray(list)) return -1; + return list.findIndex(folder => folder.id === folder_id); + }; + + static modifyFolder(navigation, folder_id, folder_data) { + navigation.forEach(item => { + if (item.type === FOLDER) { + this._modifyFolder(item, folder_id, folder_data); + } + }); + } + + static _modifyFolder(folder, folder_id, folder_data) { + if (folder.id === folder_id) { + for (let key in folder_data) { + folder[key] = folder_data[key]; + } + return; + } + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._modifyFolder(item, folder_id, folder_data); + } + }); + } + + static deleteFolder(navigation, pages, folder_id) { + // delete folder and pages within it + const folderIndex = this.getFolderIndexById(navigation, folder_id); + if (folderIndex > -1) { + this._deletePagesInFolder(pages, navigation[folderIndex]); + navigation.splice(folderIndex, 1); + return true; + } + + // delete subfolder and pages within it + navigation.forEach(item => { + if (item.type === FOLDER) { + const folderIndex = this.getFolderIndexById(item.children, folder_id); + if (folderIndex > -1) { + const subfolder = item.children[folderIndex]; + this._deletePagesInFolder(pages, subfolder); + item.children.splice(folderIndex, 1); + return true; + } + } + }); + return false; + } + + static _deletePagesInFolder(pages, folder) { + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._deletePagesInFolder(pages, item); + } + if (item.type === PAGE) { + let index = this.getPageIndexById(item.id, pages); + pages.splice(index, 1); + } + }); + } + + static addPage(navigation, page_id, target_folder_id) { + // 1. Add pages directly under the root directory + if (!target_folder_id) { + navigation.push({ id: page_id, type: PAGE }); + return; + } else { + // 2. Add pages to the folder + navigation.forEach(item => { + if (item.type === FOLDER) { + this._addPageInFolder(page_id, item, target_folder_id); + } + }); + } + } + + static _addPageInFolder(page_id, folder, target_folder_id) { + if (folder.id === target_folder_id) { + folder.children.push({ id: page_id, type: PAGE }); + return true; + } + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._addPageInFolder(page_id, item, target_folder_id); + } + }); + } + + static insertPage(navigation, page_id, target_page_id, folder_id, move_position) { + // 1. No folder, insert page in root directory + if (!folder_id) { + let insertIndex = target_page_id ? navigation.findIndex(item => item.id === target_page_id) : -1; + if (insertIndex < 0) { + this.addPage(navigation, page_id, folder_id); + return true; + } + if (move_position === 'move_below') { + insertIndex++; + } + navigation.splice(insertIndex, 0, { id: page_id, type: PAGE }); + return; + } + // 2. If there is a folder, find it and insert it + navigation.forEach(item => { + if (item.type === FOLDER) { + this._insertPageIntoFolder(item, page_id, target_page_id, folder_id, move_position); + } + }); + } + + static _insertPageIntoFolder(folder, page_id, target_page_id, folder_id, move_position) { + if (folder.id === folder_id) { + let insertIndex = target_page_id ? folder.children.findIndex(item => item.id === target_page_id) : -1; + if (move_position === 'move_below') { + insertIndex++; + } + folder.children.splice(insertIndex, 0, { id: page_id, type: PAGE }); + return; + } + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._insertPageIntoFolder(item, page_id, target_page_id, folder_id, move_position); + } + }); + } + + // Move the page to the top or bottom of the folder + static insertPageOut(navigation, page_id, folder_id, move_position) { + let indexOffset = 0; + if (move_position === 'move_below') { + indexOffset++; + } + let page = { id: page_id, type: PAGE }; + let folder_index = this.getFolderIndexById(navigation, folder_id); + if (folder_index > -1) { + navigation.splice(folder_index + indexOffset, 0, page); + } else { + navigation.forEach((item) => { + if (item.type === FOLDER) { + let folder_index = this.getFolderIndexById(item.children, folder_id); + if (folder_index > -1) { + item.children.splice(folder_index + indexOffset, 0, page); + } + } + }); + } + } + + static deletePage(navigation, page_id) { + // 1. Delete pages directly under the root directory + const pageIndex = navigation.findIndex(item => item.id === page_id); + if (pageIndex > -1) { + navigation.splice(pageIndex, 1); + return true; + } + // 2. Delete Page in Folder + navigation.forEach(item => { + if (item.type === FOLDER) { + this._deletePageInFolder(item, page_id); + } + }); + } + + static _deletePageInFolder(folder, page_id) { + let pageIndex = folder.children.findIndex(item => item.id === page_id); + if (pageIndex > -1) { + folder.children.splice(pageIndex, 1); + return true; + } + folder.children.forEach(item => { + if (item.type === FOLDER) { + this._deletePageInFolder(item, page_id); + } + }); + } + + // movePageintoFolder + static movePage(navigation, moved_page_id, target_page_id, source_folder_id, target_folder_id, move_position) { + this.deletePage(navigation, moved_page_id, source_folder_id); + this.insertPage(navigation, moved_page_id, target_page_id, target_folder_id, move_position); + } + + // movePageOutsideFolder + static movePageOut(navigation, moved_page_id, source_folder_id, target_folder_id, move_position) { + this.deletePage(navigation, moved_page_id, source_folder_id); + this.insertPageOut(navigation, moved_page_id, target_folder_id, move_position); + } +} diff --git a/frontend/src/pages/wiki/view-structure/view-structure-footer.js b/frontend/src/pages/wiki/view-structure/view-structure-footer.js new file mode 100644 index 0000000000..cc82a75e3f --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/view-structure-footer.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import CommonAddTool from '../../../components/common/common-add-tool'; +import AddViewDropdownMenu from './add-view-dropdownmenu'; +import { gettext } from '../../../utils/constants'; + +class ViewStructureFooter extends Component { + + constructor(props) { + super(props); + this.state = { + isShowAddViewDropdownMenu: false, + isAddToolHover: false, + }; + } + + onMouseEnter = () => { + this.setState({ isAddToolHover: true }); + }; + + onMouseLeave = () => { + this.setState({ isAddToolHover: false }); + }; + + onToggleAddViewDropdown = (event) => { + event && event.stopPropagation(); + this.setState({ isShowAddViewDropdownMenu: !this.state.isShowAddViewDropdownMenu }); + }; + + render() { + return ( +
      this.viewFooterRef = ref} + > +
      + + {this.state.isShowAddViewDropdownMenu && + + } +
      +
      + ); + } +} + +ViewStructureFooter.propTypes = { + onToggleAddView: PropTypes.func, + onToggleAddFolder: PropTypes.func, +}; + +export default ViewStructureFooter; diff --git a/frontend/src/pages/wiki/view-structure/view-structure.js b/frontend/src/pages/wiki/view-structure/view-structure.js new file mode 100644 index 0000000000..ec63de47d2 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/view-structure.js @@ -0,0 +1,233 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { DropdownItem } from 'reactstrap'; +import { DropTarget, DragLayer } from 'react-dnd'; +import html5DragDropContext from './html5DragDropContext'; +import DraggedFolderItem from './folders/dragged-folder-item'; +import ViewItem from './views/view-item'; +import ViewStructureFooter from './view-structure-footer'; +import { repoID } from '../../../utils/constants'; + +import '../css/view-structure.css'; + +export const FOLDER = 'folder'; +export const PAGE = 'page'; + +class ViewStructure extends Component { + + static propTypes = { + isEditMode: PropTypes.bool, + navigation: PropTypes.array, + views: PropTypes.array, + onTogglePinViewList: PropTypes.func, + onToggleAddView: PropTypes.func, + onToggleAddFolder: PropTypes.func, + onModifyFolder: PropTypes.func, + onDeleteFolder: PropTypes.func, + onMoveFolder: PropTypes.func, + onSelectView: PropTypes.func, + onUpdatePage: PropTypes.func, + onDeleteView: PropTypes.func, + onMoveView: PropTypes.func, + moveFolderToFolder: PropTypes.func, + movePageOut: PropTypes.func, + duplicatePage: PropTypes.func, + onSetFolderId: PropTypes.func, + currentPageId: PropTypes.string, + }; + + constructor(props) { + super(props); + this.folderClassNameCache = ''; + this.idFoldedStatusMap = this.getFoldedFoldersFromBase(); + } + + getFoldedFoldersFromBase = () => { + const foldedFolders = window.localStorage.getItem(`wiki-folded-folders-${repoID}`); + return foldedFolders ? JSON.parse(foldedFolders) : {}; + }; + + setFoldedFolders = (foldedFolders) => { + window.localStorage.setItem(`wiki-folded-folders-${repoID}`, JSON.stringify(foldedFolders)); + }; + + getFolderState = (folderId) => { + return this.idFoldedStatusMap[folderId]; + }; + + onToggleExpandFolder = (folderId) => { + const idFoldedStatusMap = this.getFoldedFoldersFromBase(); + if (idFoldedStatusMap[folderId]) { + delete idFoldedStatusMap[folderId]; + } else { + idFoldedStatusMap[folderId] = true; + } + this.setFoldedFolders(idFoldedStatusMap); + this.idFoldedStatusMap = idFoldedStatusMap; + }; + + onToggleAddView = (folderId) => { + this.props.onToggleAddView(folderId); + }; + + onMoveViewToFolder = (source_view_folder_id, moved_view_id, target_view_folder_id) => { + this.props.onMoveView({ + moved_view_id, + source_view_folder_id, + target_view_folder_id, + target_view_id: null, + move_position: 'move_below' + }); + }; + + renderFolderMenuItems = ({ currentFolderId, onMoveViewToFolder }) => { + // folder lists (in the root directory) + const { navigation } = this.props; + let renderFolders = navigation.filter(item => item.type === 'folder' && item.id !== currentFolderId); + return renderFolders.map(folder => { + const { id, name } = folder; + return ( + + {name} + + ); + }); + }; + + setClassName = (name) => { + this.folderClassNameCache = name; + }; + + getClassName = () => { + return this.folderClassNameCache; + }; + + renderFolder = (folder, index, tableGridsLength, isOnlyOneView, id_view_map, layerDragProps) => { + const { isEditMode, views } = this.props; + const folderId = folder.id; + return ( + + ); + }; + + renderView = (view, index, tableGridsLength, isOnlyOneView, id_view_map) => { + const { isEditMode, views } = this.props; + const id = view.id; + if (!views.find(item => item.id === id)) return; + const folderId = null; // Pages in the root directory, no folders, use null + return ( + item.id === id)} + views={views} + viewIndex={index} + folderId={folderId} + isEditMode={isEditMode} + renderFolderMenuItems={this.renderFolderMenuItems} + duplicatePage={this.props.duplicatePage} + onSetFolderId={this.props.onSetFolderId} + onSelectView={() => this.props.onSelectView(id)} + onUpdatePage={this.props.onUpdatePage} + onDeleteView={this.props.onDeleteView.bind(this, id)} + onMoveViewToFolder={(targetFolderId) => { + this.onMoveViewToFolder(folderId, view.id, targetFolderId); + }} + onMoveView={this.props.onMoveView} + onMoveFolder={this.props.onMoveFolder} + foldersStr={''} + currentPageId={this.props.currentPageId} + /> + ); + }; + + // eslint-disable-next-line + renderStructureBody = React.forwardRef((layerDragProps, ref) => { + const { navigation, views, isEditMode } = this.props; + let isOnlyOneView = false; + if (views.length === 1) { + isOnlyOneView = true; + } + const tableGridsLength = views.length; + let id_view_map = {}; + views.forEach(view => id_view_map[view.id] = view); + const style = { maxHeight: isEditMode ? 'calc(100% - 40px)' : '100%' }; + return ( +
      + {navigation.map((item, index) => { + return item.type === 'folder' ? + this.renderFolder(item, index, tableGridsLength, isOnlyOneView, id_view_map, layerDragProps) : + this.renderView(item, index, tableGridsLength, isOnlyOneView, id_view_map); + })} +
      + ); + }); + + collect = (monitor) => { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + clientOffset: monitor.getClientOffset(), + isDragging: monitor.isDragging() + }; + }; + + render() { + const StructureBody = html5DragDropContext( + DropTarget('ViewStructure', {}, connect => ({ + connectDropTarget: connect.dropTarget() + }))(DragLayer(this.collect)(this.renderStructureBody)) + ); + const isSpecialInstance = false; + const isDarkMode = false; + return ( +
      + + {(this.props.isEditMode && !isSpecialInstance) && + + } +
      + ); + } +} + +export default ViewStructure; diff --git a/frontend/src/pages/wiki/view-structure/views/delete-dialog.js b/frontend/src/pages/wiki/view-structure/views/delete-dialog.js new file mode 100644 index 0000000000..149da5f2ea --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/views/delete-dialog.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { gettext } from '../../../../utils/constants'; + +export default class DeleteDialog extends React.Component { + + static propTypes = { + closeDeleteDialog: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + }; + + toggle = () => { + this.props.closeDeleteDialog(); + }; + + render() { + return ( + + {gettext('Delete page')} + +

      {gettext('Are you sure to delete this page?')}

      +
      + + + + +
      + ); + } +} diff --git a/frontend/src/pages/wiki/view-structure/views/drop-target-top-view.js b/frontend/src/pages/wiki/view-structure/views/drop-target-top-view.js new file mode 100644 index 0000000000..260474f998 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/views/drop-target-top-view.js @@ -0,0 +1,81 @@ +import React from 'react'; +import { DropTarget } from 'react-dnd'; +import PropTypes from 'prop-types'; + +const DropTargetTopView = (Placeholder) => class extends React.Component { + + static propTypes = { + connectDropTarget: PropTypes.func.isRequired, + isOver: PropTypes.bool, + canDrop: PropTypes.bool, + draggedRow: PropTypes.object, + targetFolderId: PropTypes.string, + targetViewId: PropTypes.string, + onMoveView: PropTypes.func, + }; + + render() { + const { connectDropTarget, isOver, canDrop, draggedRow } = this.props; + const { mode } = draggedRow || {}; + if (mode !== 'view') { + return null; + } + const style = { + position: 'absolute', + top: 0, + width: '100%', + zIndex: canDrop ? 1 : -1, + }; + return connectDropTarget( +
      + + {isOver && canDrop &&
      } +
      + ); + } +}; + +const target = { + drop(props, monitor) { + const sourceRow = monitor.getItem(); + if (sourceRow.mode !== 'view') { + return; + } + const { targetFolderId, targetViewId } = props; + const sourceFolderId = sourceRow.folderId; + const draggedViewId = sourceRow.data.id; + if (draggedViewId !== targetViewId) { + props.onMoveView({ + moved_view_id: draggedViewId, + target_view_id: targetViewId, + source_view_folder_id: sourceFolderId, + target_view_folder_id: targetFolderId, + move_position: 'move_above' + }); + } + } +}; + +function collect(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + draggedRow: monitor.getItem(), + }; +} + +class Placeholder extends React.Component { + + static propTypes = { + key: PropTypes.string, + }; + + render() { + return ( +
      + ); + } +} + +export default DropTarget('ViewStructure', target, collect)(DropTargetTopView(Placeholder)); diff --git a/frontend/src/pages/wiki/view-structure/views/page-dropdownmenu.js b/frontend/src/pages/wiki/view-structure/views/page-dropdownmenu.js new file mode 100644 index 0000000000..ccdb25d125 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/views/page-dropdownmenu.js @@ -0,0 +1,190 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap'; +import toaster from '../../../../components/toast'; +import { gettext } from '../../../../utils/constants'; +import Icon from '../../../../components/icon'; + +export default class PageDropdownMenu extends Component { + + static propTypes = { + view: PropTypes.object.isRequired, + views: PropTypes.array, + tableGridsLength: PropTypes.number, + folderId: PropTypes.string, + canDelete: PropTypes.bool, + canDuplicate: PropTypes.bool, + renderFolderMenuItems: PropTypes.func, + toggle: PropTypes.func, + toggleViewEditor: PropTypes.func, + duplicatePage: PropTypes.func, + onSetFolderId: PropTypes.func, + onDeleteView: PropTypes.func, + onModifyViewType: PropTypes.func, + onMoveViewToFolder: PropTypes.func, + isOnlyOneView: PropTypes.bool, + }; + + constructor(props) { + super(props); + this.state = { + isShowMenu: false, + }; + this.pageNameMap = this.calculateNameMap(); + } + + calculateNameMap = () => { + const { views } = this.props; + return views.reduce((map, view) => { + map[view.name] = true; + return map; + }, {}); + }; + + onDropdownToggle = (evt) => { + if (evt.target && this.foldersDropdownToggle && this.foldersDropdownToggle.contains(evt.target)) { + return; + } + evt.stopPropagation(); + this.props.toggle(); + }; + + onRenameView = (event) => { + event.nativeEvent.stopImmediatePropagation(); + this.props.toggleViewEditor(); + }; + + onDeleteView = (event) => { + event.nativeEvent.stopImmediatePropagation(); + this.props.onDeleteView(); + }; + + onModifyViewType = (event) => { + event.nativeEvent.stopImmediatePropagation(); + this.props.onModifyViewType(); + }; + + onMoveViewToFolder = (targetFolderId) => { + this.props.onMoveViewToFolder(targetFolderId); + }; + + onRemoveFromFolder = (evt) => { + evt.nativeEvent.stopImmediatePropagation(); + this.props.onMoveViewToFolder(null); + }; + + onToggleFoldersMenu = () => { + this.setState({ isShowMenu: !this.state.isShowMenu }); + }; + + duplicatePage = () => { + const { view, folderId } = this.props; + const { id: from_page_id, name } = view; + let duplicateCount = 1; + let newName = name + '(copy)'; + while (this.pageNameMap[newName]) { + newName = `${name}(copy${duplicateCount})`; + duplicateCount++; + } + const onsuccess = () => {}; + this.props.onSetFolderId(folderId); + this.props.duplicatePage({ name: newName, from_page_id }, onsuccess, this.duplicatePageFailure); + }; + + duplicatePageFailure = () => { + toaster.danger(gettext('Failed_to_duplicate_page')); + }; + + showMenu = () => { + this.setState({ isShowMenu: true }); + }; + + hideMenu = () => { + this.setState({ isShowMenu: false }); + }; + + render() { + const { + folderId, canDelete, canDuplicate, renderFolderMenuItems, tableGridsLength, isOnlyOneView, + } = this.props; + const folderMenuItems = renderFolderMenuItems && renderFolderMenuItems({ currentFolderId: folderId, onMoveViewToFolder: this.onMoveViewToFolder }); + return ( + + + + + + {gettext('Modify name')} + + {canDuplicate && + + + {gettext('Duplicate page')} + + } + {(isOnlyOneView || tableGridsLength === 1 || !canDelete) ? '' : ( + + + {gettext('Delete page')} + + )} + {folderId && + + + {gettext('Remove from folder')} + + } + {renderFolderMenuItems && folderMenuItems.length > 0 && + { + evt.stopPropagation(); + evt.nativeEvent.stopImmediatePropagation(); + this.showMenu(); + }} + onMouseEnter={this.showMenu} + onMouseLeave={this.hideMenu} + > + +
      this.foldersDropdownToggle = ref}> + + {gettext('Move to')} + + + + +
      + {this.state.isShowMenu && + + {folderMenuItems} + + } +
      +
      + } +
      +
      + ); + } +} diff --git a/frontend/src/pages/wiki/view-structure/views/view-edit-popover.js b/frontend/src/pages/wiki/view-structure/views/view-edit-popover.js new file mode 100644 index 0000000000..ffd753358c --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/views/view-edit-popover.js @@ -0,0 +1,77 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { PopoverBody } from 'reactstrap'; +import SeahubPopover from '../../../../components/common/seahub-popover'; +import { gettext } from '../../../../utils/constants'; + +import '../../css/view-edit-popover.css'; + + +class ViewEditPopover extends Component { + + constructor(props) { + super(props); + this.viewInputRef = React.createRef(); + } + + componentDidMount() { + const txtLength = this.props.viewName.length; + this.viewInputRef.current.setSelectionRange(0, txtLength); + } + + onChangeName = (e) => { + let name = e.target.value; + this.props.onChangeName(name); + }; + + onEnter = (e) => { + e.preventDefault(); + this.props.toggleViewEditor(); + }; + + renderViewName = () => { + const { viewName } = this.props; + return ( +
      + +
      + ); + }; + + render() { + return ( + +
      + {gettext('Modify Name')} +
      + + {this.renderViewName()} + +
      + ); + } +} + +ViewEditPopover.propTypes = { + viewName: PropTypes.string, + onChangeName: PropTypes.func, + toggleViewEditor: PropTypes.func, + viewEditorId: PropTypes.string, +}; + +export default ViewEditPopover; diff --git a/frontend/src/pages/wiki/view-structure/views/view-item.js b/frontend/src/pages/wiki/view-structure/views/view-item.js new file mode 100644 index 0000000000..21f2bffcb9 --- /dev/null +++ b/frontend/src/pages/wiki/view-structure/views/view-item.js @@ -0,0 +1,324 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { DragSource, DropTarget } from 'react-dnd'; +import ViewEditPopover from './view-edit-popover'; +import PageDropdownMenu from './page-dropdownmenu'; +import DeleteDialog from './delete-dialog'; +import { DRAGGED_FOLDER_MODE, DRAGGED_VIEW_MODE } from '../constant'; +import Icon from '../../../../components/icon'; + +const dragSource = { + beginDrag: props => { + return { + idx: props.viewIndex, + data: { ...props.view, index: props.viewIndex }, + folderId: props.folderId, + mode: DRAGGED_VIEW_MODE, + }; + }, + endDrag(props, monitor) { + const viewSource = monitor.getItem(); + const didDrop = monitor.didDrop(); + let viewTarget = {}; + if (!didDrop) { + return { viewSource, viewTarget }; + } + }, + isDragging(props) { + const { draggedRow, infolder, viewIndex: targetIndex } = props; + if (infolder) { + return false; + } + const { idx } = draggedRow; + return idx > targetIndex; + } +}; + +const dropTarget = { + drop(props, monitor) { + const sourceRow = monitor.getItem(); + // 1 drag page + if (sourceRow.mode === DRAGGED_VIEW_MODE) { + const { infolder, viewIndex: targetIndex, view: targetView, folderId: targetFolderId } = props; + const sourceFolderId = sourceRow.folderId; + const draggedViewId = sourceRow.data.id; + const targetViewId = targetView.id; + + if (draggedViewId !== targetViewId) { + const sourceIndex = sourceRow.idx; + let move_position; + if (infolder) { + move_position = 'move_below'; + } else { + move_position = sourceIndex > targetIndex ? 'move_above' : 'move_below'; + } + + props.onMoveView({ + moved_view_id: draggedViewId, + target_view_id: targetViewId, + source_view_folder_id: sourceFolderId, + target_view_folder_id: targetFolderId, + move_position, + }); + } + return; + } + // 1 drag folder + if (sourceRow.mode === DRAGGED_FOLDER_MODE) { + const { viewIndex: targetIndex, view: targetView } = props; + const draggedFolderId = sourceRow.data.id; + const targetViewId = targetView.id; + const sourceIndex = sourceRow.idx; + // Drag the parent folder to the child page, return + if (props.foldersStr.split('-').includes(draggedFolderId)) return; + props.onMoveFolder( + draggedFolderId, + targetViewId, + sourceIndex > targetIndex ? 'move_above' : 'move_below', + ); + return; + } + } +}; + +const dragCollect = (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + connectDragPreview: connect.dragPreview(), + isDragging: monitor.isDragging() +}); + +const dropCollect = (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + draggedRow: monitor.getItem() +}); + +class ViewItem extends Component { + + constructor(props) { + super(props); + this.state = { + isShowViewEditor: false, + isShowViewOperationDropdown: false, + isShowDeleteDialog: false, + viewName: props.view.name || '', + viewIcon: props.view.icon, + isSelected: props.currentPageId === props.view.id, + }; + this.viewItemRef = React.createRef(); + } + + onMouseEnter = () => { + if (this.state.isSelected) return; + }; + + onMouseLeave = () => { + if (this.state.isSelected) return; + }; + + onCurrentPageChanged = (currentPageId) => { + const { isSelected } = this.state; + if (currentPageId === this.props.view.id && isSelected === false) { + this.setState({ isSelected: true }); + } else if (currentPageId !== this.props.view.id && isSelected === true) { + this.setState({ isSelected: false }); + } + }; + + toggleViewEditor = (e) => { + if (e) e.stopPropagation(); + this.setState({ isShowViewEditor: !this.state.isShowViewEditor }, () => { + if (!this.state.isShowViewEditor) { + this.saveViewProperties(); + } + }); + }; + + saveViewProperties = () => { + const { name, icon, id } = this.props.view; + const { viewIcon } = this.state; + let viewName = this.state.viewName.trim(); + if (viewIcon !== icon || viewName !== name) { + let newView = {}; + if (viewName !== name) { + newView.name = viewName; + } + if (viewIcon !== icon) { + newView.icon = viewIcon; + } + this.props.onUpdatePage(id, newView); + } + }; + + onChangeName = (newViewName) => { + this.setState({ viewName: newViewName }); + }; + + onChangeIcon = (newViewIcon) => { + this.setState({ viewIcon: newViewIcon }); + }; + + openDeleteDialog = () => { + this.setState({ isShowDeleteDialog: true }); + }; + + closeDeleteDialog = () => { + this.setState({ isShowDeleteDialog: false }); + }; + + onViewOperationDropdownToggle = () => { + const isShowViewOperationDropdown = !this.state.isShowViewOperationDropdown; + this.setState({ isShowViewOperationDropdown }); + this.changeItemFreeze(isShowViewOperationDropdown); + }; + + changeItemFreeze = (isFreeze) => { + if (isFreeze) { + this.viewItemRef.classList.add('view-freezed'); + } else { + this.viewItemRef.classList.remove('view-freezed'); + } + }; + + renderIcon = (icon) => { + if (!icon) { + return null; + } + if (icon.includes('dtable-icon')) { + return ; + } else { + return ; + } + }; + + render() { + const { + connectDragSource, connectDragPreview, connectDropTarget, isOver, canDrop, isDragging, + infolder, view, tableGridsLength, isEditMode, folderId, isOnlyOneView, foldersStr, + } = this.props; + const { isShowViewEditor, viewName, viewIcon, isSelected } = this.state; + const isOverView = isOver && canDrop; + + const isSpecialInstance = false; + + let viewCanDropTop; + let viewCanDrop; + if (infolder) { + viewCanDropTop = false; + viewCanDrop = isOverView; + } else { + viewCanDropTop = isOverView && isDragging; + viewCanDrop = isOverView && !isDragging; + } + let viewEditorId = `view-editor-${view.id}`; + + return connectDropTarget( + connectDragPreview( +
      this.viewItemRef = ref} + onMouseEnter={this.onMouseEnter} + onMouseLeave={this.onMouseLeave} + id={viewEditorId} + > +
      {} : this.props.onSelectView}> + {(isEditMode && !isSpecialInstance) ? + connectDragSource( +
      + +
      + ) + : +
      + } +
      + {this.renderIcon(view.icon)} + {view.name} + {isShowViewEditor && ( + + )} +
      +
      +
      + {isEditMode && +
      + + {this.state.isShowViewOperationDropdown && + + } +
      + } +
      + {this.state.isShowDeleteDialog && + + } +
      + ) + ); + } +} + +ViewItem.propTypes = { + isOver: PropTypes.bool, + canDrop: PropTypes.bool, + isDragging: PropTypes.bool, + draggedRow: PropTypes.object, + isEditMode: PropTypes.bool, + infolder: PropTypes.bool, + view: PropTypes.object, + views: PropTypes.array, + viewIndex: PropTypes.number, + folderId: PropTypes.string, + tableGridsLength: PropTypes.number, + connectDragSource: PropTypes.func, + connectDragPreview: PropTypes.func, + connectDropTarget: PropTypes.func, + renderFolderMenuItems: PropTypes.func, + duplicatePage: PropTypes.func, + onSetFolderId: PropTypes.func, + onSelectView: PropTypes.func, + onUpdatePage: PropTypes.func, + onDeleteView: PropTypes.func, + onMoveViewToFolder: PropTypes.func, + onMoveView: PropTypes.func, + isOnlyOneView: PropTypes.bool, + onMoveFolder: PropTypes.func, + foldersStr: PropTypes.string, + currentPageId: PropTypes.string, +}; + +export default DropTarget('ViewStructure', dropTarget, dropCollect)( + DragSource('ViewStructure', dragSource, dragCollect)(ViewItem) +); diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-left-bar-dialog.css b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-left-bar-dialog.css new file mode 100644 index 0000000000..bd13973df4 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-left-bar-dialog.css @@ -0,0 +1,128 @@ +.app-settings-dialog .nav .nav-item { + padding: 0; +} + +.app-settings-dialog .nav .nav-item .nav-link { + padding: 0.5rem 0; + font-weight: normal; + transition: none; + width: 100%; +} + +.app-settings-dialog .nav .nav-item .nav-link.active { + color: #ff8000; + text-decoration: none; + border-bottom: 0.125rem solid #ff8000; +} + +.app-settings-dialog .nav-pills .nav-item .nav-link { + padding: 0.3125rem 1rem 0.3125rem 8px; +} + +.app-settings-dialog .nav-pills .nav-item .nav-link:hover { + background-color: #f5f5f5; +} + +.app-settings-dialog .nav-pills .nav-item .nav-link.active { + background-color: #ff8000; + color: #fff; + border: none; +} + +.app-settings-dialog .ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-settings-dialog { + max-width: 800px; + height: calc(100% - 56px); +} + +.app-settings-dialog .modal-content { + height: 100%; +} + +.app-settings-dialog .app-settings-dialog-content { + padding: 0; + min-height: 27rem; + display: flex; + flex-direction: row; + overflow: hidden; +} + +.app-settings-dialog-content .app-settings-dialog-side { + display: flex; + flex: 0 0 25%; + padding: 12px 8px; + border-right: 1px solid #eee; +} + +.app-settings-dialog-content .app-settings-dialog-main { + display: flex; + flex: 0 0 75%; + padding: 12px 8px; + overflow: auto; +} + +.app-settings-dialog-content .app-settings-dialog-main .tab-content { + flex: 1; +} + +.app-settings-dialog-content .app-settings-dialog-main .tab-pane { + height: 100%; +} + +.app-settings-dialog-content .app-settings-dialog-main .no-search-result { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-top: 100px; +} + +.app-settings-dialog-content .app-settings-dialog-main .no-search-result span { + color: #aaa; + margin-top: 10px; +} + +.app-settings-dialog-content .app-settings-dialog-main .search-text-clear { + line-height: 38px; + height: 38px; + margin-right: 5px; +} + +.app-setting-dialog-icon { + width: 100%; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; +} + +.app-setting-dialog-icon img { + max-height: 128px; +} + +.app-setting-dialog-icon-description { + color: #999; + font-size: 13px; +} + +.app-setting-dialog-name { + padding: 10px; +} + +.app-setting-dialog-name .rename-area-input { + width: 85%; +} + +.app-setting-dialog-name .rename-area-submit { + width: 15%; +} + +.tip { + color: #808080; + margin-bottom: 1rem; +} diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-custom-icon.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-custom-icon.js new file mode 100644 index 0000000000..534a89f277 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-custom-icon.js @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import { seafileAPI } from '../../../../utils/seafile-api'; +import { gettext, mediaUrl } from '../../../../utils/constants'; +import { getIconURL } from '../../utils'; + +class AppSettingsDialogCustomIcon extends React.Component { + + constructor(props) { + super(props); + this.state = { + iconName: this.props.config.wiki_icon, + }; + this.fileInput = React.createRef(); + } + + openFileInput = () => { + this.fileInput.current.click(); + }; + + uploadFile = () => { + if (!this.fileInput.current.files.length) { + return; + } + const file = this.fileInput.current.files[0]; + this.uploadLocalFile(file).then((iconName) => { + let wikiConfig = Object.assign({}, this.props.config, { + wiki_icon: iconName, + }); + this.props.updateConfig(wikiConfig); + this.props.onToggle(); + this.setState({ + iconName: iconName, + }); + }); + }; + + uploadLocalFile = (imageFile) => { + let repoID = this.props.repoId; + const name = 'wiki-icon-image-' + Date.now().toString() + '.png'; + return ( + seafileAPI.getFileServerUploadLink(repoID, '/').then((res) => { + const uploadLink = res.data + '?ret-json=1'; + const newFile = new File([imageFile], name, {type: imageFile.type}); + const formData = new FormData(); + formData.append('parent_dir', '/'); + formData.append('relative_path', '_Internal/Wiki/Icon'); + formData.append('file', newFile); + return seafileAPI.uploadImage(uploadLink, formData); + }).then ((res) => { + return name; + }) + ); + }; + + render() { + const hasIcon = false; + let { iconName } = this.state; + const iconUrl = iconName ? getIconURL(this.props.repoId, iconName) : `${mediaUrl}img/wiki/default.png`; + if (hasIcon) { + return ( +
      + +

      + {gettext('Please select a png image within 5MB.')} +

      +

      + {gettext('Recommended size is 256x256 px.')} +

      + + +
      + ); + } else { + return ( +
      +

      + {gettext('Please select a png image within 5MB.')} +

      +

      + {gettext('Recommended size is 256x256 px.')} +

      + + +
      + ); + } + } +} + +AppSettingsDialogCustomIcon.propTypes = { + onToggle: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, + repoId: PropTypes.string.isRequired, +}; + +export default AppSettingsDialogCustomIcon; diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.css b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.css new file mode 100644 index 0000000000..91f06c8888 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.css @@ -0,0 +1,71 @@ +.app-settings-dialog-icon-color { + padding: 10px; +} + +.app-settings-dialog-theme-color .seafile-multicolor-icon-container { + padding: 8px; +} + +.app-settings-dialog-theme-color .theme-color-backdrop { + width: 60px; + height: 60px; + line-height: 60px; + opacity: 0; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + text-align: center; + cursor: pointer; + border-radius: 50%; +} + +.app-settings-dialog-theme-color .theme-color-backdrop .dtable-font { + color: #fff; +} + +.app-settings-dialog-theme-color .theme-color-backdrop:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.3); +} + +#app-settings-dialog-theme-color-input { + position: relative; +} + +.app-settings-dialog-theme-color .dtable-icon-drop-down { + position: absolute; + font-size: 12px; + color: #b5b5b5; + -webkit-transform: scale(0.8); + transform: scale(0.8); + right: 10px; + top: 10px; +} + +.app-settings-dialog-theme-color #app-settings-dialog-theme-color-input:hover { + border-color: rgb(179, 179, 179); +} + +.app-settings-dialog-theme-color .app-theme-colors-content { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.app-settings-dialog-theme-color .app-theme-colors-content .app-theme-color-item { + display: inline-flex; + height: fit-content; + cursor: pointer; +} + +.app-settings-dialog-theme-color .app-theme-colors-content .colorinput-color { + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; +} + +.app-settings-dialog-theme-color .app-theme-colors-content .colorinput-color.light { + color: #555; +} diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.js new file mode 100644 index 0000000000..a7167dc36d --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icon-color.js @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, Label, Tooltip, Button } from 'reactstrap'; +import IconSettingsPopover from './icon-settings-popover'; +import { gettext, mediaUrl } from '../../../../utils/constants'; +import { getIconURL } from '../../utils'; + +import './app-settings-dialog-icon-color.css'; + +class AppSettingsDialogIconColor extends React.Component { + + constructor(props) { + super(props); + this.state = { + isShowIconPopover: false, + isTooltipOpen: false, + }; + this.renameRef = React.createRef(); + } + + onIconPopoverToggle = () => { + this.setState({ isShowIconPopover: !this.state.isShowIconPopover }); + }; + + onRenameIconToggle = () => { + this.setState({ isTooltipOpen: !this.state.isTooltipOpen }); + }; + + render() { + const { wiki_icon } = this.props.config; + const src = wiki_icon ? getIconURL(this.props.repoId, wiki_icon) : `${mediaUrl}img/wiki/default.png`; + return ( +
      + + +
      + {wiki_icon ? + <> + +
      + +
      + + : +
      + +
      + } + + {gettext('Change icon')} + +
      +
      + {this.state.isShowIconPopover && + + } +
      + ); + } +} + +AppSettingsDialogIconColor.propTypes = { + config: PropTypes.object.isRequired, + repoId: PropTypes.string.isRequired, + updateConfig: PropTypes.func.isRequired, +}; + +export default AppSettingsDialogIconColor; diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icons.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icons.js new file mode 100644 index 0000000000..580b5615d4 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-icons.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { mediaUrl } from '../../../../utils/constants'; + +const APP_ICON_CLASSNAMES = [ + 'default', +]; + +class AppSettingsDialogIcons extends React.Component { + + constructor(props) { + super(props); + this.state = { + iconClass: '', + }; + } + + onClickIcon = (iconClass) => { + this.setState({ iconClass }, () => { + this.props.updateConfig({ wiki_icon: '' }); + this.props.onToggle(); + }); + }; + + render() { + return ( +
      +
      + {APP_ICON_CLASSNAMES.map((name, index) => { + return ( +
      { + this.onClickIcon(name, e); + }} + className={`seafile-multicolor-icon-container ${index < 5 ? 'top' : ''}`} + > + +
      + ); + })} +
      +
      + ); + } +} + +AppSettingsDialogIcons.propTypes = { + onToggle: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, +}; + +export default AppSettingsDialogIcons; diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-name.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-name.js new file mode 100644 index 0000000000..55b54c980c --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/app-settings-dialog-name.js @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Label } from 'reactstrap'; +import toaster from '../../../../components/toast'; +import { gettext } from '../../../../utils/constants'; + +class AppSettingsDialogName extends Component { + + constructor(props) { + super(props); + this.state = { + name: props.config.wiki_name || '', + }; + } + + onChange = (event) => { + this.setState({ name: event.target.value }); + }; + + validateName = (name) => { + name = name.trim(); + if (name === '') { + return { isValid: false, message: gettext('Name is required') }; + } + if (name.includes('/')) { + return { isValid: false, message: gettext('Name cannot contain slash') }; + } + if (name.includes('\\')) { + return { isValid: false, message: gettext('Name cannot contain backslash') }; + } + return { isValid: true, message: name }; + }; + + onCommit = () => { + const { name } = this.state; + const { isValid, message } = this.validateName(name); + if (!isValid) { + toaster.danger(message); + return; + } else { + this.props.updateConfig({ wiki_name: message }); + } + }; + + render() { + const { name } = this.state; + return ( +
      + +
      + + +
      +
      + ); + } +} + +AppSettingsDialogName.propTypes = { + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, +}; + +export default AppSettingsDialogName; diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.css b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.css new file mode 100644 index 0000000000..d09ce945f6 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.css @@ -0,0 +1,64 @@ +.dtable-icon-settings-popover .popover { + max-width: 314px; + width: 314px; +} + +.app-icon-settings-popover-nav .nav { + border-bottom: 1px solid #efefef; +} + +.app-icon-settings-popover-nav .nav .nav-item { + padding: 0; +} + +.app-icon-settings-popover-nav .nav .nav-item .nav-link { + margin: 0; + display: flex; + justify-content: center; + font-weight: 600; + padding: 0.5rem 0; + border-bottom: 0.125rem solid transparent; +} + +.app-icon-settings-popover-nav .nav .nav-item .nav-link.active { + color: #ff8000; + text-decoration: none; + border-bottom: 0.125rem solid #ff8000; +} + +.app-icon-settings-popover-main { + height: 300px; + display: flex; + justify-content: center; + padding: 16px 5px; + align-items: center; +} + +.app-settings-dialog-icons { + height: 260px; +} + +.app-settings-dialog-icons .seafile-multicolor-icon-container { + margin: 10px; + cursor: pointer; + width: 40px; + height: 40px; + position: relative; +} + +.app-settings-dialog-icons .seafile-multicolor-icon-container.top { + margin: 0 10px 10px 10px; +} + +.app-settings-dialog-icons .seafile-multicolor-icon-container img { + width: 40px; + max-width: fit-content; + height: 40px; +} + +.app-settings-dialog-icons .seafile-multicolor-icon-container img.active { + width: 42px; + height: 42px; + border: 2px solid #b6e8e4; + border-radius: 50%; +} diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.js new file mode 100644 index 0000000000..2f1bd80fd2 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/icon-settings-popover.js @@ -0,0 +1,103 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Nav, NavItem, NavLink, TabContent, TabPane, PopoverBody } from 'reactstrap'; +import SeahubPopover from '../../../../components/common/seahub-popover'; +import { gettext } from '../../../../utils/constants'; +import AppSettingsDialogIcons from './app-settings-dialog-icons'; +import AppSettingsDialogCustomIcon from './app-settings-dialog-custom-icon'; + +import './icon-settings-popover.css'; + +export default class IconSettingsPopover extends React.Component { + + static propTypes = { + targetId: PropTypes.string.isRequired, + onToggle: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, + repoId: PropTypes.string.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + activeTab: 'system', + }; + } + + toggle = (tab) => { + if (this.state.activeTab !== tab) { + this.setState({ activeTab: tab }); + } + }; + + onEnter = (e) => { + e.preventDefault(); + this.props.onToggle(); + }; + + renderContent = () => { + let { activeTab } = this.state; + return ( + <> +
      + +
      +
      + + + + + + + + +
      + + ); + }; + + render() { + return ( + + + {this.renderContent()} + + + ); + } +} diff --git a/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/index.js b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/index.js new file mode 100644 index 0000000000..5f870028be --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/app-settings-dialog/index.js @@ -0,0 +1,89 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalBody, ModalHeader, Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; +import AppSettingsDialogIconColor from './app-settings-dialog-icon-color'; +import AppSettingsDialogName from './app-settings-dialog-name'; +import { gettext } from '../../../../utils/constants'; + +import './app-left-bar-dialog.css'; + +class AppSettingsDialog extends React.Component { + + constructor(props) { + super(props); + this.state = { + activeTab: 'Name', + }; + } + + toggle = (tab) => { + if (this.state.activeTab !== tab) { + this.setState({ activeTab: tab }); + } + }; + + renderContent = () => { + const { activeTab } = this.state; + return ( + +
      + +
      +
      + + + + + + + + +
      +
      + ); + }; + + render() { + return ( + + {gettext('Wiki settings')} + + {this.renderContent()} + + + ); + } +} + +AppSettingsDialog.propTypes = { + repoId: PropTypes.string.isRequired, + toggle: PropTypes.func.isRequired, + config: PropTypes.object.isRequired, + updateConfig: PropTypes.func.isRequired, +}; + +export default AppSettingsDialog; diff --git a/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar-icon.jsx b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar-icon.jsx new file mode 100644 index 0000000000..a990f264f5 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar-icon.jsx @@ -0,0 +1,53 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { Tooltip } from 'reactstrap'; +import Icon from '../../../components/icon'; + +function WikiLeftBarIcon(props) { + const [open, setOpen] = useState(false); + const inputEl = useRef(null); + + function onMouseEnter() { + if (inputEl && inputEl.current) { + inputEl.current.style.backgroundColor = '#dedede'; + } + } + + function onMouseLeave() { + if (inputEl && inputEl.current) { + inputEl.current.style.backgroundColor = ''; + } + } + + return ( + <> +
      + +
      + setOpen(!open)} + hideArrow={true} + fade={false} + > + {props.tipText} + + + ); +} + +WikiLeftBarIcon.propTypes = { + onClick: PropTypes.func.isRequired, + iconClass: PropTypes.string.isRequired, + tipText: PropTypes.string.isRequired, +}; + +export default WikiLeftBarIcon; diff --git a/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.css b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.css new file mode 100644 index 0000000000..619f3b5bf5 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.css @@ -0,0 +1,29 @@ +.seatable-app-universal-left-bar { + flex: 0 0 4%; + width: 50px; + background: #f5f5f5; + border-right: 1px solid #ddd; + z-index: 101; +} + +.seatable-app-universal-left-bar .left-bar-button { + height: 50px; + line-height: 50px; + text-align: center; +} + +.seatable-app-universal-left-bar .left-bar-button:hover { + cursor: pointer; +} + +.seatable-app-universal-left-bar .left-bar-button .seafile-multicolor-icon { + font-size: 22px; +} + +.seatable-app-universal-left-bar .left-bar-button .seafile-multicolor-icon { + color: rgba(0, 0, 0, 0.8); +} + +.seatable-app-universal-left-bar .left-bar-button:hover .seafile-multicolor-icon { + color: rgba(0, 0, 0, 0.9); +} diff --git a/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.js b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.js new file mode 100644 index 0000000000..ac655e4817 --- /dev/null +++ b/frontend/src/pages/wiki/wiki-left-bar/wiki-left-bar.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AppSettingsDialog from './app-settings-dialog/index'; +import Icon from './wiki-left-bar-icon.jsx'; +import { gettext } from '../../../utils/constants'; + +import './wiki-left-bar.css'; + +export default class WikiLeftBar extends React.Component { + + static propTypes = { + config: PropTypes.object.isRequired, + repoId: PropTypes.string.isRequired, + updateConfig: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + this.state = { + isShowSettingsDialog: false, + }; + } + + openPreviewApp = () => { + window.open(window.location.href.replace('/edit-wiki/', '/published/')); + }; + + openAppSettingsDialog = () => { + this.setState({ isShowSettingsDialog: true }); + }; + + closeAppSettingsDialog = () => { + this.setState({ isShowSettingsDialog: false }); + }; + + render() { + return ( +
      + + + {this.state.isShowSettingsDialog && + + } +
      + ); + } +} diff --git a/frontend/src/pages/wiki/wiki.css b/frontend/src/pages/wiki/wiki.css index 4e39d23275..f7809d7abd 100644 --- a/frontend/src/pages/wiki/wiki.css +++ b/frontend/src/pages/wiki/wiki.css @@ -1,13 +1,15 @@ .wiki-side-panel .panel-top { background: #fff; + display: flex; + align-items: center; } .wiki-side-nav { - flex:auto; - display:flex; - flex-direction:column; - overflow:hidden; /* for ff */ - border-right:1px solid #eee; + flex: auto; + display: flex; + flex-direction: column; + overflow: hidden; /* for ff */ + border-right: 1px solid #eee; } .wiki-pages-heading { @@ -52,9 +54,9 @@ img[src=""] { .wiki-side-panel { flex: 0 0 20%; - display:flex; - flex-direction:column; - overflow:hidden; + display: flex; + flex-direction: column; + overflow: hidden; } @media (max-width: 767px) { @@ -65,8 +67,8 @@ img[src=""] { .wiki-main-panel { flex: 1 0 80%; - display:flex; - flex-direction:column; + display: flex; + flex-direction: column; min-height: 0; overflow: hidden; } diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 3a461c64fa..bf537b7313 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -120,6 +120,7 @@ export const sharedToken = window.wiki ? window.wiki.config.sharedToken : ''; export const sharedType = window.wiki ? window.wiki.config.sharedType : ''; export const hasIndex = window.wiki ? window.wiki.config.hasIndex : ''; export const assetsUrl = window.wiki ? window.wiki.config.assetsUrl : ''; +export const isEditWiki = window.wiki ? window.wiki.config.isEditWiki : false; // file history export const PER_PAGE = 25; diff --git a/frontend/src/utils/wiki-api.js b/frontend/src/utils/wiki-api.js new file mode 100644 index 0000000000..501dbd9bb0 --- /dev/null +++ b/frontend/src/utils/wiki-api.js @@ -0,0 +1,141 @@ +import cookie from 'react-cookies'; +import axios from 'axios'; +import FormData from 'form-data'; +import { siteRoot } from './constants'; + +class WikiAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; //none + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length-1) === '/') { + var server = siteRoot.substring(0, siteRoot.length-1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + _sendPostRequest(url, form) { + if (form.getHeaders) { + return this.req.post(url, form, { + headers:form.getHeaders() + }); + } else { + return this.req.post(url, form); + } + } + + listWikiDir(slug, dirPath, withParents) { + const path = encodeURIComponent(dirPath); + let url = this.server + '/api/v2.1/wikis/' + encodeURIComponent(slug) + '/dir/?p=' + path; + if (withParents) { + url = this.server + '/api/v2.1/wikis/' + encodeURIComponent(slug) + '/dir/?p=' + path + '&with_parents=' + withParents; + } + return this.req.get(url); + } + + + getWikiFileContent(slug, filePath) { + const path = encodeURIComponent(filePath); + const time = new Date().getTime(); + const url = this.server + '/api/v2.1/wikis/' + encodeURIComponent(slug) + '/content/' + '?p=' + path + '&_=' + time; + return this.req.get(url); + } + + + listWikis(options) { + /* + * options: `{type: 'shared'}`, `{type: ['mine', 'shared', ...]}` + */ + let url = this.server + '/api/v2.1/wikis/'; + if (!options) { + // fetch all types of wikis + return this.req.get(url); + } + return this.req.get(url, { + params: options, + paramsSerializer: { + serialize: function(params) { + let list = []; + for (let key in params) { + if (Array.isArray(params[key])) { + for (let i = 0, len = params[key].length; i < len; i++) { + list.push(key + '=' + encodeURIComponent(params[key][i])); + } + } else { + list.push(key + '=' + encodeURIComponent(params[key])); + } + } + return list.join('&'); + } + } + }); + } + + addWiki(repoID) { + const url = this.server + '/api/v2.1/wikis/'; + let form = new FormData(); + form.append('repo_id', repoID); + return this._sendPostRequest(url, form); + } + + renameWiki(slug, name) { + const url = this.server + '/api/v2.1/wikis/' + slug + '/'; + let form = new FormData(); + form.append('wiki_name', name); + return this._sendPostRequest(url, form); + } + + updateWikiPermission(wikiSlug, permission) { + const url = this.server + '/api/v2.1/wikis/' + wikiSlug + '/'; + let params = { + permission: permission + }; + return this.req.put(url, params); + } + + deleteWiki(slug) { + const url = this.server + '/api/v2.1/wikis/' + slug + '/'; + return this.req.delete(url); + } + + updateWikiConfig(wikiSlug, wikiConfig) { + const url = this.server + '/api/v2.1/wiki-config/' + wikiSlug + '/'; + let params = { + wiki_config: wikiConfig + }; + return this.req.put(url, params); + } + + getWikiConfig(wikiSlug) { + const url = this.server + '/api/v2.1/wiki-config/' + wikiSlug + '/'; + return this.req.get(url); + } + +} + +let wikiAPI = new WikiAPI(); +let xcsrfHeaders = cookie.load('sfcsrftoken'); +wikiAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export default wikiAPI; diff --git a/media/img/wiki/default.png b/media/img/wiki/default.png new file mode 100644 index 0000000000..78453de8b8 Binary files /dev/null and b/media/img/wiki/default.png differ diff --git a/seahub/api2/endpoints/wikis.py b/seahub/api2/endpoints/wikis.py index ce174e55b0..031008b5fc 100644 --- a/seahub/api2/endpoints/wikis.py +++ b/seahub/api2/endpoints/wikis.py @@ -1,6 +1,8 @@ # Copyright (c) 2012-2016 Seafile Ltd. import json import logging +import requests +import posixpath from rest_framework import status from rest_framework.authentication import SessionAuthentication @@ -19,12 +21,17 @@ from seahub.api2.throttling import UserRateThrottle from seahub.api2.utils import api_error from seahub.wiki.models import Wiki, DuplicateWikiNameError from seahub.wiki.utils import is_valid_wiki_name, slugfy_wiki_name -from seahub.utils import is_org_context, get_user_repos +from seahub.utils import is_org_context, get_user_repos, gen_inner_file_get_url, gen_file_upload_url from seahub.utils.repo import is_group_repo_staff, is_repo_owner from seahub.views import check_folder_permission from seahub.share.utils import is_repo_admin from seahub.share.models import FileShare + +WIKI_CONFIG_PATH = '_Internal/Wiki' +WIKI_CONFIG_FILE_NAME = 'index.json' + + logger = logging.getLogger(__name__) @@ -71,10 +78,16 @@ class WikisView(APIView): filter_repo_ids += ([r.id for r in public]) filter_repo_ids = list(set(filter_repo_ids)) - ret = [x.to_dict() for x in Wiki.objects.filter( - repo_id__in=filter_repo_ids)] - return Response({'data': ret}) + wikis = Wiki.objects.filter(repo_id__in=filter_repo_ids) + + wiki_list = [] + for wiki in wikis: + wiki_info = wiki.to_dict() + wiki_info['can_edit'] = (username == wiki.username) + wiki_list.append(wiki_info) + + return Response({'data': wiki_list}) def post(self, request, format=None): """Add a new wiki. @@ -155,16 +168,29 @@ class WikiView(APIView): """ username = request.user.username try: - owner = Wiki.objects.get(slug=slug).username + wiki = Wiki.objects.get(slug=slug) except Wiki.DoesNotExist: error_msg = 'Wiki not found.' return api_error(status.HTTP_404_NOT_FOUND, error_msg) + owner = wiki.username if owner != username: error_msg = 'Permission denied.' return api_error(status.HTTP_403_FORBIDDEN, error_msg) Wiki.objects.filter(slug=slug).delete() + # file_name = os.path.basename(path) + repo_id = wiki.repo_id + file_name = WIKI_CONFIG_FILE_NAME + try: + seafile_api.del_file(repo_id, WIKI_CONFIG_PATH, + json.dumps([file_name]), + request.user.username) + except SearpcError as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + return Response() def put(self, request, slug): @@ -231,3 +257,102 @@ class WikiView(APIView): "Unable to rename wiki") return Response(wiki.to_dict()) + + +class WikiConfigView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + def put(self, request, slug): + """Edit a wiki config + """ + username = request.user.username + + try: + wiki = Wiki.objects.get(slug=slug) + except Wiki.DoesNotExist: + error_msg = "Wiki not found." + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if wiki.username != username: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + repo_id = wiki.repo_id + obj_id = json.dumps({'parent_dir': WIKI_CONFIG_PATH}) + + dir_id = seafile_api.get_dir_id_by_path(repo_id, WIKI_CONFIG_PATH) + if not dir_id: + seafile_api.mkdir_with_parents(repo_id, '/', WIKI_CONFIG_PATH, username) + + token = seafile_api.get_fileserver_access_token( + repo_id, obj_id, 'upload-link', username, use_onetime=False) + if not token: + return None + upload_link = gen_file_upload_url(token, 'upload-api') + upload_link = upload_link + '?replace=1' + + wiki_config = request.data.get('wiki_config', '{}') + + files = { + 'file': (WIKI_CONFIG_FILE_NAME, wiki_config) + } + data = {'parent_dir': WIKI_CONFIG_PATH, 'relative_path': '', 'replace': 1} + resp = requests.post(upload_link, files=files, data=data) + if not resp.ok: + logger.error(resp.text) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + wiki = wiki.to_dict() + wiki['wiki_config'] = wiki_config + return Response({'wiki': wiki}) + + def get(self, request, slug): + + try: + wiki = Wiki.objects.get(slug=slug) + except Wiki.DoesNotExist: + error_msg = "Wiki not found." + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if not wiki.has_read_perm(request): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + path = posixpath.join(WIKI_CONFIG_PATH, WIKI_CONFIG_FILE_NAME) + try: + repo = seafile_api.get_repo(wiki.repo_id) + if not repo: + error_msg = "Wiki library not found." + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + except SearpcError: + error_msg = _("Internal Server Error") + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + try: + file_id = seafile_api.get_file_id_by_path(repo.repo_id, path) + except SearpcError as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + wiki = wiki.to_dict() + if not file_id: + wiki['wiki_config'] = '{}' + return Response({'wiki': wiki}) + + token = seafile_api.get_fileserver_access_token(repo.repo_id, file_id, 'download', request.user.username, use_onetime=True) + + if not token: + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + url = gen_inner_file_get_url(token, WIKI_CONFIG_FILE_NAME) + resp = requests.get(url) + content = resp.content + + wiki['wiki_config'] = content + + return Response({'wiki': wiki}) diff --git a/seahub/templates/wiki/wiki.html b/seahub/templates/wiki/wiki.html index 1689d9c787..fedf9e3e4a 100644 --- a/seahub/templates/wiki/wiki.html +++ b/seahub/templates/wiki/wiki.html @@ -48,6 +48,7 @@ isPublicWiki: "{{ is_public_wiki }}", isDir: "{{ is_dir }}", hasIndex: {% if has_index %} true {% else %} false {% endif %}, + isEditWiki: false, assetsUrl: "{{ assets_url }}" } }; diff --git a/seahub/templates/wiki/wiki_edit.html b/seahub/templates/wiki/wiki_edit.html new file mode 100644 index 0000000000..8c3280d523 --- /dev/null +++ b/seahub/templates/wiki/wiki_edit.html @@ -0,0 +1,127 @@ +{% extends "base_for_react.html" %} +{% load i18n %} +{% load render_bundle from webpack_loader %} +{% load seahub_tags %} +{% block extra_ogp_tags %} + + + + + + +{% endblock %} +{% block extra_style %} + +{% render_bundle 'wiki' 'css' %} +{% endblock %} + +{% block wiki_title %} {{h1_head_content}} - {{repo_name}}{% endblock %} + +{% block extra_content %} +{% if not is_dir %} +
      +
      + {{ file_content }} +

      {% translate "Last modified by" %} {{modifier|email2nickname}}, {{modify_time|translate_seahub_time_str}}

      +
      +
      +
      + {% for outline in outlines %} + {{ outline }} + {% endfor %} +
      +
      +
      +{% endif %} +{% endblock %} + +{% block extra_script %} + + + +{% render_bundle 'wiki' 'js' %} +{% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index f25d5b5af1..338e0dccfb 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -86,7 +86,7 @@ from seahub.api2.endpoints.repo_share_invitation import RepoShareInvitationView from seahub.api2.endpoints.notifications import NotificationsView, NotificationView from seahub.api2.endpoints.repo_file_uploaded_bytes import RepoFileUploadedBytesView from seahub.api2.endpoints.user_avatar import UserAvatarView -from seahub.api2.endpoints.wikis import WikisView, WikiView +from seahub.api2.endpoints.wikis import WikisView, WikiView, WikiConfigView from seahub.api2.endpoints.drafts import DraftsView, DraftView from seahub.api2.endpoints.draft_reviewer import DraftReviewerView from seahub.api2.endpoints.repo_draft_info import RepoDraftInfo, RepoDraftCounts @@ -203,6 +203,7 @@ from seahub.ocm.settings import OCM_ENDPOINT from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskStatus, \ LibraryIndexState, QuestionAnsweringSearchInLibrary, FileDownloadToken +from seahub.wiki.views import edit_slug urlpatterns = [ path('accounts/', include('seahub.base.registration_urls')), @@ -515,6 +516,7 @@ urlpatterns = [ re_path(r'^api/v2.1/wikis/$', WikisView.as_view(), name='api-v2.1-wikis'), re_path(r'^api/v2.1/wikis/(?P[^/]+)/$', WikiView.as_view(), name='api-v2.1-wiki'), re_path(r'^api/v2.1/wikis/(?P[^/]+)/dir/$', WikiPagesDirView.as_view(), name='api-v2.1-wiki-pages-dir'), + re_path(r'^api/v2.1/wiki-config/(?P[^/]+)/$', WikiConfigView.as_view(), name='api-v2.1-wiki-config'), re_path(r'^api/v2.1/wikis/(?P[^/]+)/content/$', WikiPageContentView.as_view(), name='api-v2.1-wiki-pages-content'), path('view-image-via-public-wiki/', view_media_file_via_public_wiki, name='view_media_file_via_public_wiki'), @@ -699,6 +701,8 @@ urlpatterns = [ re_path(r'^api/v2.1/admin/invitations/$', AdminInvitations.as_view(), name='api-v2.1-admin-invitations'), re_path(r'^api/v2.1/admin/invitations/(?P[a-f0-9]{32})/$', AdminInvitation.as_view(), name='api-v2.1-admin-invitation'), + re_path(r'^edit-wiki/(?P[^/]+)/(?P.*)$', edit_slug, name='edit_slug'), + path('avatar/', include('seahub.avatar.urls')), path('notice/', include('seahub.notifications.urls')), path('contacts/', include('seahub.contacts.urls')), diff --git a/seahub/wiki/models.py b/seahub/wiki/models.py index b76eeeabe3..2029a5183b 100644 --- a/seahub/wiki/models.py +++ b/seahub/wiki/models.py @@ -108,6 +108,7 @@ class Wiki(models.Model): 'permission': self.permission, 'created_at': datetime_to_isoformat_timestr(self.created_at), 'updated_at': timestamp_to_isoformat_timestr(self.updated_at), + 'repo_id': self.repo_id, } diff --git a/seahub/wiki/views.py b/seahub/wiki/views.py index 1480f60371..544e2673e4 100644 --- a/seahub/wiki/views.py +++ b/seahub/wiki/views.py @@ -261,6 +261,145 @@ def slug(request, slug, file_path="home.md"): "assets_url": assets_url, }) +def edit_slug(request, slug, file_path="home.md"): + """ edit wiki page. + """ + # get wiki object or 404 + wiki = get_object_or_404(Wiki, slug=slug) + file_path = "/" + file_path + + # only wiki owner can edit wiki app + if not (request.user.username == wiki.username): + return render_permission_error(request, 'Permission denied.') + + is_dir = None + file_id = seafile_api.get_file_id_by_path(wiki.repo_id, file_path) + if file_id: + is_dir = False + + dir_id = seafile_api.get_dir_id_by_path(wiki.repo_id, file_path) + if dir_id: + is_dir = True + + # compatible with old wiki url + if is_dir is None: + if len(file_path.split('.')) == 1: + new_path = file_path[1:] + '.md' + url = reverse('edit_slug', args=[slug, new_path]) + return HttpResponseRedirect(url) + + # perm check + req_user = request.user.username + + if not req_user and not wiki.has_read_perm(request): + return redirect('auth_login') + else: + if not wiki.has_read_perm(request): + return render_permission_error(request, _('Unable to view Wiki')) + + file_type, ext = get_file_type_and_ext(posixpath.basename(file_path)) + if file_type == IMAGE: + file_url = reverse('view_lib_file', args=[wiki.repo_id, file_path]) + return HttpResponseRedirect(file_url + "?raw=1") + + if not req_user: + user_can_write = False + elif req_user == wiki.username or check_folder_permission( + request, wiki.repo_id, '/') == 'rw': + user_can_write = True + else: + user_can_write = False + + is_public_wiki = False + if wiki.permission == 'public': + is_public_wiki = True + + has_index = False + dirs = seafile_api.list_dir_by_path(wiki.repo_id, '/') + for dir_obj in dirs: + if dir_obj.obj_name == 'index.md': + has_index = True + break + + try: + fs = FileShare.objects.filter(repo_id=wiki.repo_id, path='/').first() + except FileShare.DoesNotExist: + fs = FileShare.objects.create_dir_link(wiki.username, wiki.repo_id, '/', + permission='view_download') + wiki.permission = 'public' + wiki.save() + is_public_wiki = True + + repo = seafile_api.get_repo(wiki.repo_id) + + file_content = '' + h1_head_content = '' + outlines = [] + latest_contributor = '' + last_modified = 0 + assets_url = '' + + if is_dir is False and file_type == MARKDOWN: + send_file_access_msg(request, repo, file_path, 'web') + + file_name = os.path.basename(file_path) + token = seafile_api.get_fileserver_access_token( + repo.repo_id, file_id, 'download', request.user.username, 'False') + if not token: + return render_error(request, _('Internal Server Error')) + + url = gen_inner_file_get_url(token, file_name) + try: + file_response = urllib.request.urlopen(url).read().decode() + except Exception as e: + logger.error(e) + return render_error(request, _('Internal Server Error')) + + err_msg = None + if file_response: + file_content, h1_head_content, outlines, err_msg = format_markdown_file_content( + slug, wiki.repo_id, file_path, fs.token, file_response) + + if err_msg: + logger.error(err_msg) + return render_error(request, _('Internal Server Error')) + + try: + dirent = seafile_api.get_dirent_by_path(wiki.repo_id, file_path) + if dirent: + latest_contributor, last_modified = dirent.modifier, dirent.mtime + except Exception as e: + logger.warning(e) + + if is_dir is False and file_type == SEADOC: + file_uuid = get_seadoc_file_uuid(repo, file_path) + assets_url = '/api/v2.1/seadoc/download-image/' + file_uuid + + last_modified = datetime.fromtimestamp(last_modified) + + return render(request, "wiki/wiki_edit.html", { + "wiki": wiki, + "repo_name": repo.name if repo else '', + "page_name": file_path, + "shared_token": fs.token, + "shared_type": fs.s_type, + "user_can_write": user_can_write, + "file_path": file_path, + "filename": os.path.splitext(os.path.basename(file_path))[0], + "h1_head_content": h1_head_content, + "file_content": file_content, + "outlines": outlines, + "modifier": latest_contributor, + "modify_time": last_modified, + "repo_id": wiki.repo_id, + "search_repo_id": wiki.repo_id, + "search_wiki": True, + "is_public_wiki": is_public_wiki, + "is_dir": is_dir, + "has_index": has_index, + "assets_url": assets_url, + }) + ''' @login_required