diff --git a/frontend/config/webpack.config.dev.js b/frontend/config/webpack.config.dev.js index b15617522c..324c862b4d 100644 --- a/frontend/config/webpack.config.dev.js +++ b/frontend/config/webpack.config.dev.js @@ -218,6 +218,11 @@ module.exports = { require.resolve('./polyfills'), require.resolve('react-dev-utils/webpackHotDevClient'), paths.appSrc + "/view-file-cdoc.js", + ], + search: [ + require.resolve('./polyfills'), + require.resolve('react-dev-utils/webpackHotDevClient'), + paths.appSrc + "/pages/search", ] }, diff --git a/frontend/config/webpack.config.prod.js b/frontend/config/webpack.config.prod.js index a41ff61334..3e53ec13d1 100644 --- a/frontend/config/webpack.config.prod.js +++ b/frontend/config/webpack.config.prod.js @@ -91,7 +91,8 @@ module.exports = { orgAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/org-admin"], sysAdmin: [require.resolve('./polyfills'), paths.appSrc + "/pages/sys-admin"], viewDataGrid: [require.resolve('./polyfills'), paths.appSrc + "/view-file-ctable.js"], - viewCdoc: [require.resolve('./polyfills'), paths.appSrc + "/view-file-cdoc.js"] + viewCdoc: [require.resolve('./polyfills'), paths.appSrc + "/view-file-cdoc.js"], + search: [require.resolve('./polyfills'), paths.appSrc + "/pages/search"] }, output: { diff --git a/frontend/src/css/search.css b/frontend/src/css/search.css index 6ed899ffa5..a70a6dedac 100644 --- a/frontend/src/css/search.css +++ b/frontend/src/css/search.css @@ -97,6 +97,82 @@ font-weight: bold; } +.main-panel-south { + flex: auto; + overflow: auto; + height: calc(100% - 50px); +} +.search-page { + margin: 30px auto; + width: 65%; +} +.search-page .search-result-container { + border-radius: 0; + box-shadow: none; +} +.search-page .search-page-container { + padding: 10px; + background: #f7f7f8; +} +.search-page .search-page-container .search-input { + padding-left: 0.5rem; + width: 30rem; +} +.search-page .search-page-container .fa-angle-double-up, +.search-page .search-page-container .fa-angle-double-down { + font-size: 1rem; +} +.search-page .advanced-search .search-file-types .search-input { + padding-left: 0.5rem; + width: 30rem; + max-width: 100%; +} +.search-page .search-page-container .search-icon-right { + left: 28rem; +} +.search-page .paginator { + text-align: center; + margin: 1rem 0; +} +.search-page .advanced-search, .search-page .search-filters { + color: #747474; +} +.search-page .search-filters { + padding: 10px 10px 0; +} +.search-page .advanced-search .search-repo, +.search-page .advanced-search .search-file-types { + padding: 5px 0; +} +.search-file-types .search-file-types-form { + top: 10px; +} +.search-page .advanced-search .search-catalog { + margin: 0 10px; + border-top: 1px dashed #e2e2e2; + padding: 10px 0; +} +.search-page .advanced-search .search-catalog:first-child { + border: none; +} +.search-page .custom-checkbox .custom-control-input:checked ~ .custom-control-label::before { + background-color: #3B88FD; +} +.search-date .ant-input { + height: 2.375rem; + padding: 0.375rem 0.75rem; + line-height: 1.6; + border: 1px solid rgba(0, 40, 100, 0.12); + border-radius: 3px; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +.search-date .select-data-icon { + position: absolute; + right: 1.5rem; + top: 0.5rem; + color: #b2b2b2; +} + @media (max-width: 767px) { .common-toolbar .search { margin: 0; @@ -137,4 +213,26 @@ left: auto; width: 20rem; } + + .search-page { + margin: 0; + width: 100%; + height: 100%; + } + + .search-page .search-page-container .search-input { + box-shadow: none; + width: 95% !important; + } + + .search-page .search-page-container .search-icon-right { + left: 85%; + } + + .search-page .search-result-container { + top: 0; + left: 0; + width: 100%; + padding: 10px; + } } diff --git a/frontend/src/pages/search/advanced-search.js b/frontend/src/pages/search/advanced-search.js new file mode 100644 index 0000000000..9028046d89 --- /dev/null +++ b/frontend/src/pages/search/advanced-search.js @@ -0,0 +1,258 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import MediaQuery from 'react-responsive'; +import { gettext, lang } from '../../utils/constants'; +import { Button, Col, Collapse, CustomInput, FormGroup, Input, Label, Row, InputGroupAddon, InputGroup } from 'reactstrap'; +import SelectDate from '@seafile/seafile-editor/dist/components/calander'; + +const { repo_name, search_repo } = window.search.pageOptions; + +class AdvancedSearch extends React.Component { + + constructor(props) { + super(props); + } + + getFileTypesList = (fileTypes) => { + const fileTypeItems = [gettext('Text'), gettext('Document'), gettext('Image'), gettext('Video'), gettext('Audio'), 'PDF', 'Markdown']; + let ftype = []; + for (let i = 0, len = fileTypes.length; i < len; i++) { + if (fileTypes[i]) { + ftype.push(fileTypeItems[i]); + } + } + return ftype; + }; + + render() { + const { stateAndValues } = this.props; + const { errorDateMsg, errorSizeMsg } = stateAndValues; + + if (stateAndValues.isShowSearchFilter) { + const { size_from, size_to, time_from, time_to, search_repo, fileTypeItemsStatus } = stateAndValues; + const fileTypes = this.getFileTypesList(fileTypeItemsStatus); + const typesLength = fileTypes.length; + return ( +
+ {search_repo && {gettext('Libraries')}{': '}{search_repo}} + {typesLength > 0 && + {gettext('File Types')}{': '} + {fileTypes.map((type, index) => { + return {type}{index !== (typesLength - 1) && ','}{' '}; + })} + + } + {(time_from && time_to ) && + {gettext('Last Update')}{': '}{time_from}{' '}{gettext('to')}{' '}{time_to} + } + {(size_from && size_to) && + {gettext('Size')}{': '}{size_from}{'MB - '}{size_to}{'MB'} + } +
+ ); + } + else { + return ( +
+ + + {search_repo !== 'all' && +
+ + {gettext('Libraries')}{': '} + + + + + + + + + + + +
+ } + +
+ + {gettext('File types')}{': '} + + + + + + + + + + + + + + + + + + + this.props.handlerFileTypes(0)} + checked={stateAndValues.fileTypeItemsStatus[0]}/> + this.props.handlerFileTypes(1)} + checked={stateAndValues.fileTypeItemsStatus[1]}/> + this.props.handlerFileTypes(2)} + checked={stateAndValues.fileTypeItemsStatus[2]}/> + this.props.handlerFileTypes(3)} + checked={stateAndValues.fileTypeItemsStatus[3]}/> + this.props.handlerFileTypes(4)} + checked={stateAndValues.fileTypeItemsStatus[4]}/> + this.props.handlerFileTypes(5)} + checked={stateAndValues.fileTypeItemsStatus[5]}/> + this.props.handlerFileTypes(6)} + checked={stateAndValues.fileTypeItemsStatus[6]}/> + + + + + + +
+ +
+ + {gettext('Last Update')}{': '} + + + + +
-
+ + + + +
+ {errorDateMsg && {errorDateMsg}} +
+ +
+ + {gettext('Size')}{': '} + + + + + {'MB'} + + + + {errorSizeMsg &&
{errorSizeMsg}
} + + +
+ +
-
+ + + + + {'MB'} + + + +
+
+ + {errorSizeMsg &&
{errorSizeMsg}
} + + +
+
+
+ ); + } + } +} + +const advancedSearchPropTypes = { + openFileTypeCollapse: PropTypes.func.isRequired, + closeFileTypeCollapse: PropTypes.func.isRequired, + handlerFileTypes: PropTypes.func.isRequired, + handlerFileTypesInput: PropTypes.func.isRequired, + handleSubmit: PropTypes.func.isRequired, + handleReset: PropTypes.func.isRequired, + handlerRepo: PropTypes.func.isRequired, + handleKeyDown: PropTypes.func.isRequired, + handleTimeFromInput: PropTypes.func.isRequired, + handleTimeToInput: PropTypes.func.isRequired, + handleSizeFromInput: PropTypes.func.isRequired, + handleSizeToInput: PropTypes.func.isRequired, + stateAndValues: PropTypes.object.isRequired, +}; + +AdvancedSearch.propTypes = advancedSearchPropTypes; + +export default AdvancedSearch; diff --git a/frontend/src/pages/search/index.js b/frontend/src/pages/search/index.js new file mode 100644 index 0000000000..c7ce73025e --- /dev/null +++ b/frontend/src/pages/search/index.js @@ -0,0 +1,45 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import CommonToolbar from '../../components/toolbar/common-toolbar'; +import Logo from '../../components/logo'; +import SearchViewPanel from './main-panel'; +import { siteRoot } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; + +import '../../css/layout.css'; +import '../../css/toolbar.css'; +import '../../css/search.css'; + +class SearchView extends React.Component { + + constructor(props) { + super(props); + } + + onSearchedClick = (selectedItem) => { + let url = selectedItem.is_dir ? + siteRoot + 'library/' + selectedItem.repo_id + '/' + selectedItem.repo_name + selectedItem.path : + siteRoot + 'lib/' + selectedItem.repo_id + '/file' + Utils.encodePath(selectedItem.path); + let newWindow = window.open('about:blank'); + newWindow.location.href = url; + }; + + render() { + return ( +
+
+ + +
+
+ +
+
+ ); + } +} + +ReactDOM.render( + , + document.getElementById('wrapper') +); diff --git a/frontend/src/pages/search/main-panel.js b/frontend/src/pages/search/main-panel.js new file mode 100644 index 0000000000..055c75e2b3 --- /dev/null +++ b/frontend/src/pages/search/main-panel.js @@ -0,0 +1,338 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { gettext } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import SearchResults from './search-results'; +import AdvancedSearch from './advanced-search'; +import toaster from '../../components/toast'; +import Loading from '../../components/loading'; + +import '../../css/search.css'; + +const _ = require('lodash'); +const { q, repo_name, search_repo, search_ftypes } = window.search.pageOptions; + +class SearchViewPanel extends React.Component { + + constructor(props) { + super(props); + this.stateHistory = null; + this.state = { + isCollapseOpen: search_repo !== 'all', + isFileTypeCollapseOpen: false, + isResultGot: false, + isLoading: true, + isAllRepoCheck: search_repo === 'all', + isShowSearchFilter: false, + // advanced search index + q: q.trim(), + search_repo: search_repo, + search_ftypes: search_ftypes, + fileTypeItemsStatus: [false, false, false, false, false, false, false], + input_fexts: '', + time_from: '', + time_to: '', + size_from: '', + size_to: '', + // search result + hasMore: false, + resultItems: [], + page: 1, + per_page: 20, + errorMsg: '', + errorDateMsg: '', + errorSizeMsg: '', + }; + } + + getSearchResults(params) { + this.setState({ + isLoading: true, + isResultGot: false, + }); + const stateHistory = _.cloneDeep(this.state); + seafileAPI.searchFiles(params, null).then(res => { + this.setState({ + isLoading: false, + isResultGot: true, + resultItems: res.data.results, + hasMore: res.data.has_more, + page: params.page, + isShowSearchFilter: true, + }); + this.stateHistory = stateHistory; + this.stateHistory.resultItems = res.data.results; + this.stateHistory.hasMore = res.data.has_more; + this.stateHistory.page = params.page; + }).catch((error) => { + this.setState({ isLoading: false }); + if (error.response) { + toaster.danger(error.response.data.detail || error.response.data.error_msg || gettext('Error'), {duration: 3}); + } else { + toaster.danger(gettext('Please check the network.'), {duration: 3}); + } + }); + } + + handleSearchParams = (page) => { + let params = { q: this.state.q.trim(), page: page }; + const ftype = this.getFileTypesList(); + if (this.state.search_repo) {params.search_repo = this.state.search_repo;} + if (this.state.search_ftypes) {params.search_ftypes = this.state.search_ftypes;} + if (this.state.per_page) {params.per_page = this.state.per_page;} + if (this.state.input_fexts) {params.input_fexts = this.state.input_fexts;} + if (this.state.time_from) {params.time_from = moment(this.state.time_from).valueOf() / 1000;} + if (this.state.time_to) {params.time_to = moment(this.state.time_to).valueOf() / 1000;} + if (this.state.size_from) {params.size_from = this.state.size_from * 1000 *1000;} + if (this.state.size_to) {params.size_to = this.state.size_to * 1000 * 1000;} + if (ftype.length !== 0) {params.ftype = ftype;} + return params; + }; + + handleSubmit = () => { + if (this.compareNumber(this.state.time_from, this.state.time_to)) { + this.setState({ errorDateMsg: gettext('Start date should be earlier than end date.') }); + return; + } + if (this.compareNumber(this.state.size_from, this.state.size_to)) { + this.setState({ errorSizeMsg: gettext('Invalid file size range.') }); + return; + } + if (this.getValueLength(this.state.q.trim()) < 3) { + if (this.state.q.trim().length === 0) { + this.setState({ errorMsg: gettext('It is required.') }); + } else { + this.setState({ errorMsg: gettext('Required at least three letters.') }); + } + if (this.state.isLoading) { + this.setState({ isLoading: false }); + } + } else { + const params = this.handleSearchParams(1); + this.getSearchResults(params); + } + }; + + compareNumber = (num1, num2) => { + if (!num1 || !num2) return false; + if (parseInt(num1.replace(/\-/g, '')) >= parseInt(num2.replace(/\-/g, ''))) { + return true; + } else { + return false; + } + } + + showSearchFilter = () => { + this.setState({ isShowSearchFilter: true }); + } + + hideSearchFilter = () => { + this.setState({ isShowSearchFilter: false }); + } + + handleReset = () => { + this.setState({ + q: q.trim(), + search_repo: search_repo, + search_ftypes: search_ftypes, + fileTypeItemsStatus: [false, false, false, false, false, false, false], + input_fexts: '', + time_from: '', + time_to: '', + size_from: '', + size_to: '', + errorMsg: '', + errorDateMsg: '', + errorSizeMsg: '', + }); + } + + handlePrevious = () => { + if (this.stateHistory && this.state.page > 1) { + this.setState(this.stateHistory,() => { + const params = this.handleSearchParams(this.state.page - 1); + this.getSearchResults(params); + }); + } else { + toaster.danger(gettext('Error'), {duration: 3}); + } + }; + + handleNext = () => { + if (this.stateHistory && this.state.hasMore) { + this.setState(this.stateHistory,() => { + const params = this.handleSearchParams(this.state.page + 1); + this.getSearchResults(params); + }); + } else { + toaster.danger(gettext('Error'), {duration: 3}); + } + }; + + getValueLength(str) { + let code, len = 0; + for (let i = 0, length = str.length; i < length; i++) { + code = str.charCodeAt(i); + if (code === 10) { //solve enter problem + len += 2; + } else if (code < 0x007f) { + len += 1; + } else if (code >= 0x0080 && code <= 0x07ff) { + len += 2; + } else if (code >= 0x0800 && code <= 0xffff) { + len += 3; + } + } + return len; + } + + toggleCollapse = () => { + this.setState({isCollapseOpen: !this.state.isCollapseOpen}); + this.hideSearchFilter(); + }; + + openFileTypeCollapse = () => { + this.setState({ + isFileTypeCollapseOpen: true, + search_ftypes: 'custom', + }); + }; + + closeFileTypeCollapse = () => { + this.setState({ + isFileTypeCollapseOpen: false, + fileTypeItemsStatus: Array(7).fill(false), + search_ftypes: 'all', + input_fexts: '', + }); + }; + + handleSearchInput = (event) => { + this.setState({ q: event.target.value }); + if (this.state.errorMsg) this.setState({ errorMsg: ''}); + if (this.state.errorSizeMsg) this.setState({ errorSizeMsg: '' }); + if (this.state.errorDateMsg) this.setState({ errorDateMsg: '' }); + }; + + handleKeyDown = (e) => { + if (e.keyCode === 13) { + e.preventDefault(); + this.handleSubmit(); + } + }; + + handlerRepo = (isAll) => { + if (isAll) { + this.setState({ + isAllRepoCheck: true, + search_repo: 'all', + }); + } else { + this.setState({ + isAllRepoCheck: false, + search_repo: search_repo !== 'all' ? search_repo : '', + }); + } + }; + + handlerFileTypes = (i) => { + let newFileTypeItemsStatus = this.state.fileTypeItemsStatus; + newFileTypeItemsStatus[i] = !this.state.fileTypeItemsStatus[i]; + this.setState({ fileTypeItemsStatus: newFileTypeItemsStatus }); + }; + + getFileTypesList = () => { + const fileTypeItems = ['Text', 'Document', 'Image', 'Video', 'Audio', 'PDF', 'Markdown']; + let ftype = []; + for (let i = 0, len = this.state.fileTypeItemsStatus.length; i < len; i++){ + if (this.state.fileTypeItemsStatus[i]) { + ftype.push(fileTypeItems[i]); + } + } + return ftype; + }; + + handlerFileTypesInput = (event) => { + this.setState({ input_fexts: event.target.value.trim() }); + }; + + handleTimeFromInput = (value) => { + this.setState({ time_from: value }); + if (this.state.errorDateMsg) this.setState({ errorDateMsg: '' }); + }; + + handleTimeToInput = (value) => { + this.setState({ time_to: value }); + if (this.state.errorDateMsg) this.setState({ errorDateMsg: '' }); + }; + + handleSizeFromInput = (event) => { + this.setState({ size_from: event.target.value >= 0 ? event.target.value : 0 }); + if (this.state.errorSizeMsg) this.setState({ errorSizeMsg: '' }); + }; + + handleSizeToInput = (event) => { + this.setState({ size_to: event.target.value >= 0 ? event.target.value : 0 }); + if (this.state.errorSizeMsg) this.setState({ errorSizeMsg: '' }); + }; + + componentDidMount() { + if (this.state.q) { + this.handleSubmit(); + } else { + this.setState({ isLoading: false }); + } + } + + render() { + let { isCollapseOpen } = this.state; + return ( +
+
+
+ + + +
+ {this.state.errorMsg &&
{this.state.errorMsg}
} + +
+ {this.state.isLoading && } + {(!this.state.isLoading && this.state.isResultGot) && } + {(!this.state.isLoading && this.state.isResultGot) && +
+ {this.state.page !== 1 && this.handlePrevious()}>{gettext('Previous')}} + {(this.state.page !== 1 && this.state.hasMore) && | } + {this.state.hasMore && this.handleNext()}>{gettext('Next')}} +
+ } +
+ ); + } +} + +export default SearchViewPanel; diff --git a/frontend/src/pages/search/search-results.js b/frontend/src/pages/search/search-results.js new file mode 100644 index 0000000000..57a37bbf20 --- /dev/null +++ b/frontend/src/pages/search/search-results.js @@ -0,0 +1,86 @@ +import React from 'react'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import { Utils } from '../../utils/utils'; +import { siteRoot, gettext } from '../../utils/constants'; + +class ResultsItem extends React.Component { + + constructor(props) { + super(props); + } + + handlerFileURL= (item) => { + return item.is_dir ? siteRoot + 'library/' + item.repo_id + '/' + item.repo_name + item.fullpath : + siteRoot + 'lib/' + item.repo_id + '/file' + Utils.encodePath(item.fullpath); + }; + + handlerParentDirPath= (item) => { + let index = item.is_dir ? item.fullpath.length - item.name.length - 1 : item.fullpath.length - item.name.length; + return item.fullpath.substring(0, index); + }; + + handlerParentDirURL= (item) => { + return siteRoot + 'library/' + item.repo_id + '/' + item.repo_name + this.handlerParentDirPath(item); + }; + + render() { + let item = this.props.item; + let linkContent = decodeURI(item.fullpath).substring(1); + let folderIconUrl = linkContent ? Utils.getFolderIconUrl(false, 192) : Utils.getDefaultLibIconUrl(true); + let fileIconUrl = item.is_dir ? folderIconUrl : Utils.getFileIconUrl(item.name, 192); + return ( +
  • + +
    +
    + {item.name} +
    +
    + {item.repo_name}{this.handlerParentDirPath(item)} +
    +
    + {Utils.bytesToSize(item.size) + ' ' + moment(item.last_modified * 1000).format('YYYY-MM-DD')} +
    +
    +
    +
  • + ); + } +} + +const resultsItemPropTypes = { + item: PropTypes.object.isRequired, +}; + +ResultsItem.propTypes = resultsItemPropTypes; + +class SearchResults extends React.Component { + + constructor(props) { + super(props); + } + + render() { + const { resultItems } = this.props; + const total = resultItems.length; + return ( +
    + +
    + ); + } +} + +const searchResultsPropTypes = { + resultItems: PropTypes.array.isRequired, +}; + +SearchResults.propTypes = searchResultsPropTypes; + +export default SearchResults;