diff --git a/frontend/src/app.js b/frontend/src/app.js index e7c2c410de..644fb114fa 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -20,6 +20,7 @@ import MyLibraries from './pages/my-libs/my-libs'; import DirView from './components/dir-view/dir-view'; import Group from './pages/groups/group-view'; import Groups from './pages/groups/groups-view'; +import Wikis from './pages/wikis/wikis'; import MainContentWrapper from './components/main-content-wrapper'; import './assets/css/fa-solid.css'; @@ -38,6 +39,7 @@ const ShareAdminLibrariesWrapper = MainContentWrapper(ShareAdminLibraries); const ShareAdminFoldersWrapper = MainContentWrapper(ShareAdminFolders); const ShareAdminShareLinksWrapper = MainContentWrapper(ShareAdminShareLinks); const ShareAdminUploadLinksWrapper = MainContentWrapper(ShareAdminUploadLinks); +const Wikiswrapper = MainContentWrapper(Wikis); class App extends Component { @@ -142,6 +144,7 @@ class App extends Component { + diff --git a/frontend/src/components/main-side-nav.js b/frontend/src/components/main-side-nav.js index cb06d7c856..1dfc7028d4 100644 --- a/frontend/src/components/main-side-nav.js +++ b/frontend/src/components/main-side-nav.js @@ -170,6 +170,12 @@ class MainSideNav extends React.Component { {gettext('Acitivities')} +
  • + this.tabItemClick('wikis')}> + + {gettext('Wikis')} + +
  • this.tabItemClick('linked-devices')}> diff --git a/frontend/src/pages/wikis/wiki-add.js b/frontend/src/pages/wikis/wiki-add.js new file mode 100644 index 0000000000..2be9b8431a --- /dev/null +++ b/frontend/src/pages/wikis/wiki-add.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + isShowWikiAdd: PropTypes.bool.isRequired, + addPosition: PropTypes.object.isRequired, + onSelectToggle: PropTypes.func.isRequired, + onCreateToggle: PropTypes.func.isRequired, +}; + +class WikiAdd extends React.Component { + + render() { + let style = {}; + let {isShowWikiAdd, addPosition} = this.props; + if (isShowWikiAdd) { + style = {position: 'fixed', top: addPosition.top, left: addPosition.left, display: 'block'}; + } + return ( +
      +
    • {gettext('New Wiki')}
    • +
    • {gettext('Choose a library as Wiki')}
    • +
    + ); + } +} + +WikiAdd.propTypes = propTypes; + +export default WikiAdd; diff --git a/frontend/src/pages/wikis/wiki-create.js b/frontend/src/pages/wikis/wiki-create.js new file mode 100644 index 0000000000..a15d6c8a50 --- /dev/null +++ b/frontend/src/pages/wikis/wiki-create.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter, Input } from 'reactstrap'; + +const propTypes = { + toggleCancel: PropTypes.func.isRequired, + addWiki: PropTypes.func.isRequired, +}; + +class WikiDelete extends React.Component { + + constructor(props) { + super(props); + this.state = { + isExist: false, + name: "", + repoID: "", + }; + this.newName = React.createRef(); + } + + componentDidMount() { + this.newName.focus(); + this.newName.setSelectionRange(0, -1); + } + + inputNewName = (e) => { + this.setState({ + name: e.target.value, + }); + } + + handleKeyPress = (e) => { + if (e.key === 'Enter') { + this.handleSubmit(); + } + } + + handleSubmit = () => { + let { isExist, name, repoID } = this.state; + this.props.addWiki(isExist, name, repoID); + this.props.toggleCancel(); + } + + toggle = () => { + this.props.toggleCancel(); + } + + render() { + return ( + + {gettext('New Wiki')} + + + {this.newName = input}} value={this.state.name} onChange={this.inputNewName}/> + + + + + + + ); + } +} + +WikiDelete.propTypes = propTypes; + +export default WikiDelete; diff --git a/frontend/src/pages/wikis/wiki-delete.js b/frontend/src/pages/wikis/wiki-delete.js new file mode 100644 index 0000000000..867ab5e12b --- /dev/null +++ b/frontend/src/pages/wikis/wiki-delete.js @@ -0,0 +1,35 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; + +const propTypes = { + toggleCancel: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, +}; + +class WikiDelete extends React.Component { + + toggle = () => { + this.props.toggleCancel(); + } + + render() { + return ( + + {gettext('Delete Wiki')} + +

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

    +
    + + + + +
    + ); + } +} + +WikiDelete.propTypes = propTypes; + +export default WikiDelete; diff --git a/frontend/src/pages/wikis/wiki-menu.js b/frontend/src/pages/wikis/wiki-menu.js new file mode 100644 index 0000000000..c462d8d33e --- /dev/null +++ b/frontend/src/pages/wikis/wiki-menu.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + menuPosition: PropTypes.object.isRequired, + onRenameToggle: PropTypes.func.isRequired, + onDeleteToggle: PropTypes.func.isRequired, +}; + +class WikiMenu extends React.Component { + + render() { + let menuPosition = this.props.menuPosition; + let style = {position: 'fixed', top: menuPosition.top, left: menuPosition.left, display: 'block'}; + + return ( +
      +
    • {gettext('Rename')}
    • +
    • {gettext('Delete')}
    • +
    + ); + } +} + +WikiMenu.propTypes = propTypes; + +export default WikiMenu; diff --git a/frontend/src/pages/wikis/wiki-rename.js b/frontend/src/pages/wikis/wiki-rename.js new file mode 100644 index 0000000000..a657068a2d --- /dev/null +++ b/frontend/src/pages/wikis/wiki-rename.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const propTypes = { + wiki: PropTypes.object.isRequired, + onRenameConfirm: PropTypes.func.isRequired, + onRenameCancel: PropTypes.func.isRequired, +}; +class WikiRename extends React.Component { + + constructor(props) { + super(props); + this.state = { + name: props.wiki.name + }; + } + + componentDidMount() { + this.refs.renameInput.focus(); + this.refs.renameInput.setSelectionRange(0, -1); + } + + onChange = (e) => { + this.setState({name: e.target.value}); + } + + onClick = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + } + + onKeyPress = (e) => { + if (e.key === 'Enter') { + this.onRenameConfirm(e); + } + } + + onRenameConfirm = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + this.props.onRenameConfirm(this.state.name); + } + + onRenameCancel = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + this.props.onRenameCancel(); + } + + render() { + return ( +
    + + + +
    + ); + } +} + +WikiRename.propTypes = propTypes; + +export default WikiRename; \ No newline at end of file diff --git a/frontend/src/pages/wikis/wiki-select.js b/frontend/src/pages/wikis/wiki-select.js new file mode 100644 index 0000000000..49fd3e6409 --- /dev/null +++ b/frontend/src/pages/wikis/wiki-select.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext, siteRoot } from '../../utils/constants'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { seafileAPI } from '../../utils/seafile-api'; +import moment from 'moment'; + +const propTypes = { + toggleCancel: PropTypes.func.isRequired, + addWiki: PropTypes.func.isRequired, +}; + +class WikiSelect extends React.Component { + + constructor(props) { + super(props); + this.state = { + repos: [], + isExist: true, + name: "", + repoID: "", + } + } + + componentDidMount() { + seafileAPI.listRepos().then(res => { + this.setState({ + repos: res.data.repos, + }); + }) + } + + onChange = (repo) => { + this.setState({ + name: repo.repo_name, + repoID: repo.repo_id, + }); + } + + handleSubmit = () => { + let { isExist, name, repoID } = this.state; + this.props.addWiki(isExist, name, repoID); + this.props.toggleCancel(); + } + + toggle = () => { + this.props.toggleCancel(); + } + + render() { + return ( + + {gettext('Choose a library as Wiki')} + + + + + + + + + + + + {this.state.repos.map((repo, index) => { + return ( + + + + + + + ); + })} + +
    {/* select */}{/* icon */}{gettext('Name')}{gettext('Last Update')}
    {gettext(repo.repo_name)}{moment(repo.last_modified).fromNow()}
    +
    + + + + +
    + ); + } +} + +WikiSelect.propTypes = propTypes; + +export default WikiSelect; diff --git a/frontend/src/pages/wikis/wikis.js b/frontend/src/pages/wikis/wikis.js new file mode 100644 index 0000000000..a532e5b754 --- /dev/null +++ b/frontend/src/pages/wikis/wikis.js @@ -0,0 +1,410 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { seafileAPI } from '../../utils/seafile-api'; +import { gettext, siteRoot, loginUrl } from '../../utils/constants'; +import moment from 'moment'; +import { Button } from 'reactstrap'; +import Toast from '../../components/toast'; +import MenuControl from '../../components/menu-control'; +import WikiAdd from './wiki-add'; +import WikiMenu from './wiki-menu'; +import WikiRename from './wiki-rename'; +import WikiDelete from './wiki-delete'; +import WikiSelect from './wiki-select'; +import WikiCreate from './wiki-create'; + + +const itempropTypes = { + wiki: PropTypes.object.isRequired, + deleteWiki: PropTypes.func.isRequired, +}; + +class Item extends Component { + constructor(props) { + super(props); + this.state = { + isShowWikiMenu: false, + menuPosition: {top:'', left: ''}, + isItemFreezed: false, + isShowDeleteDialog: false, + isShowMenuControl: false, + isRenameing: false, + highlight: '', + wiki: this.props.wiki, + }; + } + + componentDidMount() { + document.addEventListener('click', this.onHideWikiMenu); + } + + componentWillUnmount() { + document.removeEventListener('click', this.onHideWikiMenu); + } + + onMenuToggle = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + + if (this.state.isShowWikiMenu) { + this.onHideWikiMenu(); + } else { + this.onShowWikiMenu(e); + } + } + + onShowWikiMenu = (e) => { + let left = e.clientX - 8*16; + let top = e.clientY + 12; + let position = {top: top, left: left}; + this.setState({ + isShowWikiMenu: true, + menuPosition: position, + isItemFreezed: true, + }); + } + + onHideWikiMenu = () => { + this.setState({ + isShowWikiMenu: false, + isItemFreezed: false, + }); + } + + onMouseEnter = () => { + if (!this.state.isItemFreezed) { + this.setState({ + isShowMenuControl: true, + highlight: 'tr-highlight', + }); + } + } + + onMouseLeave = () => { + if (!this.state.isItemFreezed) { + this.setState({ + isShowMenuControl: false, + highlight: '', + }); + } + } + + onRenameToggle = () => { + this.setState({ + isShowWikiMenu: false, + isItemFreezed: true, + isRenameing: true, + }); + } + + onRenameConfirm = (newName) => { + let wiki = this.state.wiki; + + if (newName === wiki.name) { + this.onRenameCancel(); + return false; + } + if (!newName) { + let errMessage = 'Name is required.'; + Toast.error(gettext(errMessage)); + return false; + } + if (newName.indexOf('/') > -1) { + let errMessage = 'Name should not include ' + '\'/\'' + '.'; + Toast.error(gettext(errMessage)); + return false; + } + this.renameWiki(newName); + this.onRenameCancel(); + } + + onRenameCancel = () => { + this.setState({ + isRenameing: false, + isItemFreezed: false, + }); + } + + onDeleteToggle = () => { + this.setState({ + isShowDeleteDialog: !this.state.isShowDeleteDialog, + }); + } + + renameWiki = (newName) => { + let wiki = this.state.wiki; + seafileAPI.renameWiki(wiki.slug, newName).then((res) => { + this.setState({wiki: res.data}); + }).catch((error) => { + if(error.response) { + let errorMsg = error.response.data.error_msg; + Toast.error(errorMsg); + } + }); + } + + deleteWiki = () => { + let wiki = this.props.wiki; + this.props.deleteWiki(wiki); + this.setState({ + isShowDeleteDialog: !this.state.isShowDeleteDialog, + }); + } + + render() { + let wiki = this.state.wiki; + let userProfileURL = `${siteRoot}profile/${encodeURIComponent(wiki.owner)}/`; + + return ( + + + {this.state.isRenameing ? + : + {gettext(wiki.name)} + } + + {gettext(wiki.owner_nickname)} + {moment(wiki.updated_at).fromNow()} + + + {this.state.isShowWikiMenu && + + } + {this.state.isShowDeleteDialog && + + } + + + ); + } +} + +Item.propTypes = itempropTypes; + + +const contentpropTypes = { + data: PropTypes.object.isRequired, + deleteWiki: PropTypes.func.isRequired, +}; + +class WikisContent extends Component { + + render() { + let {loading, errorMsg, wikis} = this.props.data; + + if (loading) { + return ; + } else if (errorMsg) { + return

    {errorMsg}

    ; + } else { + return ( + + + + + + + + + + + {wikis.map((wiki, index) => { + return(); + })} + +
    {gettext('Name')}{gettext('Owner')}{gettext('Last Update')}{/* operation */}
    + ); + } + } +} + +WikisContent.propTypes = contentpropTypes; + + +class Wikis extends Component { + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + wikis: [], + isShowWikiAdd: false, + addPosition: {top:'', left: ''}, + isShowSelectDialog: false, + isShowCreateDialog: false, + }; + } + + componentDidMount() { + document.addEventListener('click', this.onHideWikiAdd); + this.getWikis(); + } + + componentWillReceiveProps() { + this.getWikis(); + } + + componentWillUnmount() { + document.removeEventListener('click', this.onHideWikiAdd); + } + + getWikis = () => { + seafileAPI.listWikis().then(res => { + this.setState({ + loading: false, + wikis: res.data.data, + }); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + loading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + loading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + loading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + onAddMenuToggle = (e) => { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + + if (this.state.isShowWikiAdd) { + this.onHideWikiAdd(); + } else { + this.onShowWikiAdd(e); + } + } + + onShowWikiAdd = (e) => { + let left = e.clientX - 10*20; + let top = e.clientY + 12; + let position = {top: top, left: left}; + this.setState({ + isShowWikiAdd: true, + addPosition: position, + }); + } + + onHideWikiAdd = () => { + this.setState({ + isShowWikiAdd: false, + }); + } + + onSelectToggle = () => { + this.setState({ + isShowSelectDialog: !this.state.isShowSelectDialog, + }); + } + + onCreateToggle = () => { + this.setState({ + isShowCreateDialog: !this.state.isShowCreateDialog, + }); + } + + addWiki = (isExist, name, repoID) => { + seafileAPI.addWiki(isExist, name, repoID).then((res) => { + this.state.wikis.push(res.data); + this.setState({ + wikis: this.state.wikis + }); + }).catch((error) => { + if(error.response) { + let errorMsg = error.response.data.error_msg; + Toast.error(errorMsg); + } + }); + } + + deleteWiki = (wiki) => { + seafileAPI.deleteWiki(wiki.slug).then(() => { + this.setState({ + wikis: this.state.wikis.filter(item => { + return item.name !== wiki.name + }) + }); + }).catch((error) => { + if(error.response) { + let errorMsg = error.response.data.error_msg; + Toast.error(errorMsg); + } + }); + } + + render() { + return ( +
    +
    +
    +

    {gettext('Wikis')}

    +
    + +
    + {this.state.isShowWikiAdd && + + } + {this.state.isShowCreateDialog && + + } + {this.state.isShowSelectDialog && + + } +
    +
    + {(this.state.loading || this.state.wikis.length !== 0) && + + } + {(!this.state.loading && this.state.wikis.length === 0) && +
    +

    {gettext('You do not have any Wiki.')}

    +

    {gettext('Seafile Wiki enables you to organize your knowledge in a simple way. The contents of wiki is stored in a normal library with pre-defined file/folder structure. This enables you to edit your wiki in your desktop and then sync back to the server.')}

    +
    + } +
    +
    +
    + ); + } +} + +export default Wikis; diff --git a/seahub/wiki/views.py b/seahub/wiki/views.py index 2ae81f0792..46e98d70c2 100644 --- a/seahub/wiki/views.py +++ b/seahub/wiki/views.py @@ -34,7 +34,7 @@ def wiki_list(request): if joined_groups: joined_groups.sort(lambda x, y: cmp(x.group_name.lower(), y.group_name.lower())) - return render(request, "wiki/wiki_list.html", { + return render(request, "react_app.html", { "grps": joined_groups, })