diff --git a/docker-compose.yml b/docker-compose.yml index 02cdfbd9a..6ebe77582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '2' services: mysql: image: 'mysql:5.7' - restart: always + restart: unless-stopped volumes: - './dump/mysql:/dump' - mysqldata:/var/lib/mysql @@ -15,14 +15,14 @@ services: redis: image: 'redis:6.0.5-alpine' - restart: always + restart: unless-stopped command: ["redis-server", "--save 900 1", "--save 300 10", "--save 60 10000"] volumes: - ./dump/redis:/data elasticsearch: image: 'elasticsearch:7.4.0' - restart: always + restart: unless-stopped environment: - xpack.security.enabled=false - discovery.type=single-node @@ -42,7 +42,7 @@ services: build: dockerfile: 'docker/php/Dockerfile' context: '.' - restart: always + restart: unless-stopped depends_on: - redis - mysql @@ -58,7 +58,7 @@ services: nginx: image: 'nginx:1.19.1-alpine' - restart: always + restart: unless-stopped ports: - "127.0.0.1:${APP_PORT}:8080" links: diff --git a/www/.eslintrc b/www/.eslintrc index 9f0369133..e911e5f1e 100644 --- a/www/.eslintrc +++ b/www/.eslintrc @@ -76,6 +76,7 @@ "XMLHttpRequest": true, "ActiveXObject": true, "FileReader": true, - "process": true + "process": true, + "setTimeout": true } -} \ No newline at end of file +} diff --git a/www/application/classes/Controller/Search.php b/www/application/classes/Controller/Search.php index 9713cdddd..e99fd7b13 100644 --- a/www/application/classes/Controller/Search.php +++ b/www/application/classes/Controller/Search.php @@ -16,20 +16,51 @@ public function action_search() * Perform search for specified phrase using *query* pattern */ $query = htmlspecialchars(Arr::get($_GET, 'query', '')); - $response = $this->elastic->searchByField( - Model_Page::ELASTIC_TYPE, - self::MAX_SEARCH_RESULTS, - Model_Page::ELASTIC_SEARCH_FIELD, - $query - ); - /** - * Return Model_Page[] to user - */ - $result = array_map(function ($item) { - return new Model_Page($item['_id']); - }, $response['hits']['hits']); + $response = []; + $success = 0; + $error = ""; + + try { + $response = $this->elastic->searchByField( + Model_Page::ELASTIC_TYPE, + self::MAX_SEARCH_RESULTS, + Model_Page::ELASTIC_SEARCH_FIELDS, + $query + ); + + $success = 1; + } catch (Exception $err) { + $error = $err->getMessage(); + } + + if ($success) { + /** + * Return pages search result to user + */ + $result = array_map(function ($item) { + return new Model_Page($item['_id']); + }, $response['hits']['hits']); + + /** + * Sort by date: display newest first + */ + usort($result, function($first, $second){ + return $first->date < $second->date; + }); + + $search_result['html'] = View::factory( + 'templates/pages/list', + array( + 'pages' => $result, + 'active_tab' => Model_Feed_Pages::ALL, + 'emptyListMessage' => 'Ничего не найдено' + ) + )->render(); + } else { + $search_result['error'] = $error; + } - $this->response->body(@json_encode($result)); + $this->response->body(@json_encode($search_result)); } } diff --git a/www/application/classes/Model/Elastic.php b/www/application/classes/Model/Elastic.php index f31043d9e..dcc55d451 100644 --- a/www/application/classes/Model/Elastic.php +++ b/www/application/classes/Model/Elastic.php @@ -56,12 +56,12 @@ public function get($type, $id) /** * @param $type - entity type (table in elastic db) * @param $size - maximum search results to return - * @param $field - in what entity field to search + * @param $fields - in what entity fields to search * @param $query - what occurrence to search * * @return array - search result */ - public function searchByField($type, $size, $field, $query) + public function searchByField($type, $size, $fields, $query) { return $this->client->search( [ @@ -70,8 +70,9 @@ public function searchByField($type, $size, $field, $query) 'type' => $type, 'body' => [ 'query' => [ - 'match' => [ - $field => '*' . $query . '*' + 'simple_query_string' => [ + 'query' => $query . '*', + 'fields' => $fields ] ] ] diff --git a/www/application/classes/Model/Page.php b/www/application/classes/Model/Page.php index 4916955cd..fd83fc43b 100644 --- a/www/application/classes/Model/Page.php +++ b/www/application/classes/Model/Page.php @@ -84,7 +84,7 @@ class Model_Page extends Model /** * Field in elastic db used to store searchable page content: paragraphs, headings and lists */ - const ELASTIC_SEARCH_FIELD = 'text'; + const ELASTIC_SEARCH_FIELDS = ['text', 'title']; private $modelCacheKey; diff --git a/www/application/views/main.php b/www/application/views/main.php index 203b71735..658f631d1 100644 --- a/www/application/views/main.php +++ b/www/application/views/main.php @@ -1,35 +1,35 @@ - - - - - - - - - - - - - - +<!DOCTYPE html> +<html> +<head> + + <meta http-equiv="Content-Type" content="text/html;charset=utf-8"> + <meta name="language" content="<?= I18n::$lang ?>"> + + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> + <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-status-bar-style" content="black"> + <meta name="format-detection" content="telephone=no"> + <meta property="og:site_name" content="<?= Arr::get($site_info, 'title', 'CodeX Media') ?>" /> + + <title> <?= $title ?: Arr::get($site_info, 'title', 'CodeX Media') . ': ' . Arr::get($site_info, 'description', '') ?> - - - - - - - - - + + + + + + + + + - - + + - - - - + + + + - - + + render(); ?> - + render(); ?> + render(); ?> - + render(); ?> - -
- -
- + +
+ +
+ -
+
render(); ?> -
- +
+ -
+
-
- +
+ render(); ?> - -
- -
- - - - + +
+ +
+ + + + - + - - - + + + - + - + - + - - - - + + + + - + - - - + + + diff --git a/www/application/views/templates/components/esir_navigator.php b/www/application/views/templates/components/esir_navigator.php index ef8765c51..ab7f12924 100644 --- a/www/application/views/templates/components/esir_navigator.php +++ b/www/application/views/templates/components/esir_navigator.php @@ -1,4 +1,18 @@ -
+
+
Сайт включен в каталог ЕСИР
diff --git a/www/application/views/templates/components/search.php b/www/application/views/templates/components/search.php new file mode 100644 index 000000000..61c72c54f --- /dev/null +++ b/www/application/views/templates/components/search.php @@ -0,0 +1,17 @@ + diff --git a/www/public/app/css/components/search-modal.css b/www/public/app/css/components/search-modal.css new file mode 100644 index 000000000..9e0613393 --- /dev/null +++ b/www/public/app/css/components/search-modal.css @@ -0,0 +1,159 @@ +:root { + --overlay-bg: rgba(0, 0, 0, .7); + --placeholder-color: #6e758a; + --loader-color: #ccc; + --blue-gray: #7b8999; +} + +.search-modal { + position: absolute; + box-sizing: border-box; + z-index: 6; + top: 143px; + left: 50%; + transform: translateX(-50%); + background: #eef0f5; + font-size: 15.5px; + padding: 10px; + border-radius: 8px; + width: 670px; + + &__results { + &-placeholder { + padding: 21px 14px; + color: var(--placeholder-color); + } + + &-data { + margin-top: 10px; + min-height: 185px; + max-height: 315px; + overflow-y: scroll; + } + } + + &__results { + &-placeholder { + display: none; + } + } + + + &--with-results { + .post-list-item { + border-radius: 8px; + + &:last-of-type { + margin-bottom: 0; + } + } + } +} + +&--search-in-progress { + &.loading { + opacity: 0.5; + position: relative; + overflow: hidden; + } + + .loader { + &::before { + content: ''; + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 60px; + height: 60px; + margin-top: -30px; + margin-left: -30px; + border-radius: 50%; + border: 3px solid var(--loader-color); + border-top-color: var(--blue-gray); + animation: spinner 0.6s linear infinite; + } + } + + @keyframes spinner { + to { + transform: rotate(360deg); + } + } +} + +&--ready-for-search { + & input[type='search'] { + height: 56px; + border-radius: 8px; + background: var(--color-white); + text-indent: 42px; + + &::placeholder { + color: transparent; + text-shadow: 0 0 0 var(--color-gray); + } + } + + ^&__results { + &-placeholder { + display: block; + } + } + + ^ &__input-wrapper { + display: flex; + position: relative; + } + + label { + display: block; + background: url(/public/app/svg/search.svg) no-repeat; + height: 16px; + line-height: 16px; + width: 16px; + background-size: 16px; + color: var(--blue-gray); + position: absolute; + left: 21px; + top: 50%; + transform: translateY(-50%); + } + +} + +&-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--overlay-bg); + z-index: 5; +} + +&__exit { + &-button { + position: absolute; + left: 100%; + margin-left: 18px; + color: #e1e3eb; + text-align: center; + font-size: 12px; + font-weight: bold; + top: 7px; + cursor: pointer; + } + + &-icon { + display: block; + background: url(/public/app/svg/escape-cross.svg) no-repeat center; + border: 2px solid white; + border-radius: 50%; + width: 28px; + height: 28px; + margin-bottom: 10px; + } +} + +} diff --git a/www/public/app/js/main.js b/www/public/app/js/main.js index 304cebed9..82af81765 100644 --- a/www/public/app/js/main.js +++ b/www/public/app/js/main.js @@ -154,4 +154,8 @@ codex.layout = require('./modules/layout'); codex.pageTypeSelector = require('./modules/pageTypeSelector'); codex.datePicker = require('./modules/datePicker'); -module.exports = codex; \ No newline at end of file +const SearchModule = require('./modules/search').default; + +codex.search = new SearchModule(); + +module.exports = codex; diff --git a/www/public/app/js/modules/core.js b/www/public/app/js/modules/core.js index 3b61a9bf5..6bc535089 100644 --- a/www/public/app/js/modules/core.js +++ b/www/public/app/js/modules/core.js @@ -316,4 +316,47 @@ module.exports = { }, + /** + * Debounce method + * Call method after passed time + * + * @param {Function} func - function which call is delayed + * @param {Number} wait - time in milliseconds + * @param {Boolean} immediate - call now + * @return {Function} + */ + debounce: function (func, wait, immediate) { + + let timeout; + + return function () { + + let context = this, + args = arguments; + + let later = function () { + + timeout = null; + if (!immediate) { + + func.apply(context, args); + + } + + }; + + let callNow = immediate && !timeout; + + window.clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + if (callNow) { + + func.apply(context, args); + + } + + }; + + } + }; diff --git a/www/public/app/js/modules/search.js b/www/public/app/js/modules/search.js new file mode 100644 index 000000000..02954fcff --- /dev/null +++ b/www/public/app/js/modules/search.js @@ -0,0 +1,177 @@ +const ajax = require('@codexteam/ajax'); + +/** + * Allow text search starting from the following input length + */ +const MIN_SEARCH_LENGTH = 3; +/** + * Search debounce timeout — prevents from sending search requests during user input + */ +const SEARCH_TIMEOUT = 500; + +const OVERFLOW = { + HIDDEN: 'hidden', + AUTO: 'auto' +}; + +const CSS = { + loader: 'loader' +}; + +/** + * This allows the user to perform a text search on the site's articles. + */ +export default class Search { + + constructor() { + + /** + * DOM elements involved in search process + */ + this.elements = { + holder: null, + modal: null, + placeholder: null, + input: null, + searchResults: null, + closer: null, + loader: null, + }; + + } + + /** + * Prepare DOM elements to work with + * @param elementId - search wrapper id + * @param modalId - modal id + * @param closerId - modal close button id + * @param inputId - search input id + * @param resultsId - search results element id + * @param placeholderId - search placeholder id + */ + init({elementId, modalId, closerId, inputId, resultsId, placeholderId}) { + + this.elements.holder = document.getElementById(elementId); + this.elements.modal = document.getElementById(modalId); + this.elements.closer = document.getElementById(closerId); + this.elements.input = document.getElementById(inputId); + this.elements.searchResults = document.getElementById(resultsId); + this.elements.placeholder = document.getElementById(placeholderId); + this.elements.loader = this.makeElement('div', CSS.loader); + + } + + toggleOverflow() { + + const currentValue = document.body.style.overflow; + + document.body.style.overflow = (currentValue === OVERFLOW.HIDDEN) ? OVERFLOW.AUTO : OVERFLOW.HIDDEN; + + } + + addListeners() { + + const delayedSearch = codex.core.debounce( + (value) => this.search(value), SEARCH_TIMEOUT, false + ); + + this.elements.input && this.elements.input.addEventListener( + 'input', (event) => delayedSearch(event.target.value) + ); + + this.elements.closer && this.elements.closer.addEventListener('click', () => this.hide()); + + } + + /** + * Reveals search modal to user & sets up event listeners + */ + show() { + + this.elements.holder && this.elements.holder.removeAttribute('hidden'); + this.toggleOverflow(); + this.addListeners(); + + }; + + makeElement(tag, classes, attributes = {}) { + + const element = document.createElement(tag); + + if (Array.isArray(classes)) { + + element.classList.add(...classes); + + } else { + + element.classList.add(classes); + + } + + for (let key in attributes) { + + element.setAttribute(key, attributes[key]); + + } + + return element; + + } + + /** + * Hide modal and reset related DOM elements appearance + */ + hide() { + + this.elements.holder.setAttribute('hidden', true); + this.toggleOverflow(); + + this.elements.modal.class = 'search-modal'; + this.elements.input.value = ''; + + }; + + /** + * Perform search on user input + * @param value - input string to search for + */ + search(value) { + + /** + * Don't search if input is too short + */ + if (value.length < MIN_SEARCH_LENGTH) { + + return; + + } + + /** + * Adjust related DOM elements appearance + */ + this.elements.searchResults.appendChild(this.elements.loader); + this.elements.modal.classList.add('search-modal--search-in-progress'); + + ajax.get({ + url: '/search', + data: { + query: value + }, + type: ajax.contentType.FORM + }).then(response => { + + /** + * Show search results to user + */ + if (response.body['html']) { + + this.elements.searchResults.innerHTML = response.body['html']; + this.elements.modal.class = 'search-modal search-modal--search-with-results'; + + } + + }); + + }; + +} diff --git a/www/public/app/svg/escape-cross.svg b/www/public/app/svg/escape-cross.svg new file mode 100644 index 000000000..86074453d --- /dev/null +++ b/www/public/app/svg/escape-cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/www/public/app/svg/search.svg b/www/public/app/svg/search.svg index e424187d4..5461a0e47 100644 --- a/www/public/app/svg/search.svg +++ b/www/public/app/svg/search.svg @@ -1,12 +1,3 @@ - - - Created with Sketch. - - - - - - - - - \ No newline at end of file + + +