diff --git a/frontend/config/webpack.config.dev.js b/frontend/config/webpack.config.dev.js index afa1a26b88..6933ed5e65 100644 --- a/frontend/config/webpack.config.dev.js +++ b/frontend/config/webpack.config.dev.js @@ -204,6 +204,11 @@ module.exports = { require.resolve('react-dev-utils/webpackHotDevClient'), paths.appSrc + "/repo-history.js", ], + repoSnapshot: [ + require.resolve('./polyfills'), + require.resolve('react-dev-utils/webpackHotDevClient'), + paths.appSrc + "/repo-snapshot.js", + ], repoFolderTrash: [ require.resolve('./polyfills'), require.resolve('react-dev-utils/webpackHotDevClient'), diff --git a/frontend/config/webpack.config.prod.js b/frontend/config/webpack.config.prod.js index e9a5b9d1c9..157e32d31f 100644 --- a/frontend/config/webpack.config.prod.js +++ b/frontend/config/webpack.config.prod.js @@ -89,6 +89,7 @@ module.exports = { viewFileUnknown: [require.resolve('./polyfills'), paths.appSrc + "/view-file-unknown.js"], settings: [require.resolve('./polyfills'), paths.appSrc + "/settings.js"], repoHistory: [require.resolve('./polyfills'), paths.appSrc + "/repo-history.js"], + repoSnapshot: [require.resolve('./polyfills'), paths.appSrc + "/repo-snapshot.js"], repoFolderTrash: [require.resolve('./polyfills'), paths.appSrc + "/repo-folder-trash.js"], orgAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/org-admin"], sysAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/sys-admin"], diff --git a/frontend/src/components/dialog/confirm-restore-repo.js b/frontend/src/components/dialog/confirm-restore-repo.js new file mode 100644 index 0000000000..e20fe08da7 --- /dev/null +++ b/frontend/src/components/dialog/confirm-restore-repo.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext } from '../../utils/constants'; + +const propTypes = { + restoreRepo: PropTypes.func.isRequired, + toggle: PropTypes.func.isRequired +}; + +class ConfirmRestoreRepo extends Component { + + constructor(props) { + super(props); + this.state = { + btnDisabled: false + }; + } + + action = () => { + this.setState({ + btnDisabled: true + }); + this.props.restoreRepo(); + } + + render() { + const {formActionURL, csrfToken, toggle} = this.props; + return ( + + {gettext('Restore Library')} + +

{gettext('Are you sure you want to restore this library?')}

+
+ + + + +
+ ); + } +} + +ConfirmRestoreRepo.propTypes = propTypes; + +export default ConfirmRestoreRepo; diff --git a/frontend/src/css/repo-snapshot.css b/frontend/src/css/repo-snapshot.css new file mode 100644 index 0000000000..cf63ab3952 --- /dev/null +++ b/frontend/src/css/repo-snapshot.css @@ -0,0 +1,37 @@ +body { + overflow: hidden; +} +#wrapper { + height: 100%; +} +.top-header { + background: #f4f4f7; + border-bottom: 1px solid #e8e8e8; + padding: .5rem 1rem; + flex-shrink: 0; +} +.go-back { + color: #c0c0c0; + font-size: 1.75rem; + position: absolute; + left: -40px; + top: -5px; +} +.op-bar { + padding: 9px 10px; + background: #f2f2f2; + border-radius: 2px; +} +.op-bar-btn { + border-color: #ccc; + border-radius: 2px; + height: 30px; + line-height: 28px; + font-weight: normal; + padding: 0 0.5rem; + min-width: 55px; +} +.heading-commit-time { + font-weight: normal; + font-size: 60%; +} diff --git a/frontend/src/repo-history.js b/frontend/src/repo-history.js index 697f9e8a47..f391653748 100644 --- a/frontend/src/repo-history.js +++ b/frontend/src/repo-history.js @@ -294,7 +294,7 @@ class Item extends React.Component { {userPerm == 'rw' && ( item.isFirstCommit ? {gettext('Current Version')} : - {gettext('View Snapshot')} + {gettext('View Snapshot')} )} diff --git a/frontend/src/repo-snapshot.js b/frontend/src/repo-snapshot.js new file mode 100644 index 0000000000..b0750bfa55 --- /dev/null +++ b/frontend/src/repo-snapshot.js @@ -0,0 +1,337 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { navigate } from '@reach/router'; +import moment from 'moment'; +import { Utils } from './utils/utils'; +import { gettext, loginUrl, siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle } from './utils/constants'; +import { seafileAPI } from './utils/seafile-api'; +import Loading from './components/loading'; +import ModalPortal from './components/modal-portal'; +import toaster from './components/toast'; +import CommonToolbar from './components/toolbar/common-toolbar'; +import ConfirmRestoreRepo from './components/dialog/confirm-restore-repo'; + +import './css/toolbar.css'; +import './css/search.css'; + +import './css/repo-snapshot.css'; + +const { + repoID, repoName, isRepoOwner, + commitID, commitTime, commitDesc, commitRelativeTime, + showAuthor, authorAvatarURL, authorName, authorNickName +} = window.app.pageOptions; + +class RepoSnapshot extends React.Component { + + constructor(props) { + super(props); + this.state = { + isLoading: true, + errorMsg: '', + folderPath: '/', + folderItems: [], + isConfirmDialogOpen: false + }; + } + + componentDidMount() { + this.renderFolder(this.state.folderPath); + } + + toggleDialog = () => { + this.setState({ + isConfirmDialogOpen: !this.state.isConfirmDialogOpen + }); + } + + onSearchedClick = (selectedItem) => { + if (selectedItem.is_dir === true) { + let url = siteRoot + 'library/' + selectedItem.repo_id + '/' + selectedItem.repo_name + selectedItem.path; + navigate(url, {repalce: true}); + } else { + let url = siteRoot + 'lib/' + selectedItem.repo_id + '/file' + Utils.encodePath(selectedItem.path); + let newWindow = window.open('about:blank'); + newWindow.location.href = url; + } + } + + goBack = (e) => { + e.preventDefault(); + window.history.back(); + } + + renderFolder = (folderPath) => { + this.setState({ + folderPath: folderPath, + folderItems: [], + isLoading: true + }); + + seafileAPI.listCommitDir(repoID, commitID, folderPath).then((res) => { + this.setState({ + isLoading: false, + folderItems: res.data.dirent_list + }); + }).catch((error) => { + if (error.response) { + if (error.response.status == 403) { + this.setState({ + isLoading: false, + errorMsg: gettext('Permission denied') + }); + location.href = `${loginUrl}?next=${encodeURIComponent(location.href)}`; + } else { + this.setState({ + isLoading: false, + errorMsg: gettext('Error') + }); + } + } else { + this.setState({ + isLoading: false, + errorMsg: gettext('Please check the network.') + }); + } + }); + } + + clickFolderPath = (folderPath, e) => { + e.preventDefault(); + this.renderFolder(folderPath); + } + + renderPath = () => { + const path = this.state.folderPath; + const pathList = path.split('/'); + + if (path == '/') { + return repoName; + } + + return ( + + {repoName} + / + {pathList.map((item, index) => { + if (index > 0 && index != pathList.length - 1) { + return ( + + {pathList[index]} + / + + ); + } + } + )} + {pathList[pathList.length - 1]} + + ); + } + + restoreRepo = () => { + seafileAPI.revertRepo(repoID, commitID).then((res) => { + this.toggleDialog(); + toaster.success(gettext('Successfully restored the library.')); + }).catch((error) => { + let errorMsg = ''; + if (error.response) { + if (error.response.data && error.response.data['error_msg']) { + errorMsg = error.response.data['error_msg']; + } else { + errorMsg = gettext('Error'); + } + } else { + errorMsg = gettext('Please check the network.'); + } + this.toggleDialog(); + toaster.danger(errorMsg); + }); + } + + render() { + const { isConfirmDialogOpen, folderPath } = this.state; + + return ( + +
+
+ + logo + + +
+
+
+
+

