tilde/index.html
2017-06-29 01:01:06 -07:00

768 lines
20 KiB
HTML

<!doctype html>
<script>
const CONFIG = {
categories: [
{ name: "Work", 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: "Lurk", commands: [
{ key: 'i', name: 'Instagram', url: 'https://www.instagram.com', search: false },
{ key: 'r', name: 'Reddit', url: 'https://www.reddit.com', search: '/search?q={}' },
{ key: 't', name: 'Twitter', url: 'https://twitter.com', search: '/search?q={}' },
] },
{ name: "Listen", commands: [
{ key: 'h', name: 'Hypem', url: 'http://hypem.com/popular', search: '/search/{}' },
{ key: 'l', name: 'Line Radio', url: 'https://linerad.io', search: '/#{}' },
{ key: 's', name: 'SoundCloud', url: 'https://soundcloud.com/discover', search: '/search?q={}' },
] },
{ name: "Watch", commands: [
{ key: 'n', name: 'Netflix', url: 'https://www.netflix.com/browse', search: '/search?q={}' },
{ key: 'w', name: 'Twitch', url: 'https://www.twitch.tv/directory/following', search: false },
{ key: 'y', name: 'YouTube', url: 'https://youtube.com/feed/subscriptions', search: '/results?search_query={}' },
] },
{ name: "Download", commands: [
{ key: 'T', name: 'TPB', url: 'https://thepiratebay.org', search: '/search/{}' },
{ key: 'Y', name: 'YIFY', url: 'https://yts.ag/browse-movies/0/1080p/all/7/latest', search: '/browse-movies/{}/1080p/all/0/rating' },
{ 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: '/',
// instantly redirect when a key is matched.
// put a space before any other queries to prevent unwanted redirects.
instantRedirect: false,
// give suggestions as you type.
suggestions: true,
// max amount of suggestions that will ever be displayed.
suggestionsLimit: 4,
// the order and limit for each suggestion influencer.
// the following would give you 1 suggestion from your search history
// and 4 suggestions from Duck Duck Go.
influencers: [
{ name: 'History', limit: 1 },
{ name: 'DuckDuckGo', limit: 4 },
],
// open queries in a new tab.
newTab: true,
// the delimiter between the hours and minutes in the clock.
clockDelimiter: '&nbsp;',
// 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,
button,
input:focus,
button:focus {
box-sizing: border-box;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #fff;
font-family: 'Lato', sans-serif;
-webkit-appearance: none;
-moz-appearance: none;
}
ul,
li {
margin: 0;
padding: 0;
list-style: none;
}
.search-input,
.search-input:focus {
padding: 12px;
border-radius: 2px;
background: #222;
font-size: 1.1rem;
}
.search-input.bottom-no-radius {
border-radius: 2px 2px 0 0;
}
.search-suggestions {
display: none;
padding: 4px 0;
border-radius: 0 0 2px 2px;
background: #111;
overflow: hidden;
}
.search-suggestions.active {
display: block;
}
.search-suggestion {
margin: -4px 0;
padding: 10px 12px;
transition: background .2s;
font-size: .8rem;
text-align: left;
cursor: pointer;
}
.search-suggestion:hover,
.search-suggestion:focus {
background: #333;
}
.overlay {
position: fixed;
top: 0;
left: 0;
visibility: hidden;
overflow: auto;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 8vw;
transition: visibility .2s, opacity .2s, transform .4s;
transform: translateY(10px);
opacity: 0;
background: #00d2ff;
background: -webkit-linear-gradient(to top left, #00d2ff, #3a7bd5);
background: linear-gradient(to top left, #00d2ff, #3a7bd5);
font-size: 1.2rem;
}
.overlay[data-toggled='true'] {
visibility: visible;
transform: translateY(0);
opacity: 1;
}
.category {
width: 100%;
margin-bottom: 3em;
}
.category-name {
margin: 0 0 2em;
color: rgba(255, 255, 255, .6);
font-size: .7em;
letter-spacing: .2em;
text-transform: uppercase;
}
.command a {
display: block;
position: relative;
padding: .5em 0;
color: #fff;
font-size: 1em;
line-height: 2em;
text-decoration: none;
}
.command-key {
display: block;
float: left;
width: 2.5em;
margin-right: 1em;
border-radius: 50%;
background-color: rgba(255, 255, 255, .1);
font-size: .8em;
text-align: center;
}
.command-name {
position: relative;
overflow: hidden;
}
.command-name::after {
content: ' ';
position: absolute;
right: 0;
bottom: -.35em;
left: 0;
height: 2px;
transition: .2s;
transform: translateX(-2em);
background-color: rgba(255, 255, 255, .2);
opacity: 0;
}
.command:hover .command-name::after {
transform: translateX(0);
opacity: 1;
}
@media (min-width: 1000px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
padding: 2em;
font-size: 1.3vw;
}
.lists {
display: flex;
justify-content: space-around;
width: 100%;
}
.category {
width: 10em;
margin-bottom: 0;
}
}
</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 => document.querySelector(s),
els: s => [].slice.call(document.querySelectorAll(s) || []),
jsonp: url => {
let script = document.createElement('script');
script.src = url;
$.el('head').appendChild(script);
},
};
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._handleKeydown = this._handleKeydown.bind(this);
this._buildAndAppendLists();
this._registerEvents();
}
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}" target="${CONFIG.newTab ? '_blank' : '_self'}">
<span class="command-key">${key}</span>
<span class="command-name">${name}</span>
</a>
</li>`
)).join('');
}
_handleKeydown(event) {
const isEsc = event.which === 27;
if (isEsc) this.toggle(false);
}
_registerEvents() {
document.addEventListener('keydown', this._handleKeydown);
}
}
class History {
constructor(suggestionsLimit = 0) {
this._suggestionsLimit = suggestionsLimit;
this._history = this._getFromStorage('history');
}
addItem(query) {
if (query.length < 2) return;
this._updateHistory(query);
this._sortHistory();
this._saveToStorage('history', this._history);
}
getSuggestionsPromise(query) {
return new Promise(resolve => {
const suggestions = this._history
.filter(item => this._itemContainsQuery(query, item[0]))
.slice(0, this._suggestionsLimit)
.map(item => item[0]);
resolve(suggestions);
});
}
_getFromStorage(name) {
return JSON.parse(localStorage.getItem(name)) || [];
}
_itemContainsQuery(query, item) {
return query && item.indexOf(query) !== -1;
}
_saveToStorage(name, data) {
localStorage.setItem(name, JSON.stringify(data));
}
_sortHistory() {
this._history = this._history
.sort((current, next) => 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 DuckDuckGo {
constructor(suggestionsLimit = 0) {
this._endpoint = 'https://duckduckgo.com/ac/';
this._callback = 'autocompleteCallback';
this._suggestionsLimit = suggestionsLimit;
}
addItem() {}
getSuggestionsPromise(query) {
return new Promise(resolve => {
this._resolve = resolve;
window[this._callback] = this._handleResponse.bind(this);
$.jsonp(`${this._endpoint}?callback=${this._callback}&q=${query}`);
});
}
_handleResponse(res) {
const suggestions = res.slice(0, this._suggestionsLimit)
.map(i => i.phrase);
this._resolve(suggestions);
}
}
class Suggester {
constructor(influencers) {
this._inputEl = $.el('#js-search-input');
this._suggestionsEl = $.el('#js-search-suggestions');
this._totalSuggestions = 0;
this._suggestionEls = [];
this._influencers = influencers;
this._handleKeydown = this._handleKeydown.bind(this);
document.addEventListener('keydown', this._handleKeydown);
}
add(query) {
this._influencers.forEach(i => i.addItem(query));
}
suggest(input, clickCallback = () => {}) {
if (!input) {
this._clearSuggestions();
return;
}
this._handleClick = clickCallback;
this._suggest(input);
}
_appendSuggestion(suggestion, input) {
const suggestionHtml = suggestion
.replace(new RegExp(input, 'g'), `<b>${input}</b>`);
this._suggestionsEl.insertAdjacentHTML(
'beforeend',
`<li>
<button
type="button"
class="js-search-suggestion search-suggestion"
data-suggestion="${suggestion}"
>
${suggestionHtml}
</button>
</li>`
);
this._suggestionsEl.classList.add('active');
this._inputEl.classList.add('bottom-no-radius');
return ++this._totalSuggestions === CONFIG.suggestionsLimit;
}
_clearClickEvents() {
this._suggestionEls.forEach(el => {
const callback = this._handleClick.bind(null, el.value);
el.removeEventListener('click', callback);
});
}
_clearSuggestions() {
this._totalSuggestions = 0;
this._clearClickEvents();
this._suggestionsEl.innerHTML = '';
this._suggestionsEl.classList.remove('active');
this._inputEl.classList.remove('bottom-no-radius');
}
// [[1, 2], [1, 2, 3, 4]] -> [1, 2, 3, 4]
_flattenAndUnique(array) {
return [...new Set([].concat.apply([], array))];
}
_focusNext() {
if (this._suggestionEls.length) {
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.length) {
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();
}
});
}
}
}
_gatherInfluencers(input) {
return this._influencers
.map(influencer => influencer.getSuggestionsPromise(input));
}
_handleKeydown(event) {
const isDown = event.which === 40;
const isUp = event.which === 38;
const isCtrlN = event.which === 78 && event.ctrlKey;
const isCtrlP = event.which === 80 && event.ctrlKey;
if (isDown || isUp || isCtrlN || isCtrlP) event.preventDefault();
if (isDown || isCtrlN) this._focusNext();
if (isUp || isCtrlP) this._focusPrevious();
}
_registerClickEvents() {
this._suggestionEls.forEach(el => {
const value = el.getAttribute('data-suggestion');
el.addEventListener('click', this._handleClick.bind(null, value));
});
}
_suggest(input) {
Promise.all(this._gatherInfluencers(input)).then(res => {
this._clearSuggestions();
this._flattenAndUnique(res)
.some(item => this._appendSuggestion(item, input));
this._suggestionEls = $.els('.js-search-suggestion');
this._registerClickEvents();
});
}
}
class QueryParser {
generateRedirect(query) {
const encodedQuery = encodeURIComponent(query);
let redirectUrl = CONFIG.defaultSearch.replace('{}', encodedQuery);
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 => {
if (callback(command)) return;
}));
}
_prepPath(command, query) {
const baseUrl = this._stripUrlPath(command.url);
const path = this._shiftAndTrim(query, CONFIG.pathDelimiter);
return `${baseUrl}/${path}`;
}
_prepSearch(command, query) {
if (!command.search) return command.url;
const baseUrl = this._stripUrlPath(command.url);
const search = this._shiftAndTrimAndEncode(query);
const searchPath = command.search.replace('{}', search);
return `${baseUrl}${searchPath}`;
}
_shiftAndTrim(arr, delimiter) {
arr.shift();
return arr.join(delimiter).trim();
}
_shiftAndTrimAndEncode(arr) {
const clean = this._shiftAndTrim(arr, CONFIG.searchDelimiter);
return encodeURIComponent(clean);
}
_stripUrlPath(url) {
const parser = document.createElement('a');
parser.href = url;
return `${parser.protocol}//${parser.hostname}`;
}
}
class Form {
constructor(help, suggester, queryParser) {
this._help = help;
this._suggester = suggester;
this._queryParser = queryParser;
this._formEl = $.el('#js-search-form');
this._inputEl = $.el('#js-search-input');
this._inputElVal = '';
this._bindMethods();
this._registerEvents();
}
_bindMethods() {
this._handleKeypress = this._handleKeypress.bind(this);
this._submitForm = this._submitForm.bind(this);
this._handleKeyup = this._handleKeyup.bind(this);
this._submitWithValue = this._submitWithValue.bind(this);
}
_handleKeypress(event) {
const newChar = String.fromCharCode(event.which);
const newQuery = this._inputEl.value + newChar;
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, newQuery, this._submitWithValue);
}
}
_handleKeyup(event) {
const oldVal = this._inputElVal;
const newVal = this._inputEl.value.trim();
this._inputElVal = newVal;
if (CONFIG.suggestions && oldVal !== newVal) {
this._suggester.suggest(newVal, this._submitWithValue);
}
}
_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._inputEl.value = '';
this._help.toggle();
} else {
this._suggester.add(query);
this._suggester.suggest('');
this._inputEl.value = '';
this._redirect(this._queryParser.generateRedirect(query));
}
}
_submitWithValue(value) {
this._inputEl.value = value;
this._submitForm();
}
}
</script>
<script>
const clock = new Clock();
const help = new Help();
const influencers = { History: History, DuckDuckGo: DuckDuckGo };
const suggester = new Suggester(
CONFIG.influencers.map(i => new influencers[i.name](i.limit))
);
const parser = new QueryParser();
const form = new Form(help, suggester, parser);
</script>