summaryrefslogtreecommitdiff
path: root/client/simple/src/js
diff options
context:
space:
mode:
authorMarkus Heiser <markus.heiser@darmarit.de>2025-01-23 11:10:40 +0100
committerMarkus Heiser <markus.heiser@darmarIT.de>2025-02-28 12:27:41 +0100
commita1132deaa4618f228e82252397247a150081a5f3 (patch)
tree0445fbe04c8932acdfbe5362db40ea1782f38539 /client/simple/src/js
parentb6487b70aaa199aba6ae999a9c99b340b5e98884 (diff)
[web-client] simple theme: move sources to client/simple/src
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
Diffstat (limited to 'client/simple/src/js')
-rw-r--r--client/simple/src/js/head/00_init.js20
-rw-r--r--client/simple/src/js/main/00_toolkit.js165
-rw-r--r--client/simple/src/js/main/infinite_scroll.js88
-rw-r--r--client/simple/src/js/main/keyboard.js461
-rw-r--r--client/simple/src/js/main/mapresult.js74
-rw-r--r--client/simple/src/js/main/preferences.js53
-rw-r--r--client/simple/src/js/main/results.js181
-rw-r--r--client/simple/src/js/main/search.js210
8 files changed, 1252 insertions, 0 deletions
diff --git a/client/simple/src/js/head/00_init.js b/client/simple/src/js/head/00_init.js
new file mode 100644
index 000000000..a7c61c43e
--- /dev/null
+++ b/client/simple/src/js/head/00_init.js
@@ -0,0 +1,20 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+(function (w, d) {
+ 'use strict';
+
+ // add data- properties
+ var script = d.currentScript || (function () {
+ var scripts = d.getElementsByTagName('script');
+ return scripts[scripts.length - 1];
+ })();
+
+ w.searxng = {
+ settings: JSON.parse(atob(script.getAttribute('client_settings')))
+ };
+
+ // update the css
+ var htmlElement = d.getElementsByTagName("html")[0];
+ htmlElement.classList.remove('no-js');
+ htmlElement.classList.add('js');
+
+})(window, document);
diff --git a/client/simple/src/js/main/00_toolkit.js b/client/simple/src/js/main/00_toolkit.js
new file mode 100644
index 000000000..4e374a019
--- /dev/null
+++ b/client/simple/src/js/main/00_toolkit.js
@@ -0,0 +1,165 @@
+/**
+ * @license
+ * (C) Copyright Contributors to the SearXNG project.
+ * (C) Copyright Contributors to the searx project (2014 - 2021).
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+window.searxng = (function (w, d) {
+
+ 'use strict';
+
+ // not invented here toolkit with bugs fixed elsewhere
+ // purposes : be just good enough and as small as possible
+
+ // from https://plainjs.com/javascript/events/live-binding-event-handlers-14/
+ if (w.Element) {
+ (function (ElementPrototype) {
+ ElementPrototype.matches = ElementPrototype.matches ||
+ ElementPrototype.matchesSelector ||
+ ElementPrototype.webkitMatchesSelector ||
+ ElementPrototype.msMatchesSelector ||
+ function (selector) {
+ var node = this, nodes = (node.parentNode || node.document).querySelectorAll(selector), i = -1;
+ while (nodes[++i] && nodes[i] != node);
+ return !!nodes[i];
+ };
+ })(Element.prototype);
+ }
+
+ function callbackSafe (callback, el, e) {
+ try {
+ callback.call(el, e);
+ } catch (exception) {
+ console.log(exception);
+ }
+ }
+
+ var searxng = window.searxng || {};
+
+ searxng.on = function (obj, eventType, callback, useCapture) {
+ useCapture = useCapture || false;
+ if (typeof obj !== 'string') {
+ // obj HTMLElement, HTMLDocument
+ obj.addEventListener(eventType, callback, useCapture);
+ } else {
+ // obj is a selector
+ d.addEventListener(eventType, function (e) {
+ var el = e.target || e.srcElement, found = false;
+ while (el && el.matches && el !== d && !(found = el.matches(obj))) el = el.parentElement;
+ if (found) callbackSafe(callback, el, e);
+ }, useCapture);
+ }
+ };
+
+ searxng.ready = function (callback) {
+ if (document.readyState != 'loading') {
+ callback.call(w);
+ } else {
+ w.addEventListener('DOMContentLoaded', callback.bind(w));
+ }
+ };
+
+ searxng.http = function (method, url, data = null) {
+ return new Promise(function (resolve, reject) {
+ try {
+ var req = new XMLHttpRequest();
+ req.open(method, url, true);
+ req.timeout = 20000;
+
+ // On load
+ req.onload = function () {
+ if (req.status == 200) {
+ resolve(req.response, req.responseType);
+ } else {
+ reject(Error(req.statusText));
+ }
+ };
+
+ // Handle network errors
+ req.onerror = function () {
+ reject(Error("Network Error"));
+ };
+
+ req.onabort = function () {
+ reject(Error("Transaction is aborted"));
+ };
+
+ req.ontimeout = function () {
+ reject(Error("Timeout"));
+ }
+
+ // Make the request
+ if (data) {
+ req.send(data)
+ } else {
+ req.send();
+ }
+ } catch (ex) {
+ reject(ex);
+ }
+ });
+ };
+
+ searxng.loadStyle = function (src) {
+ var path = searxng.settings.theme_static_path + "/" + src,
+ id = "style_" + src.replace('.', '_'),
+ s = d.getElementById(id);
+ if (s === null) {
+ s = d.createElement('link');
+ s.setAttribute('id', id);
+ s.setAttribute('rel', 'stylesheet');
+ s.setAttribute('type', 'text/css');
+ s.setAttribute('href', path);
+ d.body.appendChild(s);
+ }
+ };
+
+ searxng.loadScript = function (src, callback) {
+ var path = searxng.settings.theme_static_path + "/" + src,
+ id = "script_" + src.replace('.', '_'),
+ s = d.getElementById(id);
+ if (s === null) {
+ s = d.createElement('script');
+ s.setAttribute('id', id);
+ s.setAttribute('src', path);
+ s.onload = callback;
+ s.onerror = function () {
+ s.setAttribute('error', '1');
+ };
+ d.body.appendChild(s);
+ } else if (!s.hasAttribute('error')) {
+ try {
+ callback.apply(s, []);
+ } catch (exception) {
+ console.log(exception);
+ }
+ } else {
+ console.log("callback not executed : script '" + path + "' not loaded.");
+ }
+ };
+
+ searxng.insertBefore = function (newNode, referenceNode) {
+ referenceNode.parentNode.insertBefore(newNode, referenceNode);
+ };
+
+ searxng.insertAfter = function (newNode, referenceNode) {
+ referenceNode.parentNode.insertAfter(newNode, referenceNode.nextSibling);
+ };
+
+ searxng.on('.close', 'click', function () {
+ this.parentNode.classList.add('invisible');
+ });
+
+ function getEndpoint () {
+ for (var className of d.getElementsByTagName('body')[0].classList.values()) {
+ if (className.endsWith('_endpoint')) {
+ return className.split('_')[0];
+ }
+ }
+ return '';
+ }
+
+ searxng.endpoint = getEndpoint();
+
+ return searxng;
+})(window, document);
diff --git a/client/simple/src/js/main/infinite_scroll.js b/client/simple/src/js/main/infinite_scroll.js
new file mode 100644
index 000000000..07db3305a
--- /dev/null
+++ b/client/simple/src/js/main/infinite_scroll.js
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+/* global searxng */
+
+searxng.ready(function () {
+ 'use strict';
+
+ searxng.infinite_scroll_supported = (
+ 'IntersectionObserver' in window &&
+ 'IntersectionObserverEntry' in window &&
+ 'intersectionRatio' in window.IntersectionObserverEntry.prototype);
+
+ if (searxng.endpoint !== 'results') {
+ return;
+ }
+
+ if (!searxng.infinite_scroll_supported) {
+ console.log('IntersectionObserver not supported');
+ return;
+ }
+
+ let d = document;
+ var onlyImages = d.getElementById('results').classList.contains('only_template_images');
+
+ function newLoadSpinner () {
+ var loader = d.createElement('div');
+ loader.classList.add('loader');
+ return loader;
+ }
+
+ function replaceChildrenWith (element, children) {
+ element.textContent = '';
+ children.forEach(child => element.appendChild(child));
+ }
+
+ function loadNextPage (callback) {
+ var form = d.querySelector('#pagination form.next_page');
+ if (!form) {
+ return
+ }
+ replaceChildrenWith(d.querySelector('#pagination'), [ newLoadSpinner() ]);
+ var formData = new FormData(form);
+ searxng.http('POST', d.querySelector('#search').getAttribute('action'), formData).then(
+ function (response) {
+ var nextPageDoc = new DOMParser().parseFromString(response, 'text/html');
+ var articleList = nextPageDoc.querySelectorAll('#urls article');
+ var paginationElement = nextPageDoc.querySelector('#pagination');
+ d.querySelector('#pagination').remove();
+ if (articleList.length > 0 && !onlyImages) {
+ // do not add <hr> element when there are only images
+ d.querySelector('#urls').appendChild(d.createElement('hr'));
+ }
+ articleList.forEach(articleElement => {
+ d.querySelector('#urls').appendChild(articleElement);
+ });
+ if (paginationElement) {
+ d.querySelector('#results').appendChild(paginationElement);
+ callback();
+ }
+ }
+ ).catch(
+ function (err) {
+ console.log(err);
+ var e = d.createElement('div');
+ e.textContent = searxng.settings.translations.error_loading_next_page;
+ e.classList.add('dialog-error');
+ e.setAttribute('role', 'alert');
+ replaceChildrenWith(d.querySelector('#pagination'), [ e ]);
+ }
+ )
+ }
+
+ if (searxng.settings.infinite_scroll && searxng.infinite_scroll_supported) {
+ const intersectionObserveOptions = {
+ rootMargin: "20rem",
+ };
+ const observedSelector = 'article.result:last-child';
+ const observer = new IntersectionObserver(entries => {
+ const paginationEntry = entries[0];
+ if (paginationEntry.isIntersecting) {
+ observer.unobserve(paginationEntry.target);
+ loadNextPage(() => observer.observe(d.querySelector(observedSelector), intersectionObserveOptions));
+ }
+ });
+ observer.observe(d.querySelector(observedSelector), intersectionObserveOptions);
+ }
+
+});
diff --git a/client/simple/src/js/main/keyboard.js b/client/simple/src/js/main/keyboard.js
new file mode 100644
index 000000000..e16134579
--- /dev/null
+++ b/client/simple/src/js/main/keyboard.js
@@ -0,0 +1,461 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+/* global searxng */
+
+searxng.ready(function () {
+
+ function isElementInDetail (el) {
+ while (el !== undefined) {
+ if (el.classList.contains('detail')) {
+ return true;
+ }
+ if (el.classList.contains('result')) {
+ // we found a result, no need to go to the root of the document:
+ // el is not inside a <div class="detail"> element
+ return false;
+ }
+ el = el.parentNode;
+ }
+ return false;
+ }
+
+ function getResultElement (el) {
+ while (el !== undefined) {
+ if (el.classList.contains('result')) {
+ return el;
+ }
+ el = el.parentNode;
+ }
+ return undefined;
+ }
+
+ function isImageResult (resultElement) {
+ return resultElement && resultElement.classList.contains('result-images');
+ }
+
+ searxng.on('.result', 'click', function (e) {
+ if (!isElementInDetail(e.target)) {
+ highlightResult(this)(true, true);
+ let resultElement = getResultElement(e.target);
+ if (isImageResult(resultElement)) {
+ e.preventDefault();
+ searxng.selectImage(resultElement);
+ }
+ }
+ });
+
+ searxng.on('.result a', 'focus', function (e) {
+ if (!isElementInDetail(e.target)) {
+ let resultElement = getResultElement(e.target);
+ if (resultElement && resultElement.getAttribute("data-vim-selected") === null) {
+ highlightResult(resultElement)(true);
+ }
+ if (isImageResult(resultElement)) {
+ searxng.selectImage(resultElement);
+ }
+ }
+ }, true);
+
+ /* common base for layouts */
+ var baseKeyBinding = {
+ 'Escape': {
+ key: 'ESC',
+ fun: removeFocus,
+ des: 'remove focus from the focused input',
+ cat: 'Control'
+ },
+ 'c': {
+ key: 'c',
+ fun: copyURLToClipboard,
+ des: 'copy url of the selected result to the clipboard',
+ cat: 'Results'
+ },
+ 'h': {
+ key: 'h',
+ fun: toggleHelp,
+ des: 'toggle help window',
+ cat: 'Other'
+ },
+ 'i': {
+ key: 'i',
+ fun: searchInputFocus,
+ des: 'focus on the search input',
+ cat: 'Control'
+ },
+ 'n': {
+ key: 'n',
+ fun: GoToNextPage(),
+ des: 'go to next page',
+ cat: 'Results'
+ },
+ 'o': {
+ key: 'o',
+ fun: openResult(false),
+ des: 'open search result',
+ cat: 'Results'
+ },
+ 'p': {
+ key: 'p',
+ fun: GoToPreviousPage(),
+ des: 'go to previous page',
+ cat: 'Results'
+ },
+ 'r': {
+ key: 'r',
+ fun: reloadPage,
+ des: 'reload page from the server',
+ cat: 'Control'
+ },
+ 't': {
+ key: 't',
+ fun: openResult(true),
+ des: 'open the result in a new tab',
+ cat: 'Results'
+ },
+ };
+ var keyBindingLayouts = {
+
+ "default": Object.assign(
+ { /* SearXNG layout */
+ 'ArrowLeft': {
+ key: '←',
+ fun: highlightResult('up'),
+ des: 'select previous search result',
+ cat: 'Results'
+ },
+ 'ArrowRight': {
+ key: '→',
+ fun: highlightResult('down'),
+ des: 'select next search result',
+ cat: 'Results'
+ },
+ }, baseKeyBinding),
+
+ 'vim': Object.assign(
+ { /* Vim-like Key Layout. */
+ 'b': {
+ key: 'b',
+ fun: scrollPage(-window.innerHeight),
+ des: 'scroll one page up',
+ cat: 'Navigation'
+ },
+ 'f': {
+ key: 'f',
+ fun: scrollPage(window.innerHeight),
+ des: 'scroll one page down',
+ cat: 'Navigation'
+ },
+ 'u': {
+ key: 'u',
+ fun: scrollPage(-window.innerHeight / 2),
+ des: 'scroll half a page up',
+ cat: 'Navigation'
+ },
+ 'd': {
+ key: 'd',
+ fun: scrollPage(window.innerHeight / 2),
+ des: 'scroll half a page down',
+ cat: 'Navigation'
+ },
+ 'g': {
+ key: 'g',
+ fun: scrollPageTo(-document.body.scrollHeight, 'top'),
+ des: 'scroll to the top of the page',
+ cat: 'Navigation'
+ },
+ 'v': {
+ key: 'v',
+ fun: scrollPageTo(document.body.scrollHeight, 'bottom'),
+ des: 'scroll to the bottom of the page',
+ cat: 'Navigation'
+ },
+ 'k': {
+ key: 'k',
+ fun: highlightResult('up'),
+ des: 'select previous search result',
+ cat: 'Results'
+ },
+ 'j': {
+ key: 'j',
+ fun: highlightResult('down'),
+ des: 'select next search result',
+ cat: 'Results'
+ },
+ 'y': {
+ key: 'y',
+ fun: copyURLToClipboard,
+ des: 'copy url of the selected result to the clipboard',
+ cat: 'Results'
+ },
+ }, baseKeyBinding)
+ }
+
+ var keyBindings = keyBindingLayouts[searxng.settings.hotkeys] || keyBindingLayouts.default;
+
+ searxng.on(document, "keydown", function (e) {
+ // check for modifiers so we don't break browser's hotkeys
+ if (
+ Object.prototype.hasOwnProperty.call(keyBindings, e.key)
+ && !e.ctrlKey && !e.altKey
+ && !e.shiftKey && !e.metaKey
+ ) {
+ var tagName = e.target.tagName.toLowerCase();
+ if (e.key === 'Escape') {
+ keyBindings[e.key].fun(e);
+ } else {
+ if (e.target === document.body || tagName === 'a' || tagName === 'button') {
+ e.preventDefault();
+ keyBindings[e.key].fun();
+ }
+ }
+ }
+ });
+
+ function highlightResult (which) {
+ return function (noScroll, keepFocus) {
+ var current = document.querySelector('.result[data-vim-selected]'),
+ effectiveWhich = which;
+ if (current === null) {
+ // no selection : choose the first one
+ current = document.querySelector('.result');
+ if (current === null) {
+ // no first one : there are no results
+ return;
+ }
+ // replace up/down actions by selecting first one
+ if (which === "down" || which === "up") {
+ effectiveWhich = current;
+ }
+ }
+
+ var next, results = document.querySelectorAll('.result');
+ results = Array.from(results); // convert NodeList to Array for further use
+
+ if (typeof effectiveWhich !== 'string') {
+ next = effectiveWhich;
+ } else {
+ switch (effectiveWhich) {
+ case 'visible':
+ var top = document.documentElement.scrollTop || document.body.scrollTop;
+ var bot = top + document.documentElement.clientHeight;
+
+ for (var i = 0; i < results.length; i++) {
+ next = results[i];
+ var etop = next.offsetTop;
+ var ebot = etop + next.clientHeight;
+
+ if ((ebot <= bot) && (etop > top)) {
+ break;
+ }
+ }
+ break;
+ case 'down':
+ next = results[results.indexOf(current) + 1] || current;
+ break;
+ case 'up':
+ next = results[results.indexOf(current) - 1] || current;
+ break;
+ case 'bottom':
+ next = results[results.length - 1];
+ break;
+ case 'top':
+ /* falls through */
+ default:
+ next = results[0];
+ }
+ }
+
+ if (next) {
+ current.removeAttribute('data-vim-selected');
+ next.setAttribute('data-vim-selected', 'true');
+ if (!keepFocus) {
+ var link = next.querySelector('h3 a') || next.querySelector('a');
+ if (link !== null) {
+ link.focus();
+ }
+ }
+ if (!noScroll) {
+ scrollPageToSelected();
+ }
+ }
+ };
+ }
+
+ function reloadPage () {
+ document.location.reload(true);
+ }
+
+ function removeFocus (e) {
+ const tagName = e.target.tagName.toLowerCase();
+ if (document.activeElement && (tagName === 'input' || tagName === 'select' || tagName === 'textarea')) {
+ document.activeElement.blur();
+ } else {
+ searxng.closeDetail();
+ }
+ }
+
+ function pageButtonClick (css_selector) {
+ return function () {
+ var button = document.querySelector(css_selector);
+ if (button) {
+ button.click();
+ }
+ };
+ }
+
+ function GoToNextPage () {
+ return pageButtonClick('nav#pagination .next_page button[type="submit"]');
+ }
+
+ function GoToPreviousPage () {
+ return pageButtonClick('nav#pagination .previous_page button[type="submit"]');
+ }
+
+ function scrollPageToSelected () {
+ var sel = document.querySelector('.result[data-vim-selected]');
+ if (sel === null) {
+ return;
+ }
+ var wtop = document.documentElement.scrollTop || document.body.scrollTop,
+ wheight = document.documentElement.clientHeight,
+ etop = sel.offsetTop,
+ ebot = etop + sel.clientHeight,
+ offset = 120;
+ // first element ?
+ if ((sel.previousElementSibling === null) && (ebot < wheight)) {
+ // set to the top of page if the first element
+ // is fully included in the viewport
+ window.scroll(window.scrollX, 0);
+ return;
+ }
+ if (wtop > (etop - offset)) {
+ window.scroll(window.scrollX, etop - offset);
+ } else {
+ var wbot = wtop + wheight;
+ if (wbot < (ebot + offset)) {
+ window.scroll(window.scrollX, ebot - wheight + offset);
+ }
+ }
+ }
+
+ function scrollPage (amount) {
+ return function () {
+ window.scrollBy(0, amount);
+ highlightResult('visible')();
+ };
+ }
+
+ function scrollPageTo (position, nav) {
+ return function () {
+ window.scrollTo(0, position);
+ highlightResult(nav)();
+ };
+ }
+
+ function searchInputFocus () {
+ window.scrollTo(0, 0);
+ var q = document.querySelector('#q');
+ q.focus();
+ if (q.setSelectionRange) {
+ var len = q.value.length;
+ q.setSelectionRange(len, len);
+ }
+ }
+
+ function openResult (newTab) {
+ return function () {
+ var link = document.querySelector('.result[data-vim-selected] h3 a');
+ if (link === null) {
+ link = document.querySelector('.result[data-vim-selected] > a');
+ }
+ if (link !== null) {
+ var url = link.getAttribute('href');
+ if (newTab) {
+ window.open(url);
+ } else {
+ window.location.href = url;
+ }
+ }
+ };
+ }
+
+ function initHelpContent (divElement) {
+ var categories = {};
+
+ for (var k in keyBindings) {
+ var key = keyBindings[k];
+ categories[key.cat] = categories[key.cat] || [];
+ categories[key.cat].push(key);
+ }
+
+ var sorted = Object.keys(categories).sort(function (a, b) {
+ return categories[b].length - categories[a].length;
+ });
+
+ if (sorted.length === 0) {
+ return;
+ }
+
+ var html = '<a href="#" class="close" aria-label="close" title="close">×</a>';
+ html += '<h3>How to navigate SearXNG with hotkeys</h3>';
+ html += '<table>';
+
+ for (var i = 0; i < sorted.length; i++) {
+ var cat = categories[sorted[i]];
+
+ var lastCategory = i === (sorted.length - 1);
+ var first = i % 2 === 0;
+
+ if (first) {
+ html += '<tr>';
+ }
+ html += '<td>';
+
+ html += '<h4>' + cat[0].cat + '</h4>';
+ html += '<ul class="list-unstyled">';
+
+ for (var cj in cat) {
+ html += '<li><kbd>' + cat[cj].key + '</kbd> ' + cat[cj].des + '</li>';
+ }
+
+ html += '</ul>';
+ html += '</td>'; // col-sm-*
+
+ if (!first || lastCategory) {
+ html += '</tr>'; // row
+ }
+ }
+
+ html += '</table>';
+
+ divElement.innerHTML = html;
+ }
+
+ function toggleHelp () {
+ var helpPanel = document.querySelector('#vim-hotkeys-help');
+ if (helpPanel === undefined || helpPanel === null) {
+ // first call
+ helpPanel = document.createElement('div');
+ helpPanel.id = 'vim-hotkeys-help';
+ helpPanel.className = 'dialog-modal';
+ initHelpContent(helpPanel);
+ var body = document.getElementsByTagName('body')[0];
+ body.appendChild(helpPanel);
+ } else {
+ // toggle hidden
+ helpPanel.classList.toggle('invisible');
+ return;
+ }
+ }
+
+ function copyURLToClipboard () {
+ var currentUrlElement = document.querySelector('.result[data-vim-selected] h3 a');
+ if (currentUrlElement === null) return;
+
+ const url = currentUrlElement.getAttribute('href');
+ navigator.clipboard.writeText(url);
+ }
+
+ searxng.scrollPageToSelected = scrollPageToSelected;
+ searxng.selectNext = highlightResult('down');
+ searxng.selectPrevious = highlightResult('up');
+});
diff --git a/client/simple/src/js/main/mapresult.js b/client/simple/src/js/main/mapresult.js
new file mode 100644
index 000000000..2c3777678
--- /dev/null
+++ b/client/simple/src/js/main/mapresult.js
@@ -0,0 +1,74 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+/* global L */
+(function (w, d, searxng) {
+ 'use strict';
+
+ searxng.ready(function () {
+ searxng.on('.searxng_init_map', 'click', function (event) {
+ // no more request
+ this.classList.remove("searxng_init_map");
+
+ //
+ var leaflet_target = this.dataset.leafletTarget;
+ var map_lon = parseFloat(this.dataset.mapLon);
+ var map_lat = parseFloat(this.dataset.mapLat);
+ var map_zoom = parseFloat(this.dataset.mapZoom);
+ var map_boundingbox = JSON.parse(this.dataset.mapBoundingbox);
+ var map_geojson = JSON.parse(this.dataset.mapGeojson);
+
+ searxng.loadStyle('css/leaflet.css');
+ searxng.loadScript('js/leaflet.js', function () {
+ var map_bounds = null;
+ if (map_boundingbox) {
+ var southWest = L.latLng(map_boundingbox[0], map_boundingbox[2]);
+ var northEast = L.latLng(map_boundingbox[1], map_boundingbox[3]);
+ map_bounds = L.latLngBounds(southWest, northEast);
+ }
+
+ // init map
+ var map = L.map(leaflet_target);
+ // create the tile layer with correct attribution
+ var osmMapnikUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+ var osmMapnikAttrib = 'Map data © <a href="https://openstreetmap.org">OpenStreetMap</a> contributors';
+ var osmMapnik = new L.TileLayer(osmMapnikUrl, {minZoom: 1, maxZoom: 19, attribution: osmMapnikAttrib});
+ var osmWikimediaUrl = 'https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}.png';
+ var osmWikimediaAttrib = 'Wikimedia maps | Maps data © <a href="https://openstreetmap.org">OpenStreetMap contributors</a>';
+ var osmWikimedia = new L.TileLayer(osmWikimediaUrl, {minZoom: 1, maxZoom: 19, attribution: osmWikimediaAttrib});
+ // init map view
+ if (map_bounds) {
+ // TODO hack: https://github.com/Leaflet/Leaflet/issues/2021
+ // Still useful ?
+ setTimeout(function () {
+ map.fitBounds(map_bounds, {
+ maxZoom: 17
+ });
+ }, 0);
+ } else if (map_lon && map_lat) {
+ if (map_zoom) {
+ map.setView(new L.latLng(map_lat, map_lon), map_zoom);
+ } else {
+ map.setView(new L.latLng(map_lat, map_lon), 8);
+ }
+ }
+
+ map.addLayer(osmMapnik);
+
+ var baseLayers = {
+ "OSM Mapnik": osmMapnik,
+ "OSM Wikimedia": osmWikimedia,
+ };
+
+ L.control.layers(baseLayers).addTo(map);
+
+ if (map_geojson) {
+ L.geoJson(map_geojson).addTo(map);
+ } /* else if(map_bounds) {
+ L.rectangle(map_bounds, {color: "#ff7800", weight: 3, fill:false}).addTo(map);
+ } */
+ });
+
+ // this event occur only once per element
+ event.preventDefault();
+ });
+ });
+})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/preferences.js b/client/simple/src/js/main/preferences.js
new file mode 100644
index 000000000..a0b853d61
--- /dev/null
+++ b/client/simple/src/js/main/preferences.js
@@ -0,0 +1,53 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+(function (w, d, searxng) {
+ 'use strict';
+
+ if (searxng.endpoint !== 'preferences') {
+ return;
+ }
+
+ searxng.ready(function () {
+ let engine_descriptions = null;
+ function load_engine_descriptions () {
+ if (engine_descriptions == null) {
+ searxng.http("GET", "engine_descriptions.json").then(function (content) {
+ engine_descriptions = JSON.parse(content);
+ for (const [engine_name, description] of Object.entries(engine_descriptions)) {
+ let elements = d.querySelectorAll('[data-engine-name="' + engine_name + '"] .engine-description');
+ for (const element of elements) {
+ let source = ' (<i>' + searxng.settings.translations.Source + ':&nbsp;' + description[1] + '</i>)';
+ element.innerHTML = description[0] + source;
+ }
+ }
+ });
+ }
+ }
+
+ for (const el of d.querySelectorAll('[data-engine-name]')) {
+ searxng.on(el, 'mouseenter', load_engine_descriptions);
+ }
+
+ const enableAllEngines = d.querySelectorAll(".enable-all-engines");
+ const disableAllEngines = d.querySelectorAll(".disable-all-engines");
+ const engineToggles = d.querySelectorAll('tbody input[type=checkbox][class~=checkbox-onoff]');
+ const toggleEngines = (enable) => {
+ for (const el of engineToggles) {
+ // check if element visible, so that only engines of the current category are modified
+ if (el.offsetParent !== null) el.checked = !enable;
+ }
+ };
+ for (const el of enableAllEngines) {
+ searxng.on(el, 'click', () => toggleEngines(true));
+ }
+ for (const el of disableAllEngines) {
+ searxng.on(el, 'click', () => toggleEngines(false));
+ }
+
+ const copyHashButton = d.querySelector("#copy-hash");
+ searxng.on(copyHashButton, 'click', (e) => {
+ e.preventDefault();
+ navigator.clipboard.writeText(copyHashButton.dataset.hash);
+ copyHashButton.innerText = copyHashButton.dataset.copiedText;
+ });
+ });
+})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/results.js b/client/simple/src/js/main/results.js
new file mode 100644
index 000000000..cc7c7efcd
--- /dev/null
+++ b/client/simple/src/js/main/results.js
@@ -0,0 +1,181 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+(function (w, d, searxng) {
+ 'use strict';
+
+ if (searxng.endpoint !== 'results') {
+ return;
+ }
+
+ searxng.ready(function () {
+ d.querySelectorAll('#urls img').forEach(
+ img =>
+ img.addEventListener(
+ 'error', () => {
+ // console.log("ERROR can't load: " + img.src);
+ img.src = window.searxng.settings.theme_static_path + "/img/img_load_error.svg";
+ },
+ {once: true}
+ ));
+
+ if (d.querySelector('#search_url button#copy_url')) {
+ d.querySelector('#search_url button#copy_url').style.display = "block";
+ }
+
+ searxng.on('.btn-collapse', 'click', function () {
+ var btnLabelCollapsed = this.getAttribute('data-btn-text-collapsed');
+ var btnLabelNotCollapsed = this.getAttribute('data-btn-text-not-collapsed');
+ var target = this.getAttribute('data-target');
+ var targetElement = d.querySelector(target);
+ var html = this.innerHTML;
+ if (this.classList.contains('collapsed')) {
+ html = html.replace(btnLabelCollapsed, btnLabelNotCollapsed);
+ } else {
+ html = html.replace(btnLabelNotCollapsed, btnLabelCollapsed);
+ }
+ this.innerHTML = html;
+ this.classList.toggle('collapsed');
+ targetElement.classList.toggle('invisible');
+ });
+
+ searxng.on('.media-loader', 'click', function () {
+ var target = this.getAttribute('data-target');
+ var iframe_load = d.querySelector(target + ' > iframe');
+ var srctest = iframe_load.getAttribute('src');
+ if (srctest === null || srctest === undefined || srctest === false) {
+ iframe_load.setAttribute('src', iframe_load.getAttribute('data-src'));
+ }
+ });
+
+ searxng.on('#copy_url', 'click', function () {
+ var target = this.parentElement.querySelector('pre');
+ navigator.clipboard.writeText(target.innerText);
+ this.innerText = this.dataset.copiedText;
+ });
+
+ // searxng.selectImage (gallery)
+ // -----------------------------
+
+ // setTimeout() ID, needed to cancel *last* loadImage
+ let imgTimeoutID;
+
+ // progress spinner, while an image is loading
+ const imgLoaderSpinner = d.createElement('div');
+ imgLoaderSpinner.classList.add('loader');
+
+ // singleton image object, which is used for all loading processes of a
+ // detailed image
+ const imgLoader = new Image();
+
+ const loadImage = (imgSrc, onSuccess) => {
+ // if defered image load exists, stop defered task.
+ if (imgTimeoutID) clearTimeout(imgTimeoutID);
+
+ // defer load of the detail image for 1 sec
+ imgTimeoutID = setTimeout(() => {
+ imgLoader.src = imgSrc;
+ }, 1000);
+
+ // set handlers in the on-properties
+ imgLoader.onload = () => {
+ onSuccess();
+ imgLoaderSpinner.remove();
+ };
+ imgLoader.onerror = () => {
+ imgLoaderSpinner.remove();
+ };
+ };
+
+ searxng.selectImage = (resultElement) => {
+
+ // add a class that can be evaluated in the CSS and indicates that the
+ // detail view is open
+ d.getElementById('results').classList.add('image-detail-open');
+
+ // add a hash to the browser history so that pressing back doesn't return
+ // to the previous page this allows us to dismiss the image details on
+ // pressing the back button on mobile devices
+ window.location.hash = '#image-viewer';
+
+ searxng.scrollPageToSelected();
+
+ // if there is none element given by the caller, stop here
+ if (!resultElement) return;
+
+ // find <img> object in the element, if there is none, stop here.
+ const img = resultElement.querySelector('.result-images-source img');
+ if (!img) return;
+
+ // <img src="" data-src="http://example.org/image.jpg">
+ const src = img.getAttribute('data-src');
+
+ // already loaded high-res image or no high-res image available
+ if (!src) return;
+
+ // use the image thumbnail until the image is fully loaded
+ const thumbnail = resultElement.querySelector('.image_thumbnail');
+ img.src = thumbnail.src;
+
+ // show a progress spinner
+ const detailElement = resultElement.querySelector('.detail');
+ detailElement.appendChild(imgLoaderSpinner);
+
+ // load full size image in background
+ loadImage(src, () => {
+ // after the singelton loadImage has loaded the detail image into the
+ // cache, it can be used in the origin <img> as src property.
+ img.src = src;
+ img.removeAttribute('data-src');
+ });
+ };
+
+ searxng.closeDetail = function () {
+ d.getElementById('results').classList.remove('image-detail-open');
+ // remove #image-viewer hash from url by navigating back
+ if (window.location.hash == '#image-viewer') window.history.back();
+ searxng.scrollPageToSelected();
+ };
+ searxng.on('.result-detail-close', 'click', e => {
+ e.preventDefault();
+ searxng.closeDetail();
+ });
+ searxng.on('.result-detail-previous', 'click', e => {
+ e.preventDefault();
+ searxng.selectPrevious(false);
+ });
+ searxng.on('.result-detail-next', 'click', e => {
+ e.preventDefault();
+ searxng.selectNext(false);
+ });
+
+ // listen for the back button to be pressed and dismiss the image details when called
+ window.addEventListener('hashchange', () => {
+ if (window.location.hash != '#image-viewer') searxng.closeDetail();
+ });
+
+ d.querySelectorAll('.swipe-horizontal').forEach(
+ obj => {
+ obj.addEventListener('swiped-left', function () {
+ searxng.selectNext(false);
+ });
+ obj.addEventListener('swiped-right', function () {
+ searxng.selectPrevious(false);
+ });
+ }
+ );
+
+ w.addEventListener('scroll', function () {
+ var e = d.getElementById('backToTop'),
+ scrollTop = document.documentElement.scrollTop || document.body.scrollTop,
+ results = d.getElementById('results');
+ if (e !== null) {
+ if (scrollTop >= 100) {
+ results.classList.add('scrolling');
+ } else {
+ results.classList.remove('scrolling');
+ }
+ }
+ }, true);
+
+ });
+
+})(window, document, window.searxng);
diff --git a/client/simple/src/js/main/search.js b/client/simple/src/js/main/search.js
new file mode 100644
index 000000000..aca146c41
--- /dev/null
+++ b/client/simple/src/js/main/search.js
@@ -0,0 +1,210 @@
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
+/* exported AutoComplete */
+
+import AutoComplete from "../../../node_modules/autocomplete-js/dist/autocomplete.js";
+
+(function (w, d, searxng) {
+ 'use strict';
+
+ var qinput_id = "q", qinput;
+
+ const isMobile = window.matchMedia("only screen and (max-width: 50em)").matches;
+
+ function submitIfQuery () {
+ if (qinput.value.length > 0) {
+ var search = document.getElementById('search');
+ setTimeout(search.submit.bind(search), 0);
+ }
+ }
+
+ function createClearButton (qinput) {
+ var cs = document.getElementById('clear_search');
+ var updateClearButton = function () {
+ if (qinput.value.length === 0) {
+ cs.classList.add("empty");
+ } else {
+ cs.classList.remove("empty");
+ }
+ };
+
+ // update status, event listener
+ updateClearButton();
+ cs.addEventListener('click', function (ev) {
+ qinput.value = '';
+ qinput.focus();
+ updateClearButton();
+ ev.preventDefault();
+ });
+ qinput.addEventListener('input', updateClearButton, false);
+ }
+
+ searxng.ready(function () {
+ qinput = d.getElementById(qinput_id);
+
+ if (qinput !== null) {
+ // clear button
+ createClearButton(qinput);
+
+ // autocompleter
+ if (searxng.settings.autocomplete) {
+ searxng.autocomplete = AutoComplete.call(w, {
+ Url: "./autocompleter",
+ EmptyMessage: searxng.settings.translations.no_item_found,
+ HttpMethod: searxng.settings.method,
+ HttpHeaders: {
+ "Content-type": "application/x-www-form-urlencoded",
+ "X-Requested-With": "XMLHttpRequest"
+ },
+ MinChars: searxng.settings.autocomplete_min,
+ Delay: 300,
+ _Position: function () {},
+ _Open: function () {
+ var params = this;
+ Array.prototype.forEach.call(this.DOMResults.getElementsByTagName("li"), function (li) {
+ if (li.getAttribute("class") != "locked") {
+ li.onmousedown = function () {
+ params._Select(li);
+ };
+ }
+ });
+ },
+ _Select: function (item) {
+ AutoComplete.defaults._Select.call(this, item);
+ var form = item.closest('form');
+ if (form) {
+ form.submit();
+ }
+ },
+ _MinChars: function () {
+ if (this.Input.value.indexOf('!') > -1) {
+ return 0;
+ } else {
+ return AutoComplete.defaults._MinChars.call(this);
+ }
+ },
+ KeyboardMappings: Object.assign({}, AutoComplete.defaults.KeyboardMappings, {
+ "KeyUpAndDown_up": Object.assign({}, AutoComplete.defaults.KeyboardMappings.KeyUpAndDown_up, {
+ Callback: function (event) {
+ AutoComplete.defaults.KeyboardMappings.KeyUpAndDown_up.Callback.call(this, event);
+ var liActive = this.DOMResults.querySelector("li.active");
+ if (liActive) {
+ AutoComplete.defaults._Select.call(this, liActive);
+ }
+ },
+ }),
+ "Tab": Object.assign({}, AutoComplete.defaults.KeyboardMappings.Enter, {
+ Conditions: [{
+ Is: 9,
+ Not: false
+ }],
+ Callback: function (event) {
+ if (this.DOMResults.getAttribute("class").indexOf("open") != -1) {
+ var liActive = this.DOMResults.querySelector("li.active");
+ if (liActive !== null) {
+ AutoComplete.defaults._Select.call(this, liActive);
+ event.preventDefault();
+ }
+ }
+ },
+ })
+ }),
+ }, "#" + qinput_id);
+ }
+
+ /*
+ Monkey patch autocomplete.js to fix a bug
+ With the POST method, the values are not URL encoded: query like "1 + 1" are sent as "1 1" since space are URL encoded as plus.
+ See HTML specifications:
+ * HTML5: https://url.spec.whatwg.org/#concept-urlencoded-serializer
+ * HTML4: https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1
+
+ autocomplete.js does not URL encode the name and values:
+ https://github.com/autocompletejs/autocomplete.js/blob/87069524f3b95e68f1b54d8976868e0eac1b2c83/src/autocomplete.ts#L665
+
+ The monkey patch overrides the compiled version of the ajax function.
+ See https://github.com/autocompletejs/autocomplete.js/blob/87069524f3b95e68f1b54d8976868e0eac1b2c83/dist/autocomplete.js#L143-L158
+ The patch changes only the line 156 from
+ params.Request.send(params._QueryArg() + "=" + params._Pre());
+ to
+ params.Request.send(encodeURIComponent(params._QueryArg()) + "=" + encodeURIComponent(params._Pre()));
+
+ Related to:
+ * https://github.com/autocompletejs/autocomplete.js/issues/78
+ * https://github.com/searxng/searxng/issues/1695
+ */
+ AutoComplete.prototype.ajax = function (params, request, timeout) {
+ if (timeout === void 0) { timeout = true; }
+ if (params.$AjaxTimer) {
+ window.clearTimeout(params.$AjaxTimer);
+ }
+ if (timeout === true) {
+ params.$AjaxTimer = window.setTimeout(AutoComplete.prototype.ajax.bind(null, params, request, false), params.Delay);
+ } else {
+ if (params.Request) {
+ params.Request.abort();
+ }
+ params.Request = request;
+ params.Request.send(encodeURIComponent(params._QueryArg()) + "=" + encodeURIComponent(params._Pre()));
+ }
+ };
+
+ if (!isMobile && document.querySelector('.index_endpoint')) {
+ qinput.focus();
+ }
+ }
+
+ // Additionally to searching when selecting a new category, we also
+ // automatically start a new search request when the user changes a search
+ // filter (safesearch, time range or language) (this requires JavaScript
+ // though)
+ if (
+ qinput !== null
+ && searxng.settings.search_on_category_select
+ // If .search_filters is undefined (invisible) we are on the homepage and
+ // hence don't have to set any listeners
+ && d.querySelector(".search_filters") != null
+ ) {
+ searxng.on(d.getElementById('safesearch'), 'change', submitIfQuery);
+ searxng.on(d.getElementById('time_range'), 'change', submitIfQuery);
+ searxng.on(d.getElementById('language'), 'change', submitIfQuery);
+ }
+
+ const categoryButtons = d.querySelectorAll("button.category_button");
+ for (let button of categoryButtons) {
+ searxng.on(button, 'click', (event) => {
+ if (event.shiftKey) {
+ event.preventDefault();
+ button.classList.toggle("selected");
+ return;
+ }
+
+ // manually deselect the old selection when a new category is selected
+ const selectedCategories = d.querySelectorAll("button.category_button.selected");
+ for (let categoryButton of selectedCategories) {
+ categoryButton.classList.remove("selected");
+ }
+ button.classList.add("selected");
+ })
+ }
+
+ // override form submit action to update the actually selected categories
+ const form = d.querySelector("#search");
+ if (form != null) {
+ searxng.on(form, 'submit', (event) => {
+ event.preventDefault();
+ const categoryValuesInput = d.querySelector("#selected-categories");
+ if (categoryValuesInput) {
+ let categoryValues = [];
+ for (let categoryButton of categoryButtons) {
+ if (categoryButton.classList.contains("selected")) {
+ categoryValues.push(categoryButton.name.replace("category_", ""));
+ }
+ }
+ categoryValuesInput.value = categoryValues.join(",");
+ }
+ form.submit();
+ });
+ }
+ });
+
+})(window, document, window.searxng);