Add search functionality

This commit is contained in:
Dale Davies
2022-05-28 20:42:56 +01:00
parent 3100b55d75
commit ca5e9b2acd
27 changed files with 584 additions and 3389 deletions

View File

@@ -4,7 +4,6 @@
font-weight: 400;
text-transform: capitalize;
text-shadow: 1px 1px 2px #000000a0;
margin-top: -50px;
margin-bottom: 15px;
.tagname {

View File

@@ -4,7 +4,6 @@
right: 0;
left: 0;
padding: 15px 15px 0 15px;
overflow: hidden;
text-align: right;
z-index: 100;

View File

@@ -0,0 +1,179 @@
.search {
position:absolute;
top:15px;
height: 55px;
width: 55px;
display: block;
background-position: top 13px left 13px;
background-repeat: no-repeat;
background-image: url(../images/search.svg);
background-size: 24px;
background-color: #ffffff15;
border-radius: 50%;
cursor: pointer;
border: 2px solid #ffffff20;
overflow: hidden;
padding:0;
font-family: 'Quicksand', sans-serif;
&:hover {
background-color: #fff;
box-shadow: 0 1px 5px rgba(0,0,0,.3);
background-image: url(../images/search-dark.svg);
border: 2px solid #cecece;
transition: background-color, background-image .1s;
}
&.open {
@extend :hover;
width: calc(100% - 30px);
max-width: 450px;
cursor: auto;
border-radius: 5px;
border: .2em solid #cecece;
box-shadow: 0 1px 5px rgba(0,0,0,.3);
.close {
position: absolute;
top: 0;
right: 0;
height: 48px;
width: 48px;
display: inline-block;
background-position: top 50% left 50%;
background-repeat: no-repeat;
background-image: url(../images/close-dark.svg);
background-size: 30px;
cursor: pointer;
border: 5px solid #fff;
border-radius: 50%;
z-index: 1;
&:hover {
background-color: #f3f3f3;
}
}
.search-form {
display: inline-block;
}
}
&.suggestions {
height: auto;
.suggestion-list {
display: block;
padding-top: 20px;
margin-top: 25px;
padding-bottom:10px;
border-top: 1px solid #ddd;
text-align: left;
color: #202124;
font-size: 15px;
.selected {
border: 1px solid red;
}
ul {
padding: 10px 0 0 0;
margin: 0;
list-style-type: none;
li a {
display: block;
padding: 5px 0 5px 15px;
text-decoration: none;
color: inherit;
&:focus,
&:hover {
outline: none;
background-color: #eee;
}
}
}
.searchproviders {
margin-bottom: 20px;
li a {
display: block;
background-position: top 50% left 15px;
background-repeat: no-repeat;
background-image: url(../images/search-dark.svg);
background-size: 17px;
padding-left: 50px;
span {
color: #999;
}
}
}
.suggestiontitle {
margin: 0 0 0 15px;
display: block;
color: #888;
font-size: 15px;
text-transform: uppercase;
}
ul.suggestions {
padding-bottom: 15px;
}
.icon {
width: 20px;
vertical-align: middle;
margin-right: 15px;
}
.name {
vertical-align: middle;
}
}
}
.search-form {
display: none;
position: relative;
top: 14px;
overflow: hidden;
width: calc(100% - 35px);
text-align: left;
color: #202124;
padding-right: 48px;
input {
display: block;
width: 100%;
background: transparent;
border: none;
color: #202124;
font-size: 17px;
font-family: 'Quicksand', sans-serif;
padding: 0 0 0 15px;
margin: 0;
outline: none;
position: relative;
}
// Remove the 'x' icon from Internet Explorer
input[type=search]::-ms-clear,
input[type=search]::-ms-reveal {
display: none;
width: 0;
height: 0;
}
// Remove the 'x' icon from chrome
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
display: none;
}
}
}

View File

