Recommitting latest version due to an error
28
README.md
@ -1,21 +1,28 @@
|
||||
# Tilde Enhanced
|
||||
|
||||
Based on [Cade Scroggins](https://github.com/cadejscroggins)'s [Tilde](https://github.com/cadejscroggins/tilde).
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||
# Tilde Enhanced
|
||||
|
||||
A slightly modified version of [Cade Scroggins](https://github.com/cadejscroggins)'s homepage [Tilde](https://github.com/cadejscroggins/tilde).
|
||||
Most of the features are carried over from the original source.
|
||||
|
||||
## Added Features
|
||||
Most of the features are carried over from the original source.
|
||||
Few of the added features are:
|
||||
|
||||
- Clicking on the clock will bring up pre-defined sites.
|
||||
- Added a "Quick Launch" functionality, which launches all the sites with `quickLaunch`
|
||||
property set to true upon hitting `!` key.
|
||||
- Clicking on the clock brings up pre-defined sites.
|
||||
- Available sites show their icons instead of their corresponding keys.
|
||||
- Other small changes on grids and boxes.
|
||||
- Added the option to invert colour theme. You can either edit config or use `invert!` command.
|
||||
- Other small changes on grids.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
To use Tilde as your homepage, simply set your browser's home URL to
|
||||
Tilde's index file.
|
||||
To go to a site, enter the first letter of the site then hit enter.
|
||||
To view the available sites, press `?` or click on the clock.
|
||||
If your input doesn't match any of the commands,
|
||||
@ -26,7 +33,9 @@ a generic DuckDuckGo search will be triggered. For example:
|
||||
- Entering `cats` would search [Cats](https://duckduckgo.com/?q=cats).
|
||||
|
||||
Note that by default, queries are searched on DuckDuckGo but this can be
|
||||
configured easily by updating two lines.
|
||||
configured easily by updating two lines on. Check [Configuration](#configuration) for details.
|
||||
|
||||
You might have to allow pop-ups for your homepage to use quick launch feature.
|
||||
|
||||
This version is not suitable for use on mobile as clicking on the clock will
|
||||
only bring up pre-defined sites.
|
||||
@ -73,9 +82,8 @@ This allows you to invoke Tilde with your native browser search bar.
|
||||
|
||||
## Configuration
|
||||
|
||||
The above is just the beginning. Open up the [index.html](index.html) file and
|
||||
read through the `CONFIG`!
|
||||
Open up the [script.js](assets/script.js) file and read through the `CONFIG`!
|
||||
|
||||
## License
|
||||
|
||||
Feel free to [use this and modify it however you like](https://github.com/cadejscroggins/tilde/blob/master/LICENSE).
|
||||
Feel free to [use this and modify it however you like](https://github.com/Ozencb/tilde-enhanced/blob/master/LICENSE).
|
||||
|
BIN
assets/fonts/Roboto-Black.woff
Normal file
BIN
assets/fonts/Roboto-Black.woff2
Normal file
BIN
assets/fonts/Roboto-Regular.woff
Normal file
BIN
assets/fonts/Roboto-Regular.woff2
Normal file
BIN
assets/icons/arstechnica.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
assets/icons/drive.png
Normal file
After Width: | Height: | Size: 32 KiB |
BIN
assets/icons/github.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
assets/icons/imdb.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
assets/icons/linkedin.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
assets/icons/mail.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
assets/icons/netflix.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
assets/icons/reddit.png
Normal file
After Width: | Height: | Size: 40 KiB |
BIN
assets/icons/stackoverflow.png
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
assets/icons/twitch.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
assets/icons/twitter.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
assets/icons/youtube.png
Normal file
After Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 71 KiB |
BIN
icons/drive.png
Before Width: | Height: | Size: 9.8 KiB |
BIN
icons/github.png
Before Width: | Height: | Size: 36 KiB |
BIN
icons/imdb.png
Before Width: | Height: | Size: 36 KiB |
Before Width: | Height: | Size: 19 KiB |
BIN
icons/mail.png
Before Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 43 KiB |
BIN
icons/reddit.png
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 24 KiB |
BIN
icons/twitch.png
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 23 KiB |
1281
index.html
968
script/script.js
Normal file
@ -0,0 +1,968 @@
|
||||
let invertColorCookie;
|
||||
|
||||
if (localStorage.getItem('invertColorCookie') === null){
|
||||
invertColorCookie = false;
|
||||
}
|
||||
|
||||
else {
|
||||
invertColorCookie = JSON.parse(localStorage.getItem('invertColorCookie'));
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
/**
|
||||
* The category, name, key, url, search path, color, icon, and quicklaunch properties for your commands.
|
||||
* Icons must be added to "icons" folder and their values/names must be updated.
|
||||
* If none of the specified keys are matched, the '*' key is used.
|
||||
* Commands without a category don't show up in the help menu.
|
||||
* Update line 11 and 13 if you prefer using Google.
|
||||
*/
|
||||
commands: [
|
||||
{
|
||||
name: 'Duckduckgo',
|
||||
key: '*',
|
||||
url: 'https://duckduckgo.com',
|
||||
search: '/?q={}',
|
||||
color: '#DE5833',
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
name: 'Mail',
|
||||
key: 'm',
|
||||
url: 'https://gmail.com',
|
||||
search: '/#search/text={}',
|
||||
color: 'linear-gradient(135deg, #dd5145, #dd5145)',
|
||||
icon: 'mail',
|
||||
quickLaunch: true,
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
name: 'Drive',
|
||||
key: 'd',
|
||||
url: 'https://drive.google.com',
|
||||
search: '/drive/search?q={}',
|
||||
color: 'linear-gradient(135deg, #FFD04B, #1EA362, #4688F3)',
|
||||
icon: 'drive',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'General',
|
||||
name: 'LinkedIn',
|
||||
key: 'l',
|
||||
url: 'https://linkedin.com',
|
||||
search: '/search/results/all/?keywords={}',
|
||||
color: 'linear-gradient(135deg, #006CA4, #0077B5)',
|
||||
icon: 'linkedin',
|
||||
quickLaunch: true,
|
||||
},
|
||||
{
|
||||
category: 'Tech',
|
||||
name: 'GitHub',
|
||||
key: 'g',
|
||||
url: 'https://github.com',
|
||||
search: '/search?q={}',
|
||||
color: 'linear-gradient(135deg, #2b2b2b, #3b3b3b)',
|
||||
icon: 'github',
|
||||
quickLaunch: true,
|
||||
},
|
||||
{
|
||||
category: 'Tech',
|
||||
name: 'StackOverflow',
|
||||
key: 's',
|
||||
url: 'https://stackoverflow.com',
|
||||
search: '/search?q={}',
|
||||
color: 'linear-gradient(135deg, #53341C, #F48024)',
|
||||
icon: 'stackoverflow',
|
||||
quickLaunch: true,
|
||||
},
|
||||
{
|
||||
category: 'Tech',
|
||||
name: 'Ars Technica',
|
||||
key: 'a',
|
||||
url: 'https://arstechnica.com',
|
||||
search: '/search/?ie=UTF-8&q={}',
|
||||
color: 'linear-gradient(135deg, #FF4E00, #B83800)',
|
||||
icon: 'arstechnica',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'Fun',
|
||||
name: 'YouTube',
|
||||
key: 'y',
|
||||
url: 'https://youtube.com',
|
||||
search: '/results?search_query={}',
|
||||
color: 'linear-gradient(135deg, #cd201f, #cd4c1f)',
|
||||
icon: 'youtube',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'Fun',
|
||||
name: 'Netflix',
|
||||
key: 'n',
|
||||
url: 'https://www.netflix.com',
|
||||
color: 'linear-gradient(135deg, #E50914, #CB020C)',
|
||||
icon: 'netflix',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'Fun',
|
||||
name: 'Twitch',
|
||||
key: 't',
|
||||
url: 'https://www.twitch.tv',
|
||||
search: '/directory/game/{}',
|
||||
color: 'linear-gradient(135deg, #6441a5, #4b367c)',
|
||||
icon: 'twitch',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'Other',
|
||||
name: 'Reddit',
|
||||
key: 'r',
|
||||
url: 'https://reddit.com',
|
||||
search: '/search?q={}',
|
||||
color: 'linear-gradient(135deg, #FF8456, #FF4500)',
|
||||
icon: 'reddit',
|
||||
quickLaunch: false,
|
||||
},
|
||||
{
|
||||
category: 'Other',
|
||||
name: 'Twitter',
|
||||
key: 'o',
|
||||
url: 'https://twitter.com',
|
||||
color: 'linear-gradient(135deg, #C0A886, #E2DBC8)',
|
||||
icon: 'twitter',
|
||||
quickLaunch: true,
|
||||
},
|
||||
{
|
||||
category: 'Other',
|
||||
name: 'IMDb',
|
||||
key: 'i',
|
||||
url: 'https://imdb.com',
|
||||
search: '/find?ref_=nv_sr_fn&q={}',
|
||||
color: 'linear-gradient(135deg, #7A5F00, #E8B708)',
|
||||
icon: 'imdb',
|
||||
quickLaunch: false,
|
||||
},
|
||||
],
|
||||
|
||||
/**
|
||||
* Get suggestions as you type.
|
||||
*/
|
||||
suggestions: true,
|
||||
suggestionsLimit: 4,
|
||||
|
||||
/**
|
||||
* The order and limit for each suggestion influencer. An "influencer" is
|
||||
* just a suggestion source. The following influencers are available:
|
||||
*
|
||||
* - "Default" suggestions come from CONFIG.defaultSuggestions
|
||||
* - "DuckDuckGo" suggestions come from the duck duck go search api
|
||||
* - "History" suggestions come from your previously entered queries
|
||||
*/
|
||||
influencers: [
|
||||
{ name: 'Default', limit: 4 },
|
||||
{ name: 'History', limit: 1 },
|
||||
{ name: 'DuckDuckGo', limit: 4 },
|
||||
],
|
||||
|
||||
/**
|
||||
* Default search suggestions for the specified queries.
|
||||
*/
|
||||
defaultSuggestions: {
|
||||
g: ['g/issues', 'g/pulls', 'gist.github.com'],
|
||||
r: ['r/r/unixporn', 'r/r/startpages', 'r/r/webdev', 'r/r/technology'],
|
||||
},
|
||||
|
||||
/**
|
||||
* Instantly redirect when a key is matched. Put a space before any other
|
||||
* queries to prevent unwanted redirects.
|
||||
*/
|
||||
instantRedirect: false,
|
||||
|
||||
/**
|
||||
* Open triggered queries in a new tab.
|
||||
*/
|
||||
newTab: false,
|
||||
|
||||
/**
|
||||
* Dynamic overlay background colors when command domains are matched.
|
||||
*/
|
||||
colors: true,
|
||||
|
||||
/**
|
||||
* Invert color theme
|
||||
*/
|
||||
invertedColors: invertColorCookie,
|
||||
|
||||
/**
|
||||
* The delimiter between a command key and your search query. For example,
|
||||
* to search GitHub for potatoes, you'd type "g:potatoes".
|
||||
*/
|
||||
searchDelimiter: ':',
|
||||
|
||||
/**
|
||||
* The delimiter between a command key and a path. For example, you'd type
|
||||
* "r/r/unixporn" to go to "https://reddit.com/r/unixporn".
|
||||
*/
|
||||
pathDelimiter: '/',
|
||||
|
||||
/**
|
||||
* The delimiter between the hours and minutes on the clock.
|
||||
*/
|
||||
clockDelimiter: ' ',
|
||||
|
||||
/**
|
||||
* Show a twenty-four-hour clock instead of a twelve-hour clock with AM/PM.
|
||||
*/
|
||||
twentyFourHourClock: true,
|
||||
};
|
||||
|
||||
|
||||
const $ = {
|
||||
bodyClassAdd: c => $.el('body').classList.add(c),
|
||||
bodyClassRemove: c => $.el('body').classList.remove(c),
|
||||
el: s => document.querySelector(s),
|
||||
els: s => [].slice.call(document.querySelectorAll(s) || []),
|
||||
escapeRegex: s => s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
|
||||
flattenAndUnique: arr => [...new Set([].concat.apply([], arr))],
|
||||
ieq: (a, b) => a.toLowerCase() === b.toLowerCase(),
|
||||
iin: (a, b) => a.toLowerCase().indexOf(b.toLowerCase()) !== -1,
|
||||
isDown: e => ['c-n', 'down', 'tab'].includes($.key(e)),
|
||||
isRemove: e => ['backspace', 'delete'].includes($.key(e)),
|
||||
isUp: e => ['c-p', 'up', 's-tab'].includes($.key(e)),
|
||||
|
||||
jsonp: url => {
|
||||
let script = document.createElement('script');
|
||||
script.src = url;
|
||||
$.el('head').appendChild(script);
|
||||
},
|
||||
|
||||
key: e => {
|
||||
const ctrl = e.ctrlKey;
|
||||
const shift = e.shiftKey;
|
||||
|
||||
switch (e.which) {
|
||||
case 8:
|
||||
return 'backspace';
|
||||
case 9:
|
||||
return shift ? 's-tab' : 'tab';
|
||||
case 13:
|
||||
return 'enter';
|
||||
case 16:
|
||||
return 'shift';
|
||||
case 17:
|
||||
return 'ctrl';
|
||||
case 18:
|
||||
return 'alt';
|
||||
case 27:
|
||||
return 'escape';
|
||||
case 38:
|
||||
return 'up';
|
||||
case 40:
|
||||
return 'down';
|
||||
case 46:
|
||||
return 'delete';
|
||||
case 78:
|
||||
return ctrl ? 'c-n' : 'n';
|
||||
case 80:
|
||||
return ctrl ? 'c-p' : 'p';
|
||||
case 189:
|
||||
return 'dash';
|
||||
case 91:
|
||||
case 93:
|
||||
case 224:
|
||||
return 'super';
|
||||
}
|
||||
},
|
||||
|
||||
pad: v => ('0' + v.toString()).slice(-2),
|
||||
};
|
||||
|
||||
class Clock {
|
||||
constructor(options) {
|
||||
this._el = $.el('#clock');
|
||||
this._delimiter = options.delimiter;
|
||||
this._twentyFourHourClock = options.twentyFourHourClock;
|
||||
this._setTime = this._setTime.bind(this);
|
||||
this._el.addEventListener('click', options.toggleHelp);
|
||||
this._start();
|
||||
}
|
||||
|
||||
_setTime() {
|
||||
const date = new Date();
|
||||
let hours = $.pad(date.getHours());
|
||||
let amPm = '';
|
||||
|
||||
if (!this._twentyFourHourClock) {
|
||||
hours = date.getHours();
|
||||
if (hours > 12) hours -= 12;
|
||||
else if (hours === 0) hours = 12;
|
||||
|
||||
amPm =
|
||||
` <span class="am-pm">` +
|
||||
`${date.getHours() >= 12 ? 'PM' : 'AM'}</span>`;
|
||||
}
|
||||
|
||||
const minutes = $.pad(date.getMinutes());
|
||||
this._el.innerHTML = `${hours}${this._delimiter}${minutes}${amPm}`;
|
||||
this._el.setAttribute('datetime', date.toTimeString());
|
||||
}
|
||||
|
||||
_start() {
|
||||
this._setTime();
|
||||
setInterval(this._setTime, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
class Help {
|
||||
constructor(options) {
|
||||
this._el = $.el('#help');
|
||||
this._commands = options.commands;
|
||||
this._newTab = options.newTab;
|
||||
this._toggled = false;
|
||||
this._handleKeydown = this._handleKeydown.bind(this);
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.launch = this.launch.bind(this);
|
||||
this._inputEl = $.el('#search-input');
|
||||
this._inputElVal = '';
|
||||
this._suggester = options.suggester;
|
||||
this._invertColors = options.invertedColors;
|
||||
this._buildAndAppendLists();
|
||||
this._registerEvents();
|
||||
this._invertValue;
|
||||
}
|
||||
|
||||
toggle(show) {
|
||||
this._toggled = typeof show !== 'undefined' ? show : !this._toggled;
|
||||
this._toggled ? $.bodyClassAdd('help') : $.bodyClassRemove('help');
|
||||
}
|
||||
|
||||
hide() {
|
||||
$.bodyClassRemove('form');
|
||||
this._inputEl.value = '';
|
||||
this._inputElVal = '';
|
||||
this._suggester.suggest('');
|
||||
}
|
||||
|
||||
launch() {
|
||||
this.hide();
|
||||
this.toggle(true);
|
||||
$.bodyClassAdd('help');
|
||||
for (let i = 0; i < CONFIG.commands.length; i++) {
|
||||
if (CONFIG.commands[i].quickLaunch === true) {
|
||||
window.open(CONFIG.commands[i].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buildAndAppendLists() {
|
||||
const lists = document.createElement('ul');
|
||||
lists.classList.add('categories');
|
||||
|
||||
this._getCategories().forEach(category => {
|
||||
lists.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<li class="category">
|
||||
<h2 class="category-name">${category}</h2>
|
||||
<ul>${this._buildListCommands(category)}</ul>
|
||||
</li>`
|
||||
);
|
||||
});
|
||||
|
||||
this._el.appendChild(lists);
|
||||
}
|
||||
|
||||
_buildListCommands(currentCategory) {
|
||||
this._invertValue = this._invertColors ? 1: 0;
|
||||
|
||||
return this._commands
|
||||
.map(({ category, name, key, url, icon }) => {
|
||||
if (category === currentCategory) {
|
||||
return `
|
||||
<li class="command">
|
||||
<a href="${url}" target="${this._newTab ? '_blank' : '_self'}">
|
||||
<span class="command-key"><img src='assets/icons/${icon}.png' height = 36px center style="filter: invert(${this._invertValue});"></span>
|
||||
<span class="command-name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
_getCategories() {
|
||||
const categories = this._commands
|
||||
.map(v => v.category)
|
||||
.filter(category => category);
|
||||
|
||||
return [...new Set(categories)];
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
if ($.key(e) === 'escape') this.toggle(false);
|
||||
}
|
||||
|
||||
_registerEvents() {
|
||||
document.addEventListener('keydown', this._handleKeydown);
|
||||
}
|
||||
}
|
||||
|
||||
class Influencer {
|
||||
constructor(options) {
|
||||
this._limit = options.limit;
|
||||
this._parseQuery = options.parseQuery;
|
||||
}
|
||||
|
||||
addItem() {}
|
||||
getSuggestions() {}
|
||||
|
||||
_addSearchPrefix(items, query) {
|
||||
const searchPrefix = this._getSearchPrefix(query);
|
||||
return items.map(s => (searchPrefix ? searchPrefix + s : s));
|
||||
}
|
||||
|
||||
_getSearchPrefix(query) {
|
||||
const { isSearch, key, split } = this._parseQuery(query);
|
||||
return isSearch ? `${key}${split} ` : false;
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultInfluencer extends Influencer {
|
||||
constructor({ defaultSuggestions }) {
|
||||
super(...arguments);
|
||||
this._defaultSuggestions = defaultSuggestions;
|
||||
}
|
||||
|
||||
getSuggestions(query) {
|
||||
return new Promise(resolve => {
|
||||
const suggestions = this._defaultSuggestions[query];
|
||||
resolve(suggestions ? suggestions.slice(0, this._limit) : []);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DuckDuckGoInfluencer extends Influencer {
|
||||
constructor({ queryParser }) {
|
||||
super(...arguments);
|
||||
}
|
||||
|
||||
getSuggestions(rawQuery) {
|
||||
const { query } = this._parseQuery(rawQuery);
|
||||
if (!query) return Promise.resolve([]);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const endpoint = 'https://duckduckgo.com/ac/';
|
||||
const callback = 'autocompleteCallback';
|
||||
|
||||
window[callback] = res => {
|
||||
const suggestions = res
|
||||
.map(i => i.phrase)
|
||||
.filter(s => !$.ieq(s, query))
|
||||
.slice(0, this._limit);
|
||||
|
||||
resolve(this._addSearchPrefix(suggestions, rawQuery));
|
||||
};
|
||||
|
||||
$.jsonp(`${endpoint}?callback=${callback}&q=${query}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryInfluencer extends Influencer {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this._storeName = 'history';
|
||||
}
|
||||
|
||||
addItem(query) {
|
||||
if (query.length < 2) return;
|
||||
let exists;
|
||||
|
||||
const history = this._getHistory().map(([item, count]) => {
|
||||
const match = $.ieq(item, query);
|
||||
if (match) exists = true;
|
||||
return [item, match ? count + 1 : count];
|
||||
});
|
||||
|
||||
if (!exists) history.push([query, 1]);
|
||||
|
||||
const sorted = history
|
||||
.sort((current, next) => current[1] - next[1])
|
||||
.reverse();
|
||||
|
||||
this._setHistory(sorted);
|
||||
}
|
||||
|
||||
getSuggestions(query) {
|
||||
return new Promise(resolve => {
|
||||
const suggestions = this._getHistory()
|
||||
.map(([item]) => item)
|
||||
.filter(item => query && !$.ieq(item, query) && $.iin(item, query))
|
||||
.slice(0, this._limit);
|
||||
|
||||
resolve(suggestions);
|
||||
});
|
||||
}
|
||||
|
||||
_fetch() {
|
||||
return JSON.parse(localStorage.getItem(this._storeName)) || [];
|
||||
}
|
||||
|
||||
_getHistory() {
|
||||
this._history = this._history || this._fetch();
|
||||
return this._history;
|
||||
}
|
||||
|
||||
_save(history) {
|
||||
localStorage.setItem(this._storeName, JSON.stringify(history));
|
||||
}
|
||||
|
||||
_setHistory(history) {
|
||||
this._history = history;
|
||||
this._save(history);
|
||||
}
|
||||
}
|
||||
|
||||
class Suggester {
|
||||
constructor(options) {
|
||||
this._el = $.el('#search-suggestions');
|
||||
this._enabled = options.enabled;
|
||||
this._influencers = options.influencers;
|
||||
this._limit = options.limit;
|
||||
this._suggestionEls = [];
|
||||
this._handleKeydown = this._handleKeydown.bind(this);
|
||||
this._registerEvents();
|
||||
}
|
||||
|
||||
setOnClick(callback) {
|
||||
this._onClick = callback;
|
||||
}
|
||||
|
||||
setOnHighlight(callback) {
|
||||
this._onHighlight = callback;
|
||||
}
|
||||
|
||||
setOnUnhighlight(callback) {
|
||||
this._onUnhighlight = callback;
|
||||
}
|
||||
|
||||
success(query) {
|
||||
this._clearSuggestions();
|
||||
this._influencers.forEach(i => i.addItem(query));
|
||||
}
|
||||
|
||||
suggest(input) {
|
||||
if (!this._enabled) return;
|
||||
input = input.trim();
|
||||
if (input === '') this._clearSuggestions();
|
||||
|
||||
Promise.all(this._getInfluencerPromises(input)).then(res => {
|
||||
const suggestions = $.flattenAndUnique(res);
|
||||
this._clearSuggestions();
|
||||
|
||||
if (suggestions.length) {
|
||||
this._appendSuggestions(suggestions, input);
|
||||
this._registerSuggestionHighlightEvents();
|
||||
this._registerSuggestionClickEvents();
|
||||
$.bodyClassAdd('suggestions');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_appendSuggestions(suggestions, input) {
|
||||
suggestions.some((suggestion, i) => {
|
||||
const match = new RegExp($.escapeRegex(input), 'ig');
|
||||
const suggestionHtml = suggestion.replace(match, `<b>${input}</b>`);
|
||||
|
||||
this._el.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<li>
|
||||
<button
|
||||
type="button"
|
||||
class="js-search-suggestion search-suggestion"
|
||||
data-suggestion="${suggestion}"
|
||||
tabindex="-1"
|
||||
>
|
||||
${suggestionHtml}
|
||||
</button>
|
||||
</li>`
|
||||
);
|
||||
|
||||
if (i + 1 >= this._limit) return true;
|
||||
});
|
||||
|
||||
this._suggestionEls = $.els('.js-search-suggestion');
|
||||
}
|
||||
|
||||
_clearSuggestionClickEvents() {
|
||||
this._suggestionEls.forEach(el => {
|
||||
el.removeEventListener('click', this._onClick);
|
||||
});
|
||||
}
|
||||
|
||||
_clearSuggestionHighlightEvents() {
|
||||
this._suggestionEls.forEach(el => {
|
||||
el.removeEventListener('mouseover', this._highlight);
|
||||
el.removeEventListener('mouseout', this._unHighlight);
|
||||
});
|
||||
}
|
||||
|
||||
_clearSuggestions() {
|
||||
$.bodyClassRemove('suggestions');
|
||||
this._clearSuggestionHighlightEvents();
|
||||
this._clearSuggestionClickEvents();
|
||||
this._suggestionEls = [];
|
||||
this._el.innerHTML = '';
|
||||
}
|
||||
|
||||
_focusNext(e) {
|
||||
const exists = this._suggestionEls.some((el, i) => {
|
||||
if (el.classList.contains('highlight')) {
|
||||
this._highlight(this._suggestionEls[i + 1], e);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!exists) this._highlight(this._suggestionEls[0], e);
|
||||
}
|
||||
|
||||
_focusPrevious(e) {
|
||||
const exists = this._suggestionEls.some((el, i) => {
|
||||
if (el.classList.contains('highlight') && i) {
|
||||
this._highlight(this._suggestionEls[i - 1], e);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!exists) this._unHighlight(e);
|
||||
}
|
||||
|
||||
_getInfluencerPromises(input) {
|
||||
return this._influencers.map(influencer =>
|
||||
influencer.getSuggestions(input)
|
||||
);
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
if ($.isDown(e)) this._focusNext(e);
|
||||
if ($.isUp(e)) this._focusPrevious(e);
|
||||
}
|
||||
|
||||
_highlight(el, e) {
|
||||
this._unHighlight();
|
||||
if (!el) return;
|
||||
this._onHighlight(el.getAttribute('data-suggestion'));
|
||||
el.classList.add('highlight');
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
_registerEvents() {
|
||||
document.addEventListener('keydown', this._handleKeydown);
|
||||
}
|
||||
|
||||
_registerSuggestionClickEvents() {
|
||||
this._suggestionEls.forEach(el => {
|
||||
const value = el.getAttribute('data-suggestion');
|
||||
el.addEventListener('click', this._onClick.bind(null, value));
|
||||
});
|
||||
}
|
||||
|
||||
_registerSuggestionHighlightEvents() {
|
||||
const noHighlightUntilMouseMove = () => {
|
||||
window.removeEventListener('mousemove', noHighlightUntilMouseMove);
|
||||
|
||||
this._suggestionEls.forEach(el => {
|
||||
el.addEventListener('mouseover', this._highlight.bind(this, el));
|
||||
el.addEventListener('mouseout', this._unHighlight.bind(this));
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', noHighlightUntilMouseMove);
|
||||
}
|
||||
|
||||
_unHighlight(e) {
|
||||
const el = $.el('.highlight');
|
||||
if (!el) return;
|
||||
this._onUnhighlight();
|
||||
el.classList.remove('highlight');
|
||||
if (e) e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
class QueryParser {
|
||||
constructor(options) {
|
||||
this._commands = options.commands;
|
||||
this._searchDelimiter = options.searchDelimiter;
|
||||
this._pathDelimiter = options.pathDelimiter;
|
||||
this._protocolRegex = /^[a-zA-Z]+:\/\//i;
|
||||
this._urlRegex = /^((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)$/i;
|
||||
this.parse = this.parse.bind(this);
|
||||
}
|
||||
|
||||
parse(query) {
|
||||
const res = { query: query, split: null };
|
||||
|
||||
if (this._urlRegex.test(query)) {
|
||||
const hasProtocol = this._protocolRegex.test(query);
|
||||
res.redirect = hasProtocol ? query : 'http://' + query;
|
||||
} else {
|
||||
const trimmed = query.trim();
|
||||
const splitSearch = trimmed.split(this._searchDelimiter);
|
||||
const splitPath = trimmed.split(this._pathDelimiter);
|
||||
|
||||
this._commands.some(({ category, key, name, search, url }) => {
|
||||
if (query === key) {
|
||||
res.key = key;
|
||||
res.isKey = true;
|
||||
res.redirect = url;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (splitSearch[0] === key && search) {
|
||||
res.key = key;
|
||||
res.isSearch = true;
|
||||
res.split = this._searchDelimiter;
|
||||
res.query = QueryParser._shiftAndTrim(splitSearch, res.split);
|
||||
res.redirect = QueryParser._prepSearch(url, search, res.query);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (splitPath[0] === key) {
|
||||
res.key = key;
|
||||
res.isPath = true;
|
||||
res.split = this._pathDelimiter;
|
||||
res.path = QueryParser._shiftAndTrim(splitPath, res.split);
|
||||
res.redirect = QueryParser._prepPath(url, res.path);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key === '*') {
|
||||
res.redirect = QueryParser._prepSearch(url, search, query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.color = QueryParser._getColorFromUrl(this._commands, res.redirect);
|
||||
return res;
|
||||
}
|
||||
|
||||
static _getColorFromUrl(commands, url) {
|
||||
const domain = new URL(url).hostname;
|
||||
|
||||
return (
|
||||
commands
|
||||
.filter(c => new URL(c.url).hostname.includes(domain))
|
||||
.map(c => c.color)[0] || null
|
||||
);
|
||||
}
|
||||
|
||||
static _prepPath(url, path) {
|
||||
return QueryParser._stripUrlPath(url) + '/' + path;
|
||||
}
|
||||
|
||||
static _prepSearch(url, searchPath, query) {
|
||||
if (!searchPath) return url;
|
||||
const baseUrl = QueryParser._stripUrlPath(url);
|
||||
const urlQuery = encodeURIComponent(query);
|
||||
searchPath = searchPath.replace('{}', urlQuery);
|
||||
return baseUrl + searchPath;
|
||||
}
|
||||
|
||||
static _shiftAndTrim(arr, delimiter) {
|
||||
arr.shift();
|
||||
return arr.join(delimiter).trim();
|
||||
}
|
||||
|
||||
static _stripUrlPath(url) {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
return `${parser.protocol}//${parser.hostname}`;
|
||||
}
|
||||
}
|
||||
|
||||
class Form {
|
||||
constructor(options) {
|
||||
this._colors = options.colors;
|
||||
this._formEl = $.el('#search-form');
|
||||
this._inputEl = $.el('#search-input');
|
||||
this._inputElVal = '';
|
||||
this._instantRedirect = options.instantRedirect;
|
||||
this._newTab = options.newTab;
|
||||
this._parseQuery = options.parseQuery;
|
||||
this._suggester = options.suggester;
|
||||
this._toggleHelp = options.toggleHelp;
|
||||
this._quickLaunch = options.quickLaunch;
|
||||
this._clearPreview = this._clearPreview.bind(this);
|
||||
this._handleInput = this._handleInput.bind(this);
|
||||
this._handleKeydown = this._handleKeydown.bind(this);
|
||||
this._previewValue = this._previewValue.bind(this);
|
||||
this._submitForm = this._submitForm.bind(this);
|
||||
this._submitWithValue = this._submitWithValue.bind(this);
|
||||
this._invertColors = options.invertedColors;
|
||||
this.hide = this.hide.bind(this);
|
||||
this.show = this.show.bind(this);
|
||||
this._registerEvents();
|
||||
this._loadQueryParam();
|
||||
this.invert();
|
||||
}
|
||||
|
||||
hide() {
|
||||
$.bodyClassRemove('form');
|
||||
this._inputEl.value = '';
|
||||
this._inputElVal = '';
|
||||
this._suggester.suggest('');
|
||||
this._setBackgroundFromQuery('');
|
||||
}
|
||||
|
||||
show() {
|
||||
$.bodyClassAdd('form');
|
||||
this._inputEl.focus();
|
||||
}
|
||||
|
||||
invert() {
|
||||
if (this._invertColors) {
|
||||
const bgcolor = getComputedStyle(document.documentElement).getPropertyValue('--background');
|
||||
const fgcolor = getComputedStyle(document.documentElement).getPropertyValue('--foreground');
|
||||
document.documentElement.style.setProperty('--background', fgcolor);
|
||||
document.documentElement.style.setProperty('--foreground', bgcolor); }
|
||||
}
|
||||
|
||||
_invertConfig() {
|
||||
invertColorCookie = !invertColorCookie;
|
||||
localStorage.clear();
|
||||
localStorage.setItem('invertColorCookie', JSON.stringify(invertColorCookie));
|
||||
location.reload();
|
||||
}
|
||||
|
||||
_clearPreview() {
|
||||
this._previewValue(this._inputElVal);
|
||||
this._inputEl.focus();
|
||||
}
|
||||
|
||||
_handleInput() {
|
||||
const newQuery = this._inputEl.value;
|
||||
const isHelp = newQuery === '?';
|
||||
const isLaunch = newQuery === '!';
|
||||
const isInvert = newQuery === 'invert!';
|
||||
const { isKey } = this._parseQuery(newQuery);
|
||||
this._inputElVal = newQuery;
|
||||
this._suggester.suggest(newQuery);
|
||||
this._setBackgroundFromQuery(newQuery);
|
||||
if (!newQuery || isHelp) this.hide();
|
||||
if (isHelp) this._toggleHelp();
|
||||
if (isLaunch) this._quickLaunch();
|
||||
if (isInvert) this._invertConfig();
|
||||
if (this._instantRedirect && isKey) this._submitWithValue(newQuery);
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
if ($.isUp(e) || $.isDown(e) || $.isRemove(e)) return;
|
||||
|
||||
switch ($.key(e)) {
|
||||
case 'alt':
|
||||
case 'ctrl':
|
||||
case 'enter':
|
||||
case 'shift':
|
||||
case 'super':
|
||||
return;
|
||||
case 'escape':
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.show();
|
||||
}
|
||||
|
||||
_loadQueryParam() {
|
||||
const q = new URLSearchParams(window.location.search).get('q');
|
||||
if (q) this._submitWithValue(q);
|
||||
}
|
||||
|
||||
_previewValue(value) {
|
||||
this._inputEl.value = value;
|
||||
this._setBackgroundFromQuery(value);
|
||||
}
|
||||
|
||||
_redirect(redirect) {
|
||||
if (this._newTab) window.open(redirect, '_blank');
|
||||
else window.location.href = redirect;
|
||||
}
|
||||
|
||||
_registerEvents() {
|
||||
document.addEventListener('keydown', this._handleKeydown);
|
||||
this._inputEl.addEventListener('input', this._handleInput);
|
||||
this._formEl.addEventListener('submit', this._submitForm, false);
|
||||
|
||||
if (this._suggester) {
|
||||
this._suggester.setOnClick(this._submitWithValue);
|
||||
this._suggester.setOnHighlight(this._previewValue);
|
||||
this._suggester.setOnUnhighlight(this._clearPreview);
|
||||
}
|
||||
}
|
||||
|
||||
_setBackgroundFromQuery(query) {
|
||||
if (!this._colors) return;
|
||||
this._formEl.style.background = this._parseQuery(query).color;
|
||||
}
|
||||
|
||||
_submitForm(e) {
|
||||
if (e) e.preventDefault();
|
||||
const query = this._inputEl.value;
|
||||
if (this._suggester) this._suggester.success(query);
|
||||
this.hide();
|
||||
this._redirect(this._parseQuery(query).redirect);
|
||||
}
|
||||
|
||||
_submitWithValue(value) {
|
||||
this._inputEl.value = value;
|
||||
this._submitForm();
|
||||
}
|
||||
}
|
||||
|
||||
const queryParser = new QueryParser({
|
||||
commands: CONFIG.commands,
|
||||
pathDelimiter: CONFIG.pathDelimiter,
|
||||
searchDelimiter: CONFIG.searchDelimiter,
|
||||
});
|
||||
|
||||
const influencers = CONFIG.influencers.map(influencerConfig => {
|
||||
return new {
|
||||
Default: DefaultInfluencer,
|
||||
DuckDuckGo: DuckDuckGoInfluencer,
|
||||
History: HistoryInfluencer,
|
||||
}[influencerConfig.name]({
|
||||
defaultSuggestions: CONFIG.defaultSuggestions,
|
||||
limit: influencerConfig.limit,
|
||||
parseQuery: queryParser.parse,
|
||||
});
|
||||
});
|
||||
|
||||
const suggester = new Suggester({
|
||||
enabled: CONFIG.suggestions,
|
||||
influencers,
|
||||
limit: CONFIG.suggestionsLimit,
|
||||
});
|
||||
|
||||
const help = new Help({
|
||||
commands: CONFIG.commands,
|
||||
newTab: CONFIG.newTab,
|
||||
suggester,
|
||||
invertedColors: CONFIG.invertedColors,
|
||||
});
|
||||
|
||||
const form = new Form({
|
||||
colors: CONFIG.colors,
|
||||
instantRedirect: CONFIG.instantRedirect,
|
||||
newTab: CONFIG.newTab,
|
||||
parseQuery: queryParser.parse,
|
||||
suggester,
|
||||
toggleHelp: help.toggle,
|
||||
quickLaunch: help.launch,
|
||||
invertedColors: CONFIG.invertedColors,
|
||||
});
|
||||
|
||||
new Clock({
|
||||
delimiter: CONFIG.clockDelimiter,
|
||||
toggleHelp: help.toggle,
|
||||
twentyFourHourClock: CONFIG.twentyFourHourClock,
|
||||
});
|
305
style/style.css
Normal file
@ -0,0 +1,305 @@
|
||||
:root {
|
||||
/* colors */
|
||||
--background: #000000;
|
||||
--foreground: #FFFFFF;
|
||||
|
||||
/* fonts */
|
||||
--font-family: 'Open Sans', sans-serif;
|
||||
--base-font-size: 18px;
|
||||
--font-weight-normal: 500;
|
||||
--font-weight-bold: 900;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
src: local('Open Sans'), local('Open Sans'),
|
||||
url('../assets/fonts/OpenSans-Regular.ttf') format('ttf');
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--base-font-size);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background 0.2s;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--font-family);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
input:focus,
|
||||
button:focus {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a,
|
||||
a:focus {
|
||||
color: inherit;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.clock {
|
||||
display: block;
|
||||
margin-top: -0.06rem;
|
||||
font-size: 4rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-align: center;
|
||||
letter-spacing: 0.05rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.am-pm {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.search-form>div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.search-suggestions {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.suggestions .search-suggestions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-suggestion {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-suggestion.highlight {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.search-suggestion b {
|
||||
position: relative;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.search-suggestion b::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -0.3rem;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
background: var(--background);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.search-suggestion.highlight b::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
display: block;
|
||||
padding: 8vw;
|
||||
background: var(--background);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 3rem;
|
||||
padding: 25px;
|
||||
width: 250px;
|
||||
min-width: 225px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command a {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 1rem 0;
|
||||
line-height: 2rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.command:last-of-type a {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.command-key {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin-right: 1rem;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--background);
|
||||
font-size: 0.75rem;
|
||||
line-height: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-name {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.command-name::after {
|
||||
content: ' ';
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 2px;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
transition: 0.2s;
|
||||
transform: translateX(-2rem);
|
||||
background: var(--foreground);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
body.help .command-name::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.command a:hover .command-name::after,
|
||||
.command a:focus .command-name::after {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body.help .help,
|
||||
body.form .search-form {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.clock {
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-suggestions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
grid-template-columns: 250px 175px;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.help {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.search-suggestions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.categories {
|
||||
grid-template-columns: repeat(2, 300px) 175px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.categories {
|
||||
grid-template-columns: repeat(5, 220px) 175px;
|
||||
}
|
||||
}
|