(${commitTime})`}}>

+ + + + {folderPath == '/' && ( +
+

{commitDesc}

+
+ {showAuthor ? ( + + + {authorNickName} + + ) : {gettext('Unknown')}} +

+
+
+ )} +
+

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

+ {(folderPath == '/' && isRepoOwner) && + + } +
+ +
+
+
+
+ {isConfirmDialogOpen && + + + + } +
+ ); + } +} + +class Content extends React.Component { + + constructor(props) { + super(props); + this.theadData = [ + {width: '5%', text: ''}, + {width: '55%', text: gettext('Name')}, + {width: '20%', text: gettext('Size')}, + {width: '20%', text: ''} + ]; + } + + render() { + const { isLoading, errorMsg, folderPath, folderItems } = this.props.data; + + if (isLoading) { + return ; + } + + if (errorMsg) { + return

{errorMsg}

; + } + + return ( + + + + {this.theadData.map((item, index) => { + return ; + })} + + + + {folderItems.map((item, index) => { + return ; + }) + } + +
{item.text}
+ ); + } +} + +class FolderItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + isIconShown: false + }; + } + + handleMouseOver = () => { + this.setState({isIconShown: true}); + } + + handleMouseOut = () => { + this.setState({isIconShown: false}); + } + + restoreItem = (e) => { + e.preventDefault(); + + const item = this.props.item; + const path = Utils.joinPath(this.props.folderPath, item.name); + const request = item.type == 'dir' ? + seafileAPI.revertFolder(repoID, path, commitID): + seafileAPI.revertFile(repoID, path, commitID); + request.then((res) => { + toaster.success(gettext('Successfully restored 1 item.')); + }).catch((error) => { + let errorMsg = ''; + if (error.response) { + errorMsg = error.response.data.error_msg || gettext('Error'); + } else { + errorMsg = gettext('Please check the network.'); + } + toaster.danger(errorMsg); + }); + } + + renderFolder = (e) => { + e.preventDefault(); + + const item = this.props.item; + const { folderPath } = this.props; + this.props.renderFolder(Utils.joinPath(folderPath, item.name)); + } + + render() { + const item = this.props.item; + const { isIconShown } = this.state; + const { folderPath } = this.props; + + return item.type == 'dir' ? ( + + {gettext('Directory')} + {item.name} + + + + + + ) : ( + + {gettext('File')} + {item.name} + {Utils.bytesToSize(item.size)} + + + + + + ); + } +} + +ReactDOM.render( + , + document.getElementById('wrapper') +); diff --git a/seahub/templates/repo_snapshot_react.html b/seahub/templates/repo_snapshot_react.html new file mode 100644 index 0000000000..ffd22535a1 --- /dev/null +++ b/seahub/templates/repo_snapshot_react.html @@ -0,0 +1,34 @@ +{% extends 'base_for_react.html' %} +{% load seahub_tags i18n avatar_tags %} +{% load render_bundle from webpack_loader %} + +{% block sub_title %}{% trans "Snapshot" %} - {% endblock %} + +{% block extra_style %} +{% render_bundle 'repoSnapshot' 'css' %} +{% endblock %} + +{% block extra_script %} + +{% render_bundle 'repoSnapshot' 'js' %} +{% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index 56ece28702..6fbf20a91d 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -14,7 +14,7 @@ from seahub.views.file import view_history_file, view_trash_file,\ text_diff, view_raw_file, download_file, view_lib_file, \ file_access, view_lib_file_via_smart_link, view_media_file_via_share_link, \ view_media_file_via_public_wiki -from seahub.views.repo import repo_history_view, view_shared_dir, \ +from seahub.views.repo import repo_history_view, repo_snapshot, view_shared_dir, \ view_shared_upload_link, view_lib_as_wiki from notifications.views import notification_list from seahub.views.wiki import personal_wiki, personal_wiki_pages, \ @@ -171,6 +171,7 @@ urlpatterns = [ url(r'^repo/text_diff/(?P[-0-9a-f]{36})/$', text_diff, name='text_diff'), url(r'^repo/history/(?P[-0-9a-f]{36})/$', repo_history, name='repo_history'), url(r'^repo/history/view/(?P[-0-9a-f]{36})/$', repo_history_view, name='repo_history_view'), + url(r'^repo/(?P[-0-9a-f]{36})/snapshot/$', repo_snapshot, name="repo_snapshot"), url(r'^repo/recycle/(?P[-0-9a-f]{36})/$', repo_recycle_view, name='repo_recycle_view'), url(r'^dir/recycle/(?P[-0-9a-f]{36})/$', dir_recycle_view, name='dir_recycle_view'), url(r'^repo/(?P[-0-9a-f]{36})/trash/$', repo_folder_trash, name="repo_folder_trash"), diff --git a/seahub/views/repo.py b/seahub/views/repo.py index e7908c6531..027cc54ad3 100644 --- a/seahub/views/repo.py +++ b/seahub/views/repo.py @@ -153,6 +153,50 @@ def repo_history_view(request, repo_id): 'referer': referer, }) +@login_required +def repo_snapshot(request, repo_id): + """View repo in history. + """ + repo = get_repo(repo_id) + if not repo: + raise Http404 + + username = request.user.username + user_perm = check_folder_permission(request, repo.id, '/') + if user_perm is None: + return render_error(request, _(u'Permission denied')) + + try: + server_crypto = UserOptions.objects.is_server_crypto(username) + except CryptoOptionNotSetError: + # Assume server_crypto is ``False`` if this option is not set. + server_crypto = False + + reverse_url = reverse('lib_view', args=[repo_id, repo.name, '']) + if repo.encrypted and \ + (repo.enc_version == 1 or (repo.enc_version == 2 and server_crypto)) \ + and not is_password_set(repo.id, username): + return render(request, 'decrypt_repo_form.html', { + 'repo': repo, + 'next': get_next_url_from_request(request) or reverse_url, + }) + + commit_id = request.GET.get('commit_id', None) + if commit_id is None: + return HttpResponseRedirect(reverse_url) + current_commit = get_commit(repo.id, repo.version, commit_id) + if not current_commit: + current_commit = get_commit(repo.id, repo.version, repo.head_cmmt_id) + + repo_owner = seafile_api.get_repo_owner(repo.id) + is_repo_owner = True if username == repo_owner else False + + return render(request, 'repo_snapshot_react.html', { + 'repo': repo, + "is_repo_owner": is_repo_owner, + 'current_commit': current_commit, + }) + @login_required def view_lib_as_wiki(request, repo_id, path):