From a431dc030587f47760e005d30576176d0e1f1163 Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Mon, 28 Nov 2016 12:52:26 -0700 Subject: [PATCH 01/11] ACP search updated to support translations --- package.json | 1 + .../en_GB/admin/appearance/themes.json | 6 + public/less/admin/admin.less | 1 + public/less/admin/modules/search.less | 36 ++++ public/src/admin/modules/search.js | 155 +++++++++--------- src/admin/search.js | 128 +++++++++++++++ src/socket.io/admin.js | 14 ++ src/views/admin/appearance/themes.tpl | 2 +- src/views/admin/partials/menu.tpl | 18 +- src/views/admin/partials/theme_list.tpl | 4 +- 10 files changed, 280 insertions(+), 85 deletions(-) create mode 100644 public/language/en_GB/admin/appearance/themes.json create mode 100644 public/less/admin/modules/search.less create mode 100644 src/admin/search.js diff --git a/package.json b/package.json index 7bcd0ce3da..78d0f582c9 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "request": "^2.44.0", "rimraf": "~2.5.0", "rss": "^1.0.0", + "sanitize-html": "^1.13.0", "semver": "^5.1.0", "serve-favicon": "^2.1.5", "sitemap": "^1.4.0", diff --git a/public/language/en_GB/admin/appearance/themes.json b/public/language/en_GB/admin/appearance/themes.json new file mode 100644 index 0000000000..3f1b10cf7f --- /dev/null +++ b/public/language/en_GB/admin/appearance/themes.json @@ -0,0 +1,6 @@ +{ + "checking-for-installed": "Checking for installed themes...", + "homepage": "Homepage", + "select-skin": "Select Skin", + "select-theme": "Select Theme" +} \ No newline at end of file diff --git a/public/less/admin/admin.less b/public/less/admin/admin.less index 0ceeb050e5..aa5efff256 100644 --- a/public/less/admin/admin.less +++ b/public/less/admin/admin.less @@ -29,6 +29,7 @@ @import "./modules/selectable"; @import "./modules/snackbar"; @import "./modules/nprogress"; +@import "./modules/search"; body { overflow-y: scroll; diff --git a/public/less/admin/modules/search.less b/public/less/admin/modules/search.less new file mode 100644 index 0000000000..381b940646 --- /dev/null +++ b/public/less/admin/modules/search.less @@ -0,0 +1,36 @@ +#acp-search { + .dropdown-menu { + max-height: 75vh; + overflow-y: auto; + } + + .state-start-typing { + .keep-typing, .search-forum, .no-results { + display: none; + } + } + + .state-keep-typing { + .start-typing, .search-forum, .no-results { + display: none; + } + } + + .state-no-results { + .keep-typing, .start-typing { + display: none; + } + } + + .state-yes-results { + .keep-typing, .start-typing, .no-results { + display: none; + } + } + + .search-disabled { + .search-forum { + display: none; + } + } +} \ No newline at end of file diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index f64a2490e7..0a5d3dde56 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -2,113 +2,106 @@ /*globals define, admin, ajaxify, RELATIVE_PATH*/ define(function () { - var search = {}, - searchIndex; + var search = {}; - search.init = function () { - $.getJSON(RELATIVE_PATH + '/templates/indexed.json', function (data) { - searchIndex = data; - for (var file in searchIndex) { - if (searchIndex.hasOwnProperty(file)) { - searchIndex[file] = searchIndex[file].replace(/' + searchIndex[file] + ''); - searchIndex[file].find('script').remove(); - - searchIndex[file] = searchIndex[file].text().toLowerCase().replace(/[ |\r|\n]+/g, ' '); - } - } + function nsToTitle(namespace) { + return namespace.replace('admin/', '').split('/').map(function (str) { + return str[0].toUpperCase() + str.slice(1); + }).join(' > '); + } - delete searchIndex['/admin/header.tpl']; - delete searchIndex['/admin/footer.tpl']; + function find(dict, term) { + var html = dict.filter(function (elem) { + return elem.translations.toLowerCase().includes(term); + }).map(function (params) { + var namespace = params.namespace; + var translations = params.translations; + var title = params.title == null ? nsToTitle(namespace) : params.title; + + var results = translations + .replace(new RegExp('^(?:(?!' + term + ').)*$', 'gmi'), '') + .replace( + new RegExp('^[\\s\\S]*?(.{0,25})(' + term + ')(.{0,25})[\\s\\S]*?$', 'gmi'), + '...$1$2$3...
' + ).replace(/(\n ?)+/g, '\n'); + + return ''; + }).join(''); + return html; + } - setupACPSearch(); + search.init = function () { + socket.emit('admin.getSearchDict', {}, function (err, dict) { + if (err) { + app.alertError(err); + throw err; + } + setupACPSearch(dict); }); }; - function setupACPSearch() { - var menu = $('#acp-search .dropdown-menu'), - routes = [], - input = $('#acp-search input'), - firstResult = null; + function setupACPSearch(dict) { + var dropdown = $('#acp-search .dropdown'); + var menu = $('#acp-search .dropdown-menu'); + var input = $('#acp-search input'); + + if (!config.searchEnabled) { + menu.addClass('search-disabled'); + } input.on('keyup', function () { - $('#acp-search .dropdown').addClass('open'); + dropdown.addClass('open'); }); $('#acp-search').parents('form').on('submit', function (ev) { - var input = $(this).find('input'), - href = firstResult ? firstResult : RELATIVE_PATH + '/search/' + input.val(); + var firstResult = menu.find('li:first-child > a').attr('href'); + var href = firstResult ? firstResult : RELATIVE_PATH + '/search/' + input.val(); ajaxify.go(href.replace(/^\//, '')); setTimeout(function () { - $('#acp-search .dropdown').removeClass('open'); - $(input).blur(); + dropdown.removeClass('open'); + input.blur(); }, 150); ev.preventDefault(); return false; }); - $('#main-menu a').each(function (idx, link) { - routes.push($(link).attr('href')); - }); - input.on('keyup focus', function () { - var $input = $(this), - value = $input.val().toLowerCase(), - menuItems = $('#acp-search .dropdown-menu').html(''); + var value = input.val().toLowerCase(); + menu.children('.result').remove(); - function toUpperCase(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); - } + var len = value.length; + var results; - firstResult = null; - - if (value.length >= 3) { - for (var file in searchIndex) { - if (searchIndex.hasOwnProperty(file)) { - var position = searchIndex[file].indexOf(value); - - if (position !== -1) { - var href = file.replace('.tpl', ''), - title = href.replace(/^\/admin\//, '').split('/'), - description = searchIndex[file].substring(Math.max(0, position - 25), Math.min(searchIndex[file].length - 1, position + 25)) - .replace(value, '' + value + ''); - - for (var t in title) { - if (title.hasOwnProperty(t)) { - title[t] = title[t] - .replace('-', ' ') - .replace(/\w\S*/g, toUpperCase); - } - } - - title = title.join(' > '); - href = RELATIVE_PATH + href; - firstResult = firstResult ? firstResult : href; - - if ($.inArray(href, routes) !== -1) { - menuItems.append('
  • ' + title + '
    ...' + description + '...
  • '); - } - } - } - } - - if (menuItems.html() === '') { - menuItems.append('
  • No results...
  • '); - } - } + menu.toggleClass('state-start-typing', len === 0); + menu.toggleClass('state-keep-typing', len > 0 && len < 3); + + if (len >= 3) { + menu.prepend(find(dict, value)); + + results = menu.children('.result').length; + + menu.toggleClass('state-no-results', !results); + menu.toggleClass('state-yes-results', !!results); - if (value.length > 0) { - if (config.searchEnabled) { - menuItems.append(''); - menuItems.append('
  • Search the forum for ' + value + '
  • '); - } else if (value.length < 3) { - menuItems.append('
  • Type more to see results...
  • '); - } + menu.find('.search-forum') + .not('.divider') + .find('a') + .attr('href', RELATIVE_PATH + '/search/' + value) + .find('strong') + .html(value); } else { - menuItems.append('
  • Start typing to see results...
  • '); + menu.removeClass('state-no-results'); } }); } diff --git a/src/admin/search.js b/src/admin/search.js new file mode 100644 index 0000000000..985ea51a05 --- /dev/null +++ b/src/admin/search.js @@ -0,0 +1,128 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var nconf = require('nconf'); +var sanitize = require('sanitize-html'); + +var languages = require('../languages'); +var meta = require('../meta'); +var utils = require('../../public/src/utils'); + +function walk(directory) { + return new Promise(function (resolve, reject) { + utils.walk(directory, function (err, data) { + if (err) { + reject(err); + } else { + resolve(data); + } + }); + }); +} + +function readFile(path) { + return new Promise(function (resolve, reject) { + fs.readFile(path, function (err, data) { + if (err) { + reject(err); + } else { + resolve(data.toString()); + } + }); + }); +} + +function loadLanguage(language, filename) { + return new Promise(function (resolve, reject) { + languages.get(language, filename + '.json', function (err, data) { + if (err || !data || !Object.keys(data).length) { + reject(err); + } else { + resolve(data); + } + }); + }); +} + +function getAdminNamespaces() { + return walk(path.resolve('./public/templates/admin')) + .then(function (directories) { + return directories.map(function (dir) { + return dir.replace(/^.*(admin.*?).tpl$/, '$1'); + }).filter(function (dir) { + return !dir.includes('/partials/'); + }).filter(function (dir) { + return dir.match(/\/.*\//); + }); + }); +} + +var fallbackCache = {}; + +function fallback(namespace) { + fallbackCache[namespace] = fallbackCache[namespace] || + readFile(path.resolve('./public/templates/', namespace + '.tpl')) + .then(function (template) { + var translations = sanitize(template, { + transformTags: { + '*': function () { + return { + tagName: 'div' + }; + } + } + }) + .replace(/(
    )|(<\/div>)/g, '') + .replace(/([\n\r]+ ?)+/g, '\n') + .replace(/[\t ]+/g, ' '); + + return { + namespace: namespace, + translations: translations, + }; + }); + return fallbackCache[namespace]; +} + +function initDict(language) { + return getAdminNamespaces().then(function (namespaces) { + return Promise.all(namespaces.map(function (namespace) { + return loadLanguage(language, namespace).then(function (translations) { + return { namespace: namespace, translations: translations }; + }).then(function (params) { + var namespace = params.namespace; + var translations = params.translations; + + var str = Object.keys(translations).map(function (key) { + return translations[key]; + }).join('\n'); + + return { + namespace: namespace, + translations: str + }; + }) + // TODO: Use translator to get title for admin route? + .catch(function () { + return fallback(namespace); + }) + .catch(function () { + return { namespace: namespace, translations: '' }; + }) + .then(function (params) { + params.translations = params.translations.replace(/\{[^\{\}]*\}/g, ''); + return params; + }); + })); + }); +} + +var cache = {}; + +function getDict(language, term) { + cache[language] = cache[language] || initDict(language); + return cache[language]; +} + +module.exports.getDict = getDict; diff --git a/src/socket.io/admin.js b/src/socket.io/admin.js index c3220ed094..9817099ef5 100644 --- a/src/socket.io/admin.js +++ b/src/socket.io/admin.js @@ -15,6 +15,7 @@ var emailer = require('../emailer'); var db = require('../database'); var analytics = require('../analytics'); var index = require('./index'); +var getAdminSearchDict = require('../admin/search').getDict; var SocketAdmin = { user: require('./admin/user'), @@ -277,5 +278,18 @@ SocketAdmin.deleteAllEvents = function (socket, data, callback) { events.deleteAll(callback); }; +SocketAdmin.getSearchDict = function (socket, data, callback) { + user.getSettings(socket.uid, function (err, settings) { + if (err) { + return callback(err); + } + var lang = settings.userLang || meta.config.defaultLang || 'en_GB'; + getAdminSearchDict(lang) + .then(function (results) { + callback(null, results); + }, callback); + }); +}; + module.exports = SocketAdmin; diff --git a/src/views/admin/appearance/themes.tpl b/src/views/admin/appearance/themes.tpl index e67c6a2755..8203c84e8b 100644 --- a/src/views/admin/appearance/themes.tpl +++ b/src/views/admin/appearance/themes.tpl @@ -1,6 +1,6 @@
    - Checking for installed themes... + [[admin/appearance/themes:checking-for-installed]]
    diff --git a/src/views/admin/partials/menu.tpl b/src/views/admin/partials/menu.tpl index 0fea719e46..9bc0c48d7e 100644 --- a/src/views/admin/partials/menu.tpl +++ b/src/views/admin/partials/menu.tpl @@ -149,7 +149,23 @@ diff --git a/src/views/admin/partials/theme_list.tpl b/src/views/admin/partials/theme_list.tpl index 1517eb5c9e..066f43cdd8 100644 --- a/src/views/admin/partials/theme_list.tpl +++ b/src/views/admin/partials/theme_list.tpl @@ -10,13 +10,13 @@

    - Homepage + [[admin/appearance/themes:homepage]]

    - Select SkinSelect Theme + [[admin/appearance/themes:select-skin]][[admin/appearance/themes:select-theme]]
    From e3dd68e19cd06ddea7c7eb127c836d8462536bac Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Mon, 28 Nov 2016 12:53:47 -0700 Subject: [PATCH 02/11] Explanations and simplifications --- public/src/admin/modules/search.js | 7 ++++- src/admin/search.js | 43 ++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index 0a5d3dde56..c7f660149a 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -19,11 +19,16 @@ define(function () { var title = params.title == null ? nsToTitle(namespace) : params.title; var results = translations + // remove all lines without a match .replace(new RegExp('^(?:(?!' + term + ').)*$', 'gmi'), '') + // get up to 25 characaters of context on both sides of the match + // and wrap the match in a `.search-match` element .replace( new RegExp('^[\\s\\S]*?(.{0,25})(' + term + ')(.{0,25})[\\s\\S]*?$', 'gmi'), '...$1$2$3...
    ' - ).replace(/(\n ?)+/g, '\n'); + ) + // collapse whitespace + .replace(/(?:\n ?)+/g, '\n'); return '
  • ').addClass('no-themes').html('No installed themes found')); + translator.translate('[[admin/appearance/themes:no-themes]]', function (text) { + instListEl.append($('
  • ').addClass('no-themes').html(text)); + }); return; } else { templates.parse('admin/partials/theme_list', { themes: themes }, function (html) { - require(['translator'], function (translator) { - translator.translate(html, function (html) { - instListEl.html(html); - highlightSelectedTheme(config['theme:id']); - }); + translator.translate(html, function (html) { + instListEl.html(html); + highlightSelectedTheme(config['theme:id']); }); }); } @@ -88,19 +90,25 @@ define('admin/appearance/themes', function () { }; function highlightSelectedTheme(themeId) { - $('[data-theme]') - .removeClass('selected') - .find('[data-action="use"]') - .html('Select Theme') - .removeClass('btn-success') - .addClass('btn-primary'); + translator.translate('[[admin/appearance/themes:select-theme]] || [[admin/appearance/themes:current-theme]]', function (text) { + text = text.split(' || '); + var select = text[0]; + var current = text[1]; - $('[data-theme="' + themeId + '"]') - .addClass('selected') - .find('[data-action="use"]') - .html('Current Theme') - .removeClass('btn-primary') - .addClass('btn-success'); + $('[data-theme]') + .removeClass('selected') + .find('[data-action="use"]') + .html(select) + .removeClass('btn-success') + .addClass('btn-primary'); + + $('[data-theme="' + themeId + '"]') + .addClass('selected') + .find('[data-action="use"]') + .html(current) + .removeClass('btn-primary') + .addClass('btn-success'); + }); } return Themes; diff --git a/src/views/admin/appearance/skins.tpl b/src/views/admin/appearance/skins.tpl index 0c1b543a47..c5d1355f08 100644 --- a/src/views/admin/appearance/skins.tpl +++ b/src/views/admin/appearance/skins.tpl @@ -1,6 +1,6 @@ From 8ca98625b9eb4d993fbdfb5d0b7c6c935a4f8727 Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Mon, 5 Dec 2016 18:32:17 -0700 Subject: [PATCH 10/11] Key through search results --- public/less/admin/modules/search.less | 9 ++++++ public/src/admin/modules/search.js | 44 ++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/public/less/admin/modules/search.less b/public/less/admin/modules/search.less index 381b940646..d2286005bf 100644 --- a/public/less/admin/modules/search.less +++ b/public/less/admin/modules/search.less @@ -2,6 +2,15 @@ .dropdown-menu { max-height: 75vh; overflow-y: auto; + + > li > a { + &.focus { + &:extend(.dropdown-menu>li>a:focus); + } + &:focus { + outline: none; + } + } } .state-start-typing { diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index 5eb2f336a2..d4ac16f1d8 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -1,7 +1,7 @@ "use strict"; /* globals socket, app, define, ajaxify, config */ -define(function () { +define(['mousetrap'], function (mousetrap) { var search = {}; function nsToTitle(namespace) { @@ -67,8 +67,11 @@ define(function () { }); $('#acp-search').parents('form').on('submit', function (ev) { - var firstResult = menu.find('li:first-child > a').attr('href'); - var href = firstResult ? firstResult : config.relative_path + '/search/' + input.val(); + var selected = menu.find('li.result > a.focus').attr('href'); + if (!selected.length) { + selected = menu.find('li.result > a').first().attr('href'); + } + var href = selected ? selected : config.relative_path + '/search/' + input.val(); ajaxify.go(href.replace(/^\//, '')); @@ -81,8 +84,41 @@ define(function () { return false; }); + mousetrap(input[0]).bind(['up', 'down'], function (ev, key) { + var next; + if (key === 'up') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().prev('.result').children(); + if (!next.length) { + next = menu.find('li.result > a').last(); + } + next.addClass('focus'); + if (menu[0].getBoundingClientRect().top > next[0].getBoundingClientRect().top) { + next[0].scrollIntoView(true); + } + } else if (key === 'down') { + next = menu.find('li.result > a.focus').removeClass('focus').parent().next('.result').children(); + if (!next.length) { + next = menu.find('li.result > a').first(); + } + next.addClass('focus'); + if (menu[0].getBoundingClientRect().bottom < next[0].getBoundingClientRect().bottom) { + next[0].scrollIntoView(false); + } + } + + ev.preventDefault(); + }); + + var prevValue; + input.on('keyup focus', function () { var value = input.val().toLowerCase(); + + if (value === prevValue) { + return; + } + prevValue = value; + menu.children('.result').remove(); var len = value.length; @@ -106,7 +142,7 @@ define(function () { .find('strong') .html(value); } else { - menu.removeClass('state-no-results'); + menu.removeClass('state-no-results state-yes-results'); } }); } From e2ea3cb21e6635bb2435371ed8d41868946eb49e Mon Sep 17 00:00:00 2001 From: Peter Jaszkowiak Date: Tue, 6 Dec 2016 15:13:34 -0700 Subject: [PATCH 11/11] Fix linting error --- public/src/admin/modules/search.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/src/admin/modules/search.js b/public/src/admin/modules/search.js index d4ac16f1d8..4f46898b8e 100644 --- a/public/src/admin/modules/search.js +++ b/public/src/admin/modules/search.js @@ -1,7 +1,7 @@ "use strict"; /* globals socket, app, define, ajaxify, config */ -define(['mousetrap'], function (mousetrap) { +define('admin/modules/search', ['mousetrap'], function (mousetrap) { var search = {}; function nsToTitle(namespace) {