@@ -11,6 +11,7 @@
@use 'footer-bar';
@use 'greeting';
@use 'header-bar';
@use 'search';
@use 'sites';
@use 'tags';
@@ -71,5 +72,5 @@ body {
justify-content:center;
width: 100%;
max-width: 650px;
margin:0 auto;
margin:-50px auto 0 auto;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="#222222" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="10" cy="10" r="7" />
<line x1="21" y1="21" x2="15" y2="15" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="#ffffff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<circle cx="10" cy="10" r="7" />
<line x1="21" y1="21" x2="15" y2="15" />
</svg>

After

Width:  |  Height:  |  Size: 359 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,8 @@
import Clock from './Clock';
import EventEmitter from 'eventemitter3';
import Fuse from 'fuse.js';
import Greeting from './Greeting';
import SearchSuggestions from './SearchSuggestions';
import Weather from './Weather';
export default class Main {
@@ -23,7 +25,8 @@ export default class Main {
this.clientlocationelm = document.querySelector('.useclientlocation');
this.showtagsbuttonelm = document.querySelector('.show-tags');
this.tagselectorelm = document.querySelector('.tags');
this.tagsselectorclosebuttonelm = document.querySelector('.tags .close')
this.tagsselectorclosebuttonelm = document.querySelector('.tags .close');
this.showsearchbuttonelm = document.querySelector('.search');
// If the user has previously asked for geolocation we will have stored the latlong.
if (this.lastrequestedlocation = this.storage.getItem('lastrequestedlocation')){
this.latlong = JSON.parse(this.lastrequestedlocation);
@@ -32,6 +35,14 @@ export default class Main {
this.eventemitter = new EventEmitter();
this.clock = new Clock(this.eventemitter, !!JUMP.ampmclock, !JUMP.owmapikey);
this.weather = new Weather(this.eventemitter);
if (this.showsearchbuttonelm) {
this.searchclosebuttonelm = this.showsearchbuttonelm.querySelector('.close');
this.fuse = new Fuse(JSON.parse(JUMP.search), {
threshold: 0.3,
keys: ['name', 'tags']
});
}
}
/**
@@ -115,6 +126,76 @@ export default class Main {
});
}
if (this.showsearchbuttonelm) {
const searchinput = document.querySelector('.search-form input');
this.searchsuggestions = new SearchSuggestions(JSON.parse(JUMP.searchengines), searchinput, this.showsearchbuttonelm, this.eventemitter);
// When the search icon is licked, show the search bar and focus on it.
this.showsearchbuttonelm.addEventListener('click', e => {
if (!e.target.classList.contains('open')) {
this.showsearchbuttonelm.classList.add('open');
searchinput.focus();
}
});
// Listen for CTRL+/ key combo and open search bar.
document.addEventListener('keyup', e => {
if (e.ctrlKey && (e.code == 'Slash')) {
if (!this.showsearchbuttonelm.classList.contains('open')) {
this.showsearchbuttonelm.classList.add('open');
searchinput.focus();
} else {
this.search_close();
}
}
});
// Handle the close button.
this.searchclosebuttonelm.addEventListener('click', e => {
e.stopPropagation();
this.search_close();
});
// Listen for key events triggered by the searh bar and do stuff.
searchinput.addEventListener('keyup', e => {
// On arrow down, focus on the first search suggestion.
let suggestionslist = document.querySelector('.suggestion-list .searchproviders');
if (e.code === 'ArrowDown') {
if (suggestionslist && suggestionslist.childNodes.length) {
suggestionslist.firstChild.firstChild.focus();
}
return;
}
// Perform search and display suggestions on the page.
let results = [];
let siteresults = this.fuse.search(searchinput.value);
if (siteresults.length > 0) {
siteresults.forEach((result) => {
results.push(result.item);
});
}
this.searchsuggestions.replace(results);
});
// If someone presses enter then open up the first link, this is the default seach engine
// purely because it is at the top of the list.
document.querySelector('.search-form').addEventListener('submit', e => {
e.preventDefault();
if (searchinput.value != '') {
document.querySelector('.searchproviders li a').click();
}
});
}
}
search_close() {
let suggestions = this.showsearchbuttonelm.querySelector('.suggestionholder');
if (suggestions) {
suggestions.remove();
}
this.showsearchbuttonelm.classList.remove('suggestions');
document.querySelector('.search').classList.remove('open');
document.querySelector('.search-form input').value = '';
}
/**

View File

@@ -0,0 +1,122 @@
/**
* Generate search suggestions.
*
* @author Dale Davies <dale@daledavies.co.uk>
* @license MIT
*/
export default class SearchSuggestions {
constructor(searchengines, inputelm, containerelm, eventemitter) {
this.containerelm = containerelm;
this.eventemitter = eventemitter;
this.inputelm = inputelm;
this.suggestionslistelm = containerelm.querySelector('.suggestion-list');
this.searchproviderlist = null;
this.searchengines = searchengines;
}
build_searchprovider_list_elm(query) {
const searchproviderlist = document.createElement('ul');
searchproviderlist.classList.add('searchproviders');
searchproviderlist.setAttribute('tabindex', -1);
this.searchengines.forEach((provider) => {
const searchprovider = document.createElement('li');
searchprovider.setAttribute('tabindex', -1);
searchprovider.innerHTML = '<a target="_blank" rel="noopener" \
href="'+provider.url+encodeURIComponent(query)+'"><span>Search on</span> '+provider.name+'</a>';
searchproviderlist.appendChild(searchprovider);
});
searchproviderlist.addEventListener('keyup', e => {
switch (e.code) {
case 'ArrowUp':
if (document.activeElement == e.target.parentNode.parentNode.firstChild.firstChild) {
this.inputelm.focus();
break;
}
document.activeElement.parentNode.previousSibling.firstChild.focus();
break;
case 'ArrowDown':
if (document.activeElement == e.target.parentNode.parentNode.lastChild.firstChild) {
const suggestionselm = document.querySelector('.suggestionholder .suggestions');
if (suggestionselm) {
suggestionselm.firstChild.firstChild.focus();
} else {
e.target.parentNode.parentNode.firstChild.firstChild.focus();
}
break;
}
document.activeElement.parentNode.nextSibling.firstChild.focus();
break;
}
});
return searchproviderlist;
}
build_suggestion_list_elm(siteresults) {
const suggestionslist = document.createElement('ul');
suggestionslist.classList.add('suggestions');
suggestionslist.setAttribute('tabindex', -1);
siteresults.forEach((result) => {
const resultitem = document.createElement('li');
resultitem.setAttribute('tabindex', -1);
resultitem.innerHTML = '<a target="_blank" rel="noopener" href="'+result.url+'">\
<img class="icon" src="'+result.iconurl+'"><span class="name">'+result.name+'</span>';
suggestionslist.appendChild(resultitem);
});
suggestionslist.addEventListener('keyup', e => {
switch (e.code) {
case 'ArrowUp':
if (document.activeElement == e.target.parentNode.parentNode.firstChild.firstChild) {
this.searchproviderlist.lastChild.firstChild.focus();
break;
}
document.activeElement.parentNode.previousSibling.firstChild.focus();
break;
case 'ArrowDown':
if (document.activeElement == e.target.parentNode.parentNode.lastChild.firstChild) {
this.searchproviderlist.firstChild.firstChild.focus();
break;
}
document.activeElement.parentNode.nextSibling.firstChild.focus();
break;
}
});
return suggestionslist;
}
replace(siteresults) {
const newsuggestionslist = this.build_suggestion_list_elm(siteresults);
const suggestionholder = document.createElement('span');
suggestionholder.classList.add('suggestionholder');
if (this.inputelm.value !== '') {
const searchtitle = document.createElement('span');
searchtitle.classList.add('suggestiontitle');
searchtitle.innerHTML = 'Search';
suggestionholder.appendChild(searchtitle);
this.searchproviderlist = this.build_searchprovider_list_elm(this.inputelm.value);
suggestionholder.appendChild(this.searchproviderlist);
}
if (newsuggestionslist.childNodes.length > 0) {
const suggestiontitle = document.createElement('span');
suggestiontitle.classList.add('suggestiontitle');
suggestiontitle.innerHTML = 'Sites';
suggestionholder.appendChild(suggestiontitle);
suggestionholder.appendChild(newsuggestionslist)
}
if (suggestionholder.childNodes.length > 0) {
this.containerelm.classList.add('suggestions');
this.suggestionslistelm.replaceChildren(suggestionholder);
} else {
this.containerelm.classList.remove('suggestions');
let suggestions = this.containerelm.querySelector('.suggestionholder');
if (suggestions) {
suggestions.remove();
}
}
}
}

View File

@@ -29,6 +29,11 @@ class Cache {
public function __construct(private Config $config) {
// Define the various caches used throughout the app.
$this->caches = [
'searchengines' => [
'cache' => null,
'expirationtype' => Caching\Cache::FILES,
'expirationparams' => $config->get('searchenginesfile')
],
'sites' => [
'cache' => null,
'expirationtype' => Caching\Cache::FILES,

View File

@@ -24,6 +24,7 @@ class Config {
private const BASE_APPLICATION_PATHS = [
'backgroundsdir' => '/assets/backgrounds',
'defaulticonpath' => '/assets/images/default-icon.png',
'searchenginesfile' => '/search/searchengines.json',
'sitesdir' => '/sites',
'sitesfile' => '/sites/sites.json',
'templatedir' => '/templates',

View File

@@ -11,7 +11,7 @@ class HomePage extends AbstractPage {
$greeting = 'home';
}
$csrfsection = $this->session->getSection('csrf');
return $template->render([
$templatecontext = [
'csrftoken' => $csrfsection->get('token'),
'greeting' => $greeting,
'noindex' => $this->config->parse_bool($this->config->get('noindex')),
@@ -19,7 +19,13 @@ class HomePage extends AbstractPage {
'owmapikey' => !!$this->config->get('owmapikey', false),
'metrictemp' => $this->config->parse_bool($this->config->get('metrictemp')),
'ampmclock' => $this->config->parse_bool($this->config->get('ampmclock', false)),
]);
];
if ($this->config->parse_bool($this->config->get('showsearch', false))) {
$templatecontext = array_merge($templatecontext,
['searchengines' => json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines()),
'searchjson' => json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_search()),]);
}
return $template->render($templatecontext);
}
protected function render_content(): string {
@@ -42,7 +48,8 @@ class HomePage extends AbstractPage {
return $template->render([
'hastags' => !empty($tags),
'tags' => $tags,
'showclock' => $this->config->parse_bool($this->config->get('showclock'))
'showclock' => $this->config->parse_bool($this->config->get('showclock')),
'showsearch' => $this->config->parse_bool($this->config->get('showsearch', false)),
]);
});
}

View File

@@ -11,14 +11,20 @@ class TagPage extends AbstractPage {
$greeting = $this->param;
$title = 'Tag: '.$this->param;
$csrfsection = $this->session->getSection('csrf');
return $template->render([
$templatecontext = [
'csrftoken' => $csrfsection->get('token'),
'greeting' => $greeting,
'noindex' => $this->config->parse_bool($this->config->get('noindex')),
'title' => $title,
'owmapikey' => !!$this->config->get('owmapikey', false),
'metrictemp' => $this->config->parse_bool($this->config->get('metrictemp')),
]);
];
if ($this->config->parse_bool($this->config->get('showsearch', false))) {
$templatecontext = array_merge($templatecontext,
['searchengines' => json_encode((new \Jump\SearchEngines($this->config, $this->cache))->get_search_engines()),
'searchjson' => json_encode((new \Jump\Sites($this->config, $this->cache))->get_sites_for_search()),]);
}
return $template->render($templatecontext);
}
protected function render_content(): string {
@@ -48,7 +54,8 @@ class TagPage extends AbstractPage {
return $template->render([
'hastags' => !empty($tags),
'tags' => $tags,
'showclock' => $this->config->parse_bool($this->config->get('showclock'))
'showclock' => $this->config->parse_bool($this->config->get('showclock')),
'showsearch' => $this->config->parse_bool($this->config->get('showsearch', false)),
]);
});
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Jump;
use \Exception;
/**
* Loads and validates the search engines defined in searchengines.json.
*
* @author Dale Davies <dale@daledavies.co.uk>
* @license MIT
*/
class SearchEngines {
private array $default;
private string $searchfilelocation;
private array $loadedsearchengines;
/**
* Automatically load searchengines.json on instantiation.
*/
public function __construct(private Config $config, private Cache $cache) {
$this->config = $config;
$this->loadedsearchengines = [];
$this->searchfilelocation = $this->config->get('searchenginesfile');
$this->cache = $cache;
// Retrieve search engines from cache. Load from json file if not cached or
// the cache has expired.
$this->loadedsearchengines = $this->cache->load(cachename: 'searchengines', callback: function() {
return $this->load_search_engines_from_json();
});
}
/**
* Try to load and validate the list of search engines from searchengines.json.
*
* Throws an exception if the file cannot be loaded, is empty, or cannot
* be decoded to an array.
*
* @return array AArray of parsed/validated search engine information from searchengines.json
* @throws Exception If searchengines.json cannot be found.
*/
private function load_search_engines_from_json(): array {
$searchengines = [];
$rawjson = file_get_contents($this->searchfilelocation);
if ($rawjson === false) {
throw new Exception('There was a problem loading the searchengines.json file');
}
if ($rawjson === '') {
throw new Exception('The searchengines.json file is empty');
}
// Do some checks to see if the JSON decodes into something
// like what we expect to see...
$decodedjson = json_decode($rawjson);
if (!is_array($decodedjson)) {
throw new Exception('The searchengines.json file is invalid');
}
// Build a new array using the values we need...
foreach ($decodedjson as $item) {
if (!isset($item->name, $item->url)) {
throw new Exception('The searchengines.json does not contain the "name" or "url" properties');
}
$searchengine = new \stdClass();
$searchengine->name = $item->name;
$searchengine->url = $item->url;
$searchengines[] = $searchengine;
}
return $searchengines;
}
/**
* Get the list of loaded search engines.
*
* @return array Array of parsed/validated search engine information from searchengines.json
*/
public function get_search_engines() {
return $this->loadedsearchengines;
}
}

View File

@@ -156,4 +156,17 @@ class Sites {
return $found;
}
public function get_sites_for_search(): array {
$searchlist = [];
foreach ($this->loadedsites as $loadedsite) {
$site = new \stdClass();
$site->name = $loadedsite->name;
$site->url = $loadedsite->url;
$site->tags = $loadedsite->tags;
$site->iconurl = '/api/icon.php?siteurl='.urlencode($loadedsite->url);
$searchlist[] = $site;
}
return $searchlist;
}
}

View File

@@ -30,7 +30,9 @@ return [
// Background brightness percentage.
'bgbright' => getenv('BGBRIGHT') ?: '85',
// Display alternative layout of sites list.
'altlayout' => getenv('ALTLAYOUT') ?: false,
'altlayout' => getenv('ALTLAYOUT') ?: false,
// Show the search bar, requires /search/searchengines.json etc.
'showsearch' => getenv('SHOWSEARCH') ?: true,
// Open Weather Map API key.
'owmapikey' => getenv('OWMAPIKEY') ?: '',

View File

@@ -0,0 +1,14 @@
[
{
"name": "Google",
"url": "https://www.google.co.uk/search?q="
},
{
"name": "DuckDuckGo",
"url": "https://duckduckgo.com/?q="
},
{
"name": "Bing",
"url": "https://www.bing.com/search?q="
}
]

View File

@@ -11,6 +11,15 @@
</span>
<span class="useclientlocation widget clickable"></span>
<div class="header-bar">
{{# showsearch}}
<span class="search">
<span class="close"></span>
<form class="search-form">
<input type="search">
</form>
<span class="suggestion-list"></span>
</span>
{{/ showsearch}}
{{# hastags }}<a href="#tags" class="show-tags"></a>{{/ hastags }}
</div>
{{# hastags}}
@@ -23,6 +32,6 @@
</div>
{{/ hastags}}
<div class="background fixed"></div>
<script defer src="/assets/js/index.52c1dcb71f05502fb292.min.js"></script>
<script defer src="/assets/js/index.1fd54d01d088096b9a90.min.js"></script>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{# noindex}}<meta name="robots" content="noindex">{{/ noindex}}
<link rel="stylesheet" href="/assets/css/styles.d5cd9fa42e523236402f.min.css">
<link rel="stylesheet" href="/assets/css/styles.2596a987cbdacf25a231.min.css">
<link rel="stylesheet" href="/background-css.php">
<link rel="stylesheet" href="/assets/css/weather-icons.min.css">
<link rel="icon" type="image/png" href="/favicon.png">
@@ -14,7 +14,9 @@
owmapikey: '{{owmapikey}}',
metrictemp: '{{metrictemp}}',
ampmclock: '{{ampmclock}}',
token: '{{csrftoken}}'
token: '{{csrftoken}}',
search: '{{{searchjson}}}',
searchengines: '{{{searchengines}}}',
};
</script>
</head>

View File

@@ -11,6 +11,15 @@
</span>
<span class="useclientlocation widget clickable"></span>
<div class="header-bar">
{{# showsearch}}
<span class="search">
<span class="close"></span>
<form class="search-form">
<input type="search">
</form>
<span class="suggestion-list"></span>
</span>
{{/ showsearch}}
{{# hastags }}<a href="#tags" class="show-tags"></a>{{/ hastags }}
</div>
{{# hastags}}

View File

@@ -14,7 +14,9 @@
owmapikey: '{{owmapikey}}',
metrictemp: '{{metrictemp}}',
ampmclock: '{{ampmclock}}',
token: '{{csrftoken}}'
token: '{{csrftoken}}',
search: '{{{searchjson}}}',
searchengines: '{{{searchengines}}}',
};
</script>
</head>

3388
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"webpack-remove-empty-scripts": "^0.8.0"
},
"dependencies": {
"eventemitter3": "^4.0.7"
"eventemitter3": "^4.0.7",
"fuse.js": "^6.6.2"
}
}

View File

@@ -38,13 +38,13 @@ module.exports = {
plugins: [
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, './jumpapp/templates/header.mustache'),
template: path.resolve(__dirname, './jumpapp/templates/src/header.mustache'),
template: path.resolve(__dirname, './jumpapp/templates/src/header.src.mustache'),
inject: false,
minify: false, // Required to prevent addition of closing tags like body and html.
}),
new HtmlWebpackPlugin({
filename: path.resolve(__dirname, './jumpapp/templates/footer.mustache'),
template: path.resolve(__dirname, './jumpapp/templates/src/footer.mustache'),
template: path.resolve(__dirname, './jumpapp/templates/src/footer.src.mustache'),
inject: false,
minify: false, // Required to prevent addition of closing tags like body and html.
}),