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 (
+
+
+
+
+
+
+
(${commitTime})`}}>
+
+
+
+ {folderPath == '/' && (
+
+ )}
+
+
{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 {item.text} | ;
+ })}
+
+
+
+ {folderItems.map((item, index) => {
+ return ;
+ })
+ }
+
+
+ );
+ }
+}
+
+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' ? (
+
+ }) |
+ {item.name} |
+ |
+
+
+ |
+
+ ) : (
+
+ }) |
+ {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):