mirror of
https://github.com/xvvvyz/tilde.git
synced 2026-03-11 14:44:24 +00:00
684 lines
17 KiB
HTML
684 lines
17 KiB
HTML
<!doctype html>
|
|
|
|
<script>
|
|
const CONFIG = {
|
|
categories: [
|
|
{ name: "Learn", commands: [
|
|
{ key: 'c', name: 'Coursera', url: 'https://www.coursera.org', search: '/courses?query=' },
|
|
{ key: 'e', name: 'Egghead', url: 'https://egghead.io', search: '/search?q=' },
|
|
{ key: 'K', name: 'Khan Academy', url: 'https://www.khanacademy.org', search: '/search?page_search_query=' },
|
|
] },
|
|
{ name: "Listen", commands: [
|
|
{ key: 'h', name: 'Hypem', url: 'http://hypem.com', search: '/search/' },
|
|
{ key: 'l', name: 'Line Radio', url: 'https://linerad.io', search: '/#/' },
|
|
{ key: 's', name: 'SoundCloud', url: 'https://soundcloud.com', search: '/search?q=' },
|
|
] },
|
|
{ name: "Tools", commands: [
|
|
{ key: 'g', name: 'GitHub', url: 'https://github.com', search: '/search?q=' },
|
|
{ key: 'm', name: 'Inbox', url: 'https://inbox.google.com', search: '/search/' },
|
|
{ key: 'k', name: 'Keep', url: 'https://keep.google.com', search: '/#search/text=' },
|
|
] },
|
|
{ name: "Download", commands: [
|
|
{ key: 'T', name: 'The Pirate Bay', url: 'https://thepiratebay.org', search: '/search/' },
|
|
{ key: 'Y', name: 'YIFY', url: 'https://yts.ag', search: '/browse-movies/' },
|
|
{ key: '7', name: '7digital', url: 'https://us.7digital.com', search: '/search?q=' },
|
|
] },
|
|
],
|
|
|
|
// if none of the keys are matched, this is used for searching.
|
|
defaultSearch: 'https://encrypted.google.com/search?q=',
|
|
|
|
// the delimiter between the key and your search query.
|
|
// e.g. to search GitHub for potatoes you'd type "g:potatoes".
|
|
searchDelimiter: ':',
|
|
|
|
// the delimiter between the key and a path.
|
|
// e.g. type "r/r/unixporn" to go to "reddit.com/r/unixporn".
|
|
pathDelimiter: '/',
|
|
|
|
// set to true to instantly redirect when a key is matched.
|
|
// put a space before any search queries to prevent unwanted redirects.
|
|
instantRedirect: false,
|
|
|
|
// suggest your most popular queries as you type.
|
|
suggestions: true,
|
|
|
|
// max amount of suggestions to display.
|
|
suggestionsLimit: 4,
|
|
|
|
// open queries in a new tab.
|
|
newTab: true,
|
|
|
|
// the delimiter between the hours and minutes in the clock.
|
|
clockDelimiter: ' ',
|
|
|
|
// used for determining when to redirect directly to a url.
|
|
urlRegex: /^(?:(http|https)?:\/\/)?(?:[\w-]+\.)+([a-z]|[A-Z]|[0-9]){2,6}/i,
|
|
|
|
// if "urlRegex" matches but this doesn't, "http://" will be
|
|
// prepended to the beginning of the query before redirecting.
|
|
protocolRegex: /^[a-zA-Z]+:\/\//i
|
|
};
|
|
</script>
|
|
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex">
|
|
|
|
<title>~</title>
|
|
|
|
<style type="text/css">
|
|
@font-face {
|
|
font-family: 'Lato';
|
|
src: url('https://fonts.gstatic.com/s/lato/v11/22JRxvfANxSmnAhzbFH8PgLUuEpTyoUstqEm5AMlJo4.woff2') format('woff2');
|
|
}
|
|
|
|
body {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
margin: 0;
|
|
padding: 0;
|
|
color: #333;
|
|
font-family: 'Lato', sans-serif;
|
|
}
|
|
|
|
main {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: 0;
|
|
left: 0;
|
|
width: 90%;
|
|
max-width: 310px;
|
|
margin: 0 auto;
|
|
transform: translateY(-100px);
|
|
text-align: center;
|
|
}
|
|
|
|
time {
|
|
display: block;
|
|
margin-bottom: 20px;
|
|
font-size: 5rem;
|
|
letter-spacing: 6px;
|
|
}
|
|
|
|
input,
|
|
input:focus {
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
margin: 0;
|
|
border: 0;
|
|
background: #222;
|
|
outline: 0;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
color: #fff;
|
|
font-family: 'Lato', sans-serif;
|
|
}
|
|
|
|
ul,
|
|
li {
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.search-input {
|
|
padding: 12px;
|
|
border-radius: 2px;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
body[search-suggestions='true'] .search-input {
|
|
border-radius: 2px 2px 0 0;
|
|
}
|
|
|
|
.search-suggestions {
|
|
border-radius: 0 0 2px 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.search-suggestion {
|
|
padding: 14px 12px;
|
|
transition: 0.5s;
|
|
font-size: .8rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
}
|
|
|
|
.search-suggestion:hover,
|
|
.search-suggestion:focus {
|
|
background: #30a388;
|
|
}
|
|
|
|
.overlay {
|
|
position: fixed;
|
|
box-sizing: border-box;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 0 10px;
|
|
transition: 200ms;
|
|
background: #222;
|
|
visibility: hidden;
|
|
opacity: 0;
|
|
overflow: auto;
|
|
}
|
|
|
|
.overlay[data-toggled='true'] {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
|
|
.lists {
|
|
display: flex;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
padding-bottom: 20px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.category {
|
|
width: 175px;
|
|
margin: 20px 10px 0;
|
|
}
|
|
|
|
.category-name {
|
|
margin: 0;
|
|
padding: 14px;
|
|
border-radius: 2px 2px 0 0;
|
|
background: #30a388;
|
|
color: #000;
|
|
font-size: .7rem;
|
|
text-transform: uppercase;
|
|
text-align: center;
|
|
}
|
|
|
|
.command {
|
|
transition: background 200ms;
|
|
background: #333;
|
|
}
|
|
|
|
.command:hover {
|
|
background: #313131;
|
|
}
|
|
|
|
.command:nth-child(even) {
|
|
background: #3a3a3a;
|
|
}
|
|
|
|
.command:nth-child(even):hover {
|
|
background: #383838;
|
|
}
|
|
|
|
.command:last-of-type {
|
|
border-radius: 0 0 2px 2px;
|
|
}
|
|
|
|
.command a {
|
|
display: block;
|
|
padding: 14px 12px;
|
|
color: #eee;
|
|
font-size: .8rem;
|
|
line-height: 1.3rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.command-key {
|
|
display: block;
|
|
width: 32px;
|
|
margin-right: 10px;
|
|
float: left;
|
|
border-radius: 2px;
|
|
background: #292929;
|
|
font-family: 'Courier New', monospace;
|
|
text-align: center;
|
|
}
|
|
|
|
@media (min-width: 740px) {
|
|
.overlay {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<main>
|
|
<time id="js-clock"></time>
|
|
<form id="js-search-form" autocomplete="off">
|
|
<input id="js-search-input" class="search-input" type="text" autofocus>
|
|
<ul id="js-search-suggestions" class="search-suggestions"></ul>
|
|
</form>
|
|
</main>
|
|
|
|
<aside id="js-overlay" class="overlay">
|
|
<ul id="js-lists" class="lists"></ul>
|
|
</aside>
|
|
|
|
<script>
|
|
const $ = {
|
|
el: s => {
|
|
return document.querySelector(s);
|
|
},
|
|
|
|
els: s => {
|
|
return document.querySelectorAll(s);
|
|
},
|
|
};
|
|
|
|
class Clock {
|
|
constructor() {
|
|
this._clockEl = $.el('#js-clock');
|
|
this._setTime = this._setTime.bind(this);
|
|
this._start();
|
|
}
|
|
|
|
_pad(num) {
|
|
return (`0${num.toString()}`).slice(-2);
|
|
}
|
|
|
|
_setTime() {
|
|
const date = new Date();
|
|
const hours = this._pad(date.getHours());
|
|
const minutes = this._pad(date.getMinutes());
|
|
this._clockEl.innerHTML = `${hours}${CONFIG.clockDelimiter}${minutes}`;
|
|
}
|
|
|
|
_start() {
|
|
this._setTime();
|
|
setInterval(this._setTime, 1000);
|
|
}
|
|
}
|
|
|
|
class Help {
|
|
constructor() {
|
|
this._overlayEl = $.el('#js-overlay');
|
|
this._listsEl = $.el('#js-lists');
|
|
this._buildAndAppendLists();
|
|
}
|
|
|
|
toggle(show) {
|
|
const toggle = (typeof show !== 'undefined') ? show :
|
|
this._overlayEl.getAttribute('data-toggled') !== 'true';
|
|
|
|
this._overlayEl.setAttribute('data-toggled', toggle);
|
|
}
|
|
|
|
_buildAndAppendLists() {
|
|
CONFIG.categories.forEach(category => {
|
|
this._listsEl.insertAdjacentHTML(
|
|
'beforeend',
|
|
`<li class="category">
|
|
<h2 class="category-name">${category.name}</h2>
|
|
<ul>${this._buildListCommands(category)}</ul>
|
|
</li>`
|
|
);
|
|
});
|
|
}
|
|
|
|
_buildListCommands(category) {
|
|
return category.commands.map(({ url, key, name }) => (
|
|
`<li class="command">
|
|
<a href="${url}">
|
|
<span class="command-key">${key}</span>
|
|
<span class="command-name">${name}</span>
|
|
</a>
|
|
</li>`
|
|
)).join('');
|
|
}
|
|
}
|
|
|
|
class History {
|
|
constructor() {
|
|
this._dbName = 'history';
|
|
this._history = this._getHistoryCache();
|
|
}
|
|
|
|
addItem(query) {
|
|
if (!this._isValidQuery(query)) return false;
|
|
this._updateHistory(query);
|
|
this._sortHistory();
|
|
this._setHistoryCache();
|
|
}
|
|
|
|
getPromise(query) {
|
|
return new Promise(resolve => {
|
|
if (CONFIG.suggestionsLimit < 1) resolve([]);
|
|
|
|
const suggestions = this._getHistory()
|
|
.filter(item => this._itemContainsQuery(query, item[0]))
|
|
.slice(0, CONFIG.suggestionsLimit)
|
|
.map(item => item[0]);
|
|
|
|
resolve(suggestions);
|
|
});
|
|
}
|
|
|
|
_getHistory() {
|
|
return this._history;
|
|
}
|
|
|
|
_getHistoryCache() {
|
|
return JSON.parse(localStorage.getItem(this._dbName)) || [];
|
|
}
|
|
|
|
_isValidQuery(item) {
|
|
return item.length > 1;
|
|
}
|
|
|
|
_itemContainsQuery(query, item) {
|
|
const match = item.indexOf(query) !== -1;
|
|
return query && match && query !== item;
|
|
}
|
|
|
|
_setHistoryCache() {
|
|
localStorage.setItem(this._dbName, JSON.stringify(this._history));
|
|
}
|
|
|
|
_sortHistory() {
|
|
this._history = this._history.sort(function(current, next) {
|
|
return current[1] > next[1];
|
|
}).reverse();
|
|
}
|
|
|
|
_updateHistory(query) {
|
|
let exists = false;
|
|
|
|
this._history = this._history.map(item => {
|
|
if (item[0] === query) {
|
|
item[1]++;
|
|
exists = true;
|
|
}
|
|
|
|
return item
|
|
});
|
|
|
|
if (!exists) this._history.push([query, 1]);
|
|
}
|
|
}
|
|
|
|
class Suggester {
|
|
constructor(influencers) {
|
|
this._inputEl = $.el('#js-search-input');
|
|
this._suggestionsEl = $.el('#js-search-suggestions');
|
|
this._suggestionEls = [];
|
|
this._influencers = influencers;
|
|
this._handleKeydown = this._handleKeydown.bind(this);
|
|
this._registerEvents();
|
|
}
|
|
|
|
setClickEventCallback(callback) {
|
|
this._clickEventCallback = callback;
|
|
}
|
|
|
|
suggest(input) {
|
|
this._clearSuggestions();
|
|
if (!input) return;
|
|
|
|
this._influencers
|
|
.map(influencer => influencer.getPromise(input))
|
|
.forEach(promise => {
|
|
promise.then(suggestions => {
|
|
this._clearClickEvents();
|
|
|
|
suggestions.forEach(suggestion => {
|
|
this._appendSuggestion(suggestion);
|
|
});
|
|
|
|
this._suggestionEls = $.els('.js-search-suggestion');
|
|
this._registerClickEvents();
|
|
});
|
|
});
|
|
}
|
|
|
|
_appendSuggestion(suggestion) {
|
|
if (this._suggestionsEl.children.length === CONFIG.suggestionsLimit) return false;
|
|
|
|
this._suggestionsEl.insertAdjacentHTML(
|
|
'beforeend',
|
|
`<li>
|
|
<input
|
|
class="js-search-suggestion search-suggestion"
|
|
type="button"
|
|
value="${suggestion}"
|
|
>
|
|
</li>`
|
|
);
|
|
|
|
document.body.setAttribute('search-suggestions', true);
|
|
}
|
|
|
|
_clearClickEvents() {
|
|
if (typeof this._suggestionEls !== 'undefined') {
|
|
this._suggestionEls.forEach(el => {
|
|
this._removeClickEvent(el, this._clickEventCallback.bind(null, el.value));
|
|
});
|
|
}
|
|
}
|
|
|
|
_clearSuggestions() {
|
|
this._suggestionsEl.innerHTML = '';
|
|
document.body.setAttribute('search-suggestions', false);
|
|
}
|
|
|
|
_focusNext() {
|
|
if (this._suggestionEls) {
|
|
const active = document.activeElement;
|
|
|
|
if (active.classList.contains('js-search-suggestion')) {
|
|
this._suggestionEls.forEach((el, index) => {
|
|
const nextSuggestion = this._suggestionEls[index + 1];
|
|
if (el === active && nextSuggestion) nextSuggestion.focus();
|
|
});
|
|
} else {
|
|
this._suggestionEls[0].focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
_focusPrevious() {
|
|
if (this._suggestionEls) {
|
|
const active = document.activeElement;
|
|
|
|
if (active.classList.contains('js-search-suggestion')) {
|
|
this._suggestionEls.forEach((el, index) => {
|
|
if (el === active) {
|
|
const previousSuggestion = this._suggestionEls[index - 1];
|
|
if (previousSuggestion) previousSuggestion.focus();
|
|
else this._inputEl.focus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_handleKeydown(event) {
|
|
const isCtrlN = event.which === 78 && event.ctrlKey;
|
|
const isCtrlP = event.which === 80 && event.ctrlKey;
|
|
if (isCtrlN || isCtrlP) event.preventDefault();
|
|
if (isCtrlN) this._focusNext();
|
|
if (isCtrlP) this._focusPrevious();
|
|
}
|
|
|
|
_registerClickEvents() {
|
|
this._suggestionEls.forEach(el => {
|
|
const callback = this._clickEventCallback.bind(null, el.value);
|
|
el.addEventListener('click', callback);
|
|
});
|
|
}
|
|
|
|
_registerEvents() {
|
|
document.addEventListener('keydown', this._handleKeydown);
|
|
}
|
|
|
|
_removeClickEvent(el, callback) {
|
|
el.removeEventListener('click', callback);
|
|
}
|
|
}
|
|
|
|
class QueryParser {
|
|
generateRedirect(query) {
|
|
let redirectUrl = CONFIG.defaultSearch + encodeURIComponent(query);
|
|
|
|
if (query.match(CONFIG.urlRegex)) {
|
|
const hasProtocol = query.match(CONFIG.protocolRegex);
|
|
redirectUrl = hasProtocol ? query : 'http://' + query;
|
|
} else {
|
|
const splitSearch = query.split(CONFIG.searchDelimiter);
|
|
const splitPath = query.split(CONFIG.pathDelimiter);
|
|
|
|
this._loopThroughCommands(command => {
|
|
const isSearch = splitSearch[0] === command.key;
|
|
const isPath = splitPath[0] === command.key;
|
|
|
|
if (isSearch || isPath) {
|
|
if (splitSearch[1] && command.search) {
|
|
redirectUrl = this._prepSearch(command, splitSearch);
|
|
} else if (splitPath[1]) {
|
|
redirectUrl = this._prepPath(command, splitPath);
|
|
} else {
|
|
redirectUrl = command.url;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
return redirectUrl;
|
|
}
|
|
|
|
instantRedirect(keypressEvent, query, callback) {
|
|
this._loopThroughCommands(command => {
|
|
if (command.key === query) {
|
|
keypressEvent.preventDefault();
|
|
callback(command.url);
|
|
}
|
|
})
|
|
}
|
|
|
|
_loopThroughCommands(callback) {
|
|
CONFIG.categories
|
|
.map(category => category.commands)
|
|
.forEach(commands => commands.forEach(command => {
|
|
let done = callback(command);
|
|
if (done) return;
|
|
}));
|
|
}
|
|
|
|
_prepPath(command, query) {
|
|
const path = this._shiftAndTrim(query, CONFIG.pathDelimiter);
|
|
return `${command.url}/${path}`;
|
|
}
|
|
|
|
_prepSearch(command, query) {
|
|
const search = this._shiftAndTrimAndEncode(query, CONFIG.searchDelimiter);
|
|
return `${command.url}${command.search}${search}`;
|
|
}
|
|
|
|
_shiftAndTrim(arr, delimiter) {
|
|
arr.shift();
|
|
return arr.join(delimiter).trim();
|
|
}
|
|
|
|
_shiftAndTrimAndEncode(arr, delimiter) {
|
|
return encodeURIComponent(this._shiftAndTrim(arr, delimiter));
|
|
}
|
|
}
|
|
|
|
class Form {
|
|
constructor(help, history, suggestionAggregator, queryParser) {
|
|
this._help = help;
|
|
this._history = history;
|
|
this._suggestionAggregator = suggestionAggregator;
|
|
this._queryParser = queryParser;
|
|
this._formEl = $.el('#js-search-form');
|
|
this._inputEl = $.el('#js-search-input');
|
|
this._inputElVal = '';
|
|
this._handleKeypress = this._handleKeypress.bind(this);
|
|
this._submitForm = this._submitForm.bind(this);
|
|
this._handleKeyup = this._handleKeyup.bind(this);
|
|
this._submitWithValue = this._submitWithValue.bind(this);
|
|
this._registerEvents();
|
|
|
|
this._suggestionAggregator.setClickEventCallback(value => {
|
|
this._submitWithValue(value)
|
|
});
|
|
}
|
|
|
|
_clearInput() {
|
|
this._inputEl.value = '';
|
|
}
|
|
|
|
_handleKeypress(event) {
|
|
const newChar = String.fromCharCode(event.which);
|
|
const isNotEmpty = newChar.length;
|
|
const isEnterKey = event.which !== 13;
|
|
|
|
if (isNotEmpty && isEnterKey) {
|
|
this._help.toggle(false);
|
|
this._inputEl.focus();
|
|
}
|
|
|
|
if (CONFIG.instantRedirect) {
|
|
this._queryParser.instantRedirect(
|
|
event,
|
|
this._inputEl.value + newChar,
|
|
this._submitWithValue
|
|
);
|
|
}
|
|
}
|
|
|
|
_handleKeyup(event) {
|
|
if (this._inputElVal !== this._inputEl.value) {
|
|
if (CONFIG.suggestions) {
|
|
this._suggestionAggregator.suggest(this._inputEl.value.trim());
|
|
}
|
|
|
|
this._inputElVal = this._inputEl.value;
|
|
}
|
|
}
|
|
|
|
_redirect(redirect) {
|
|
if (CONFIG.newTab) window.open(redirect, '_blank');
|
|
else window.location.href = redirect;
|
|
}
|
|
|
|
_registerEvents() {
|
|
document.addEventListener('keypress', this._handleKeypress);
|
|
this._inputEl.addEventListener('keyup', this._handleKeyup);
|
|
this._formEl.addEventListener('submit', this._submitForm, false);
|
|
}
|
|
|
|
_submitForm(event) {
|
|
if (event) event.preventDefault();
|
|
const query = this._inputEl.value.trim();
|
|
|
|
if (!query || query === '?') {
|
|
this._clearInput();
|
|
this._help.toggle();
|
|
} else {
|
|
this._history.addItem(query);
|
|
this._suggestionAggregator.suggest('');
|
|
this._inputEl.value = '';
|
|
this._redirect(this._queryParser.generateRedirect(query));
|
|
}
|
|
}
|
|
|
|
_submitWithValue(value) {
|
|
this._inputEl.value = value;
|
|
this._submitForm();
|
|
}
|
|
}
|
|
|
|
const clock = new Clock();
|
|
const help = new Help();
|
|
const history = new History();
|
|
const aggregator = new Suggester([history]);
|
|
const parser = new QueryParser();
|
|
const form = new Form(help, history, aggregator, parser);
|
|
</script>
|