tilde/index.html

678 lines
18 KiB
HTML

<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="color-scheme" content="dark light" />
<meta name="robots" content="noindex" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>~</title>
<script>
const CONFIG = {
commands: [
{
search: '/?q={}',
url: 'https://duckduckgo.com',
},
{
key: '0',
name: 'Local',
search: ':{}',
url: 'http://localhost:3000',
},
{
key: 'a',
name: 'AWS',
url: 'https://us-west-2.console.aws.amazon.com/console/home',
},
{
key: 'b',
name: 'Dribbble',
search: '/search/{}',
url: 'https://dribbble.com/shots/popular',
},
{
key: 'c',
name: 'Calendar',
search: '/calendar/u/0/r/search?q={}',
url: 'https://calendar.google.com/calendar/u/0/r',
},
{
key: 'd',
name: 'Drive',
search: '/drive/u/0/search?q={}',
url: 'https://drive.google.com/drive/u/0/my-drive',
},
{
key: 'f',
name: 'Figma',
url: 'https://www.figma.com/files/recent',
},
{
key: 'g',
name: 'GitHub',
search: '/search?q={}',
url: 'https://github.com',
},
{
key: 'h',
name: 'Hypem',
search: '/search/{}',
url: 'https://hypem.com/latest',
},
{
key: 'i',
name: 'Inbox',
url: 'https://mail.proton.me/u/0/inbox',
},
{
key: 'k',
name: 'Keep',
search: '/u/0/#search/text={}',
url: 'https://keep.google.com/u/0',
},
{
key: 'l',
name: 'LinkedIn',
search: '/search/results/all/?keywords={}',
url: 'https://www.linkedin.com',
},
{
key: 'm',
name: 'Mail',
search: '/mail/u/0/#search/{}',
url: 'https://mail.google.com/mail/u/0/#inbox',
},
{
key: 'n',
name: 'Notion',
url: 'https://www.notion.so',
},
{
key: 'p',
name: 'Hunt',
search: '/search?q={}',
url: 'https://www.producthunt.com',
},
{
key: 'r',
name: 'Reddit',
search: '/search?q={}',
url: 'https://www.reddit.com',
},
{
key: 's',
name: 'Spotify',
search: '/search/{}',
url: 'https://open.spotify.com',
},
{
key: 't',
name: 'Twitter',
search: '/search?q={}',
url: 'https://twitter.com/home',
},
{
key: 'v',
name: 'Vercel',
url: 'https://vercel.com',
},
{
key: 'x',
name: 'Netflix',
search: '/search?q={}',
url: 'https://www.netflix.com/browse',
},
{
key: 'y',
name: 'YouTube',
search: '/results?search_query={}',
url: 'https://youtube.com/feed/subscriptions',
},
],
suggestions: {
0: ["0'8000", "0'8080"],
a: ['a/cognito/v2/idp/user-pools', 'a/dynamodbv2#item-explorer'],
c: ['c/calendar/u/1/r', 'c/calendar/u/2/r'],
d: ['d/drive/u/1/my-drive', 'd/drive/u/2/my-drive'],
g: ['g/trending', 'g/ossu', 'g/xvvvyz/tilde', 'gist.github.com'],
h: ['h/popular', 'h/popular/lastweek', 'h/tags'],
i: ['i/u/1/inbox', 'i/u/2/inbox'],
k: ['k/u/1', 'k/u/2'],
m: ['m/mail/u/1/#inbox', 'm/mail/u/2/#inbox'],
r: ['r/r/startpages', 'r/r/unixporn', 'r/r/onebag', 'r/r/fujix'],
s: ['s/collection/tracks', 's/playlist/37i9dQZEVXcXr3r4FYT3J7'],
y: ['y/feed/trending'],
},
};
</script>
<style>
:root {
--color-background: #111;
--color-text-subtle: #999;
--color-text: #eee;
--font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica,
Ubuntu, Roboto, Noto, Segoe UI, Arial, sans-serif;
--font-size-1: 1rem;
--font-size-2: 3rem;
--font-size-base: 110%;
--font-weight-bold: 700;
--font-weight-normal: 400;
--line-height-base: 1.4;
--space-1: 1rem;
--space-2: 2rem;
--space-3: 4rem;
--transition-speed: 200ms;
}
@media (prefers-color-scheme: light) {
:root {
--color-background: #eee;
--color-text-subtle: #777;
--color-text: #111;
}
}
html {
background-color: var(--color-background);
color: var(--color-text);
font-family: var(--font-family);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
body {
margin: 0;
}
</style>
<template id="commands-template">
<style>
.commands {
display: grid;
list-style: none;
margin: 0;
padding: 0;
}
.command {
color: inherit;
display: flex;
outline: 0;
padding: var(--space-1) var(--space-2);
text-decoration: none;
}
.key {
font-weight: var(--font-weight-bold);
width: 1ch;
}
.name {
color: var(--color-text-subtle);
margin-left: var(--space-2);
transition: color var(--transition-speed);
}
.command:hover .name {
color: var(--color-text);
}
@media (min-width: 500px) {
.commands {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 700px) {
.commands {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 900px) {
.commands {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1100px) {
.commands {
grid-template-columns: repeat(5, 1fr);
}
}
</style>
<nav>
<menu class="commands"></menu>
</nav>
</template>
<template id="command-template">
<li>
<a class="command" rel="noopener noreferrer" target="_blank">
<span class="key"></span>
<span class="name"></span>
</a>
</li>
</template>
<script type="module">
class CommandsComponent extends HTMLElement {
#refs = {
commandsContainer: null,
};
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('commands-template');
const clone = template.content.cloneNode(true);
this.#refs.commandsContainer = clone.querySelector('.commands');
this.#renderCommands();
this.shadowRoot.append(clone);
}
#renderCommands() {
for (const { key, name, url } of CONFIG.commands) {
if (!name) continue;
const template = document.getElementById('command-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.command').href = url;
clone.querySelector('.key').innerText = key;
clone.querySelector('.name').innerText = name;
this.#refs.commandsContainer.append(clone);
}
}
}
customElements.define('commands-component', CommandsComponent);
</script>
<template id="search-template">
<style>
input,
button {
-moz-appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
display: block;
outline: 0;
}
.dialog {
align-items: center;
background: var(--color-background);
border: none;
display: none;
flex-direction: column;
height: 100vh;
justify-content: center;
left: 0;
padding: 0;
top: 0;
width: 100vw;
}
.dialog[open] {
display: flex;
}
.input {
font-size: var(--font-size-2);
font-weight: var(--font-weight-bold);
padding: 0;
text-align: center;
width: 100%;
}
.suggestions {
align-items: center;
display: flex;
flex-direction: column;
flex-wrap: wrap;
justify-content: center;
list-style: none;
margin: var(--space-2) 0 0;
overflow: hidden;
padding: 0;
}
.suggestion {
cursor: pointer;
font-size: var(--font-size-1);
padding: var(--space-1) var(--space-2);
position: relative;
transition: color var(--transition-speed);
white-space: nowrap;
z-index: 1;
}
.suggestion:focus,
.suggestion:hover {
color: var(--color-background);
}
.suggestion::before,
.suggestion::before {
background-color: var(--color-text);
bottom: var(--space-1);
content: ' ';
left: var(--space-2);
opacity: 0;
position: absolute;
right: var(--space-2);
top: var(--space-1);
transform: translateY(0.5em);
transition: all var(--transition-speed);
z-index: -1;
}
.suggestion:focus::before,
.suggestion:hover::before {
opacity: 1;
transform: translateY(0);
}
.match {
color: var(--color-text-subtle);
transition: color var(--transition-speed);
}
.suggestion:focus .match,
.suggestion:hover .match {
color: var(--color-background);
}
@media (min-width: 700px) {
.suggestions {
flex-direction: row;
}
}
</style>
<dialog class="dialog">
<form autocomplete="off" class="form" method="dialog" spellcheck="false">
<input class="input" title="search" type="text" />
<menu class="suggestions"></menu>
</form>
</dialog>
</template>
<template id="suggestion-template">
<li>
<button class="suggestion" type="button"></button>
</li>
</template>
<template id="match-template">
<span class="match"></span>
</template>
<script type="module">
class SearchComponent extends HTMLElement {
static #PATH_DELIMITER = '/';
static #SEARCH_DELIMITER = "'";
static #SUGGESTION_LIMIT = 4;
#refs = {
dialog: null,
form: null,
input: null,
suggestions: [],
suggestionsContainer: null,
};
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('search-template');
const clone = template.content.cloneNode(true);
this.#refs.dialog = clone.querySelector('.dialog');
this.#refs.form = clone.querySelector('.form');
this.#refs.input = clone.querySelector('.input');
this.#refs.suggestionsContainer = clone.querySelector('.suggestions');
this.#refs.input.addEventListener('input', this.#onInput);
const onSubmit = () => this.#execute(this.#refs.input.value);
this.#refs.form.addEventListener('submit', onSubmit, false);
document.addEventListener('keydown', this.#onKeydown);
this.shadowRoot.append(clone);
}
static #attachSearchPrefix(array, { key, splitBy }) {
if (!splitBy) return array;
return array.map((search) => `${key}${splitBy}${search}`);
}
static #escapeRegexCharacters(s) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
static #fetchDuckDuckGoSuggestions(search) {
return new Promise((resolve) => {
window.autocompleteCallback = (res) => {
const suggestions = [];
for (const item of res) {
if (item.phrase === search.toLowerCase()) continue;
suggestions.push(item.phrase);
}
resolve(suggestions);
};
const script = document.createElement('script');
document.querySelector('head').appendChild(script);
script.src = `https://duckduckgo.com/ac/?callback=autocompleteCallback&q=${search}`;
});
}
static #hasProtocol(s) {
return /^[a-zA-Z]+:\/\//i.test(s);
}
static #isUrl(s) {
return /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i.test(s);
}
static #parseQuery = (raw) => {
const query = raw.trim();
if (this.#isUrl(query)) {
const url = this.#hasProtocol(query) ? query : `https://${query}`;
return { query, url };
}
const searchSplit = query.split(SearchComponent.#SEARCH_DELIMITER);
const pathSplit = query.split(SearchComponent.#PATH_DELIMITER);
let defaultSearch = {};
for (const cmd of CONFIG.commands) {
if (query === cmd.key) {
return { key: cmd.key, query, url: cmd.url };
}
if (cmd.search && searchSplit[0] === cmd.key) {
const splitBy = SearchComponent.#SEARCH_DELIMITER;
const search = SearchComponent.#shiftAndTrim(searchSplit, splitBy);
const url = SearchComponent.#prepSearch(cmd.url, cmd.search, search);
return { key: cmd.key, query, search, splitBy, url };
}
if (pathSplit[0] === cmd.key) {
const splitBy = SearchComponent.#PATH_DELIMITER;
const path = SearchComponent.#shiftAndTrim(pathSplit, splitBy);
const url = `${SearchComponent.#stripUrlPath(cmd.url)}/${path}`;
return { key: cmd.key, path, query, splitBy, url };
}
if (!cmd.key) {
const url = SearchComponent.#prepSearch(cmd.url, cmd.search, query);
defaultSearch = { key: cmd.key, query, search: query, url };
}
}
return defaultSearch;
};
static #prepSearch(url, searchPath, search) {
if (!searchPath) return url;
const baseUrl = SearchComponent.#stripUrlPath(url);
const urlQuery = encodeURIComponent(search);
searchPath = searchPath.replace(/{}/g, urlQuery);
return baseUrl + searchPath;
}
static #shiftAndTrim(array, delimiter) {
return array.slice(1).join(delimiter).trim();
}
static #stripUrlPath(url) {
const parser = document.createElement('a');
parser.href = url;
return `${parser.protocol}//${parser.hostname}`;
}
#clearSuggestions() {
this.#refs.suggestions = [];
this.#refs.suggestionsContainer.innerHTML = '';
}
#close() {
this.#refs.input.value = '';
this.#refs.input.blur();
this.#refs.dialog.close();
this.#clearSuggestions();
}
#execute(query) {
const { url } = SearchComponent.#parseQuery(query);
window.open(url, '_blank', 'noopener noreferrer');
this.#close();
}
#focusNextSuggestion(previous = false) {
const active = this.shadowRoot.activeElement;
const activeIndex = this.#refs.suggestions.indexOf(active);
let nextIndex;
if (activeIndex === -1) {
nextIndex = previous ? this.#refs.suggestions.length - 1 : 0;
} else {
nextIndex = previous ? activeIndex - 1 : activeIndex + 1;
}
const next = this.#refs.suggestions[nextIndex];
if (next) next.focus();
else this.#refs.input.focus();
}
#onInput = async () => {
const q = SearchComponent.#parseQuery(this.#refs.input.value);
if (!q.query) {
this.#close();
return;
}
let suggestions = CONFIG.suggestions[q.query] ?? [];
if (q.search && suggestions.length < SearchComponent.#SUGGESTION_LIMIT) {
const res = await SearchComponent.#fetchDuckDuckGoSuggestions(q.search);
const formatted = SearchComponent.#attachSearchPrefix(res, q);
suggestions = suggestions.concat(formatted);
}
this.#renderSuggestions(suggestions, q.query);
};
#onKeydown = (e) => {
if (!this.#refs.dialog.open) {
this.#refs.dialog.show();
this.#refs.input.focus();
requestAnimationFrame(() => {
// close the search dialog before the next repaint if a character is
// not produced (e.g. if you type shift, control, alt etc.)
if (!this.#refs.input.value) this.#close();
});
return;
}
if (e.key === 'Escape') {
this.#close();
return;
}
const alt = e.altKey ? 'alt-' : '';
const ctrl = e.ctrlKey ? 'ctrl-' : '';
const meta = e.metaKey ? 'meta-' : '';
const shift = e.shiftKey ? 'shift-' : '';
const modifierPrefixedKey = `${alt}${ctrl}${meta}${shift}${e.key}`;
if (/^(ArrowDown|Tab|ctrl-n)$/.test(modifierPrefixedKey)) {
e.preventDefault();
this.#focusNextSuggestion();
return;
}
if (/^(ArrowUp|ctrl-p|shift-Tab)$/.test(modifierPrefixedKey)) {
e.preventDefault();
this.#focusNextSuggestion(true);
}
};
#renderSuggestions(suggestions, query) {
this.#clearSuggestions();
const sliced = suggestions.slice(0, SearchComponent.#SUGGESTION_LIMIT);
for (const suggestion of sliced) {
const template = document.getElementById('suggestion-template');
const clone = template.content.cloneNode(true);
const ref = clone.querySelector('.suggestion');
const onClick = this.#execute.bind(this, suggestion);
ref.addEventListener('click', onClick);
const escapedQuery = SearchComponent.#escapeRegexCharacters(query);
const matched = suggestion.match(new RegExp(escapedQuery, 'i'));
if (matched) {
const template = document.getElementById('match-template');
const clone = template.content.cloneNode(true);
const matchRef = clone.querySelector('.match');
const pre = suggestion.slice(0, matched.index);
const post = suggestion.slice(matched.index + matched[0].length);
matchRef.innerText = matched[0];
matchRef.insertAdjacentHTML('beforebegin', pre);
matchRef.insertAdjacentHTML('afterend', post);
ref.append(clone);
} else {
ref.innerText = suggestion;
}
this.#refs.suggestions.push(ref);
this.#refs.suggestionsContainer.append(clone);
}
}
}
customElements.define('search-component', SearchComponent);
</script>
<style>
main {
align-items: center;
display: flex;
height: 100vh;
justify-content: center;
width: 100vw;
}
commands-component {
margin: auto;
padding: var(--space-3) 0;
}
</style>
<main>
<commands-component></commands-component>
<search-component></search-component>
</main>