├── .babelrc ├── .browserslistrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .stylelintrc ├── LICENSE ├── README.md ├── dist ├── assets │ ├── 1.main.js │ ├── 2.main.js │ ├── favicon.png │ ├── main.js │ └── style.css └── index.html ├── docs └── screenshots │ ├── theme-dark.png │ └── theme-light.png ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── images │ └── favicon.png ├── index.hbs ├── index.js ├── js │ ├── components │ │ ├── configLoader.js │ │ ├── handlers.js │ │ ├── helpers.js │ │ ├── launchbot.js │ │ ├── pluginLoader.js │ │ ├── settings.js │ │ └── welcomeModal.js │ ├── config.js │ ├── main.js │ └── plugins │ │ ├── age.js │ │ └── weather.js └── styles │ ├── base │ ├── base.css │ ├── reset.css │ └── typography.css │ ├── components │ ├── buttons.css │ ├── grid.css │ ├── inputs.css │ ├── lists.css │ ├── modals.css │ ├── plugins.css │ └── sets.css │ ├── layout │ ├── content.css │ ├── footer.css │ ├── header.css │ ├── nav.css │ └── site.css │ ├── main.css │ ├── pages │ └── settings.css │ └── utilities │ ├── links.css │ ├── mixins.css │ ├── theming.css │ ├── typography.css │ └── variables.css └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": [ 4 | "@babel/plugin-syntax-dynamic-import", 5 | ["@babel/plugin-transform-runtime", { 6 | "regenerator": true, 7 | "useESModules": true 8 | }] 9 | ] 10 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # https://github.com/browserslist/browserslist#readme 2 | 3 | >= 1% 4 | last 1 major version 5 | not dead 6 | Chrome >= 62 7 | Firefox >= 60 8 | Edge >= 17 9 | iOS >= 11 10 | Safari >= 10 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/js/config.js 2 | 3 | # Ignored because https://github.com/eslint/eslint/issues/11486 4 | src/js/components/pluginLoader.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base" 4 | ], 5 | "env": { 6 | "es6": true, 7 | "browser": true 8 | }, 9 | "parserOptions": { 10 | "ecmaVersion": 2017, 11 | "sourceType": "module" 12 | } 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "stylelint-order" 4 | ], 5 | "rules": { 6 | "at-rule-semicolon-space-before": "never", 7 | "block-no-empty": true, 8 | "block-opening-brace-newline-after": "always-multi-line", 9 | "block-opening-brace-space-after": "always-single-line", 10 | "block-opening-brace-space-before": "always", 11 | "color-hex-case": "lower", 12 | "color-hex-length": "short", 13 | "color-no-invalid-hex": true, 14 | "comment-whitespace-inside": "always", 15 | "declaration-bang-space-after": "never", 16 | "declaration-bang-space-before": "always", 17 | "declaration-block-no-duplicate-properties": true, 18 | "declaration-block-no-shorthand-property-overrides": true, 19 | "declaration-block-semicolon-newline-after": "always", 20 | "declaration-block-semicolon-newline-before": "never-multi-line", 21 | "declaration-block-semicolon-space-before": "never", 22 | "declaration-block-trailing-semicolon": "always", 23 | "declaration-colon-space-after": "always", 24 | "declaration-colon-space-before": "never", 25 | "declaration-no-important": true, 26 | "font-family-name-quotes": "always-where-recommended", 27 | "font-family-no-missing-generic-family-keyword": true, 28 | "function-calc-no-unspaced-operator": true, 29 | "indentation": 2, 30 | "length-zero-no-unit": true, 31 | "max-empty-lines": 2, 32 | "max-nesting-depth": 3, 33 | "media-feature-colon-space-after": "always", 34 | "media-feature-colon-space-before": "never", 35 | "media-feature-name-no-vendor-prefix": true, 36 | "media-feature-range-operator-space-after": "always", 37 | "media-feature-range-operator-space-before": "always", 38 | "no-duplicate-at-import-rules": true, 39 | "no-eol-whitespace": true, 40 | "no-extra-semicolons": true, 41 | "no-invalid-double-slash-comments": true, 42 | "no-unknown-animations": true, 43 | "number-leading-zero": "never", 44 | "order/properties-order": [ 45 | "content", 46 | "display", 47 | "vertical-align", 48 | "visibility", 49 | "overflow", 50 | "position", 51 | "top", 52 | "right", 53 | "bottom", 54 | "left", 55 | "z-index", 56 | "white-space", 57 | "border-collapse", 58 | "border-spacing", 59 | "table-layout", 60 | "flex", 61 | "flex-basis", 62 | "flex-direction", 63 | "flex-flow", 64 | "flex-grow", 65 | "flex-shrink", 66 | "flex-wrap", 67 | "align-content", 68 | "align-items", 69 | "align-self", 70 | "justify-content", 71 | "order", 72 | "width", 73 | "min-width", 74 | "max-width", 75 | "height", 76 | "min-height", 77 | "max-height", 78 | "margin", 79 | "margin-top", 80 | "margin-right", 81 | "margin-bottom", 82 | "margin-left", 83 | "padding", 84 | "padding-top", 85 | "padding-right", 86 | "padding-bottom", 87 | "padding-left", 88 | "float", 89 | "clear", 90 | "columns", 91 | "column-gap", 92 | "column-fill", 93 | "column-rule", 94 | "column-span", 95 | "column-count", 96 | "column-width", 97 | "font", 98 | "font-family", 99 | "font-size", 100 | "font-smoothing", 101 | "font-style", 102 | "font-variant", 103 | "font-weight", 104 | "letter-spacing", 105 | "line-height", 106 | "list-style", 107 | "text-align", 108 | "text-decoration", 109 | "text-indent", 110 | "text-overflow", 111 | "text-rendering", 112 | "text-shadow", 113 | "text-transform", 114 | "text-wrap", 115 | "word-spacing", 116 | "color", 117 | "border", 118 | "border-top", 119 | "border-right", 120 | "border-bottom", 121 | "border-left", 122 | "border-width", 123 | "border-top-width", 124 | "border-right-width", 125 | "border-bottom-width", 126 | "border-left-width", 127 | "border-style", 128 | "border-top-style", 129 | "border-right-style", 130 | "border-bottom-style", 131 | "border-left-style", 132 | "border-radius", 133 | "border-top-left-radius", 134 | "border-top-right-radius", 135 | "border-bottom-left-radius", 136 | "border-bottom-right-radius", 137 | "border-color", 138 | "border-top-color", 139 | "border-right-color", 140 | "border-bottom-color", 141 | "border-left-color", 142 | "box-shadow", 143 | "outline", 144 | "outline-color", 145 | "outline-offset", 146 | "outline-style", 147 | "outline-width", 148 | "background", 149 | "background-color", 150 | "background-image", 151 | "background-repeat", 152 | "background-position", 153 | "background-size", 154 | "transform", 155 | "transition", 156 | "animation", 157 | "opacity", 158 | "caption-side", 159 | "cursor", 160 | "empty-cells", 161 | "pointer-events", 162 | "appearance", 163 | "quotes", 164 | "speak" 165 | ], 166 | "property-case": "lower", 167 | "property-no-vendor-prefix": true, 168 | "selector-max-compound-selectors": 3, 169 | "selector-max-id": 0, 170 | "selector-no-vendor-prefix": true, 171 | "selector-pseudo-class-case": "lower", 172 | "selector-pseudo-class-no-unknown": true, 173 | "selector-pseudo-element-case": "lower", 174 | "selector-pseudo-element-no-unknown": true, 175 | "selector-type-case": "lower", 176 | "selector-type-no-unknown": true, 177 | "shorthand-property-no-redundant-values": true, 178 | "string-no-newline": true, 179 | "string-quotes": "double", 180 | "unit-case": "lower", 181 | "unit-no-unknown": true, 182 | "value-list-comma-space-after": "always", 183 | "value-no-vendor-prefix": true 184 | } 185 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michael Xander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Launchbot 2 | 3 | A startpage to open collections of websites with one click. It’s like restoring tabs, but anywhere. 4 | 5 | ## Demo 6 | 7 | You can use the [official instance of Launchbot](https://launchbot.michaelxander.com) to try it out, or to use it indefinitely. You can customize the settings to your liking. Your data is only stored in your browser’s `localStorage`. 8 | 9 | Alternatively, you can [self-host it](#self-host-launchbot). 10 | 11 | ## Prerequisites 12 | 13 | None. You can use Launchbot from the file system with your browser, e.g. `file:///Users/michael/Projects/Launchbot/dist/index.html`. Just make sure you’re using the build files in the `dist` folder, all other files are for development only. 14 | 15 | ### Development 16 | 17 | > **Note:** I use and recommend [asdf-vm](https://github.com/asdf-vm/asdf) to install and manage versions of Node.js. Alternatives like `NVM` or `nodenv` can be used as long as there’s a `.nvmrc` etc. file. 18 | 19 | **Node.js**. Currently developed against Node.js `8.11.4`. 20 | 21 | Clone the repository and change into the `Launchbot` folder. Install dependencies: 22 | 23 | ```sh 24 | npm install 25 | ``` 26 | 27 | Used style guides *(enforced with stylelint and ESLint)*: 28 | 29 | - CSS: [michaelx/code-guide](https://github.com/michaelx/code-guide/blob/master/css-styleguide.md) 30 | - JavaScript: [airbnb-base](https://github.com/airbnb/javascript) 31 | 32 | ## Self-host Launchbot 33 | 34 | Clone or download the repository. Copy the `dist` folder (or the files within) where you want it. This can be within the file system or onto a web server. Navigate to the `index.html` in your browser. E.g. `file:///Users/michael/Projects/Launchbot/dist/index.html` or `https://launchbot.michaelxander.com` (you can omit the index.html on servers.) Open the “Settings” to customize your collections, manage plugins, and to adjust options. 35 | 36 | ### Embedded default config 37 | 38 | > **Note:** This requires the development environment. Also, if `localStorage` isn’t available, the embedded config will be used. 39 | 40 | You can also create your own embedded default config, so that you don’t have to adjust the settings twice. This is helpful if you’re using Launchbot from multiple devices. 41 | 42 | 1. Edit the file `src/js/config.js`. 43 | 2. Create a new build: `npm run build` 44 | 3. Upload the files in the folder `dist`. 45 | 46 | > **Note:** If you opened Launchbot before changing the embedded config, that config will be stored in `localStorage`. If you then change the embedded config and create a new build you have to reset it in the UI by clicking `Settings > Restore embedded config`. 47 | 48 | ### Upgrading 49 | 50 | `git pull`, or download the latest release and replace your copy with it. If an embedded default config is used, make sure to make a backup of it, or `stage` and `merge` it. 51 | 52 | ## Keyboard shortcuts 53 | 54 | Shortcut | Action 55 | :------- | :----- 56 | `1` to `9` | Open set 57 | `s` or `/` | Search 58 | `esc` | Close search 59 | 60 | ## Settings 61 | 62 | Once the settings panel is open, and you made all changes, make sure to scroll down to save or reset them. 63 | 64 | ### Sets 65 | 66 | These are your collections of websites. You can add as many as you want. Enter one URL per line. 67 | 68 | ### Options 69 | 70 | Key | Values | Description 71 | :------- | :----- | :----- 72 | darkMode | `true` or `false` | Enable dark mode or use light theme. 73 | searchEngine | URL as String | Search engine to use. 74 | faviconSize | Integer | Size of website icons. 75 | faviconService | URL as String | Service to use to get the website icons. 76 | linkTarget | `_blank` | Where to open website sets. 77 | keyboardShortcuts | `true` or `false` | Enable or disable keyboard shortcuts. 78 | 79 | ### Plugins 80 | 81 | Two default plugins are available. Development of new plugins should be straight forward. 82 | 83 | #### Weather 84 | 85 | Displays the weather. Example: 86 | 87 | ```text 88 | Weather in Berlin: Mostly Cloudy, 16°C (high 22°), 3km/h wind 0% precip., 75% cloud cover. 89 | ``` 90 | 91 | The weather plugin uses the Dark Sky API. Please familiarize yourself with their service before using it ([API Documentation](https://darksky.net/dev/docs)). 1,000 API calls per day are [free](https://darksky.net/dev/docs/faq). If `localStorage` is available, Launchbot caches by default the weather data for 30 minutes before making a new request. 92 | 93 | Dark Sky has disabled cross-origin resource sharing (CORS), so we need a proxy for that. If you don’t include your Dark Sky `apiKey` in your embedded default config, you could use the public instance of [cors-anywhere](https://cors-anywhere.herokuapp.com). If you deploy Launchbot with your `apiKey` in public, I’d host the proxy myself ([cors-anywhere on GitHub](https://github.com/Rob--W/cors-anywhere)). 94 | 95 | Key | Values | Description 96 | :------- | :----- | :----- 97 | name | String | Plugin identifier. Can’t be changed. 98 | enabled | `true` or `false` | Enable or disable the plugin. 99 | corsProxy | URL as String | CORS Proxy for Dark Sky API request. 100 | apiKey | String | Your Dark Sky API key. 101 | locationAlias | String | Name to display for the weather location. 102 | latitude | String | In decimal degrees for Dark Sky API request. 103 | longitude | String | In decimal degrees for Dark Sky API request. 104 | lang | String | Desired language for Dark Sky data. 105 | units | String | Desired units for Dark Sky data. 106 | 107 | #### Age 108 | 109 | Displays your precise age, as well as the percentage and years left until your defined goal. Example: 110 | 111 | ```text 112 | Age: 30.99692, 55.72% left until 70 113 | ``` 114 | 115 | Motivating, right? 116 | 117 | Key | Values | Description 118 | :------- | :----- | :----- 119 | name | String | Plugin identifier. Can’t be changed. 120 | enabled | `true` or `false` | Enable or disable the plugin. 121 | birthday | yyyy-mm-dd as String | Desired language for Dark Sky data. 122 | goal | Integer | Goal in years. 123 | 124 | ## Compatibility 125 | 126 | You need to allow pop-ups from Launchbot. Your browser should prompt you. 127 | 128 | Then, Launchbot should work with every major browser on most devices, as long as JavaScript is enabled. 129 | 130 | ### Exceptions 131 | 132 | Safari on iOS only allows one new tab per action. If there’s enough demand, I’ll try an [alternative approach](https://stackoverflow.com/a/46439467). You can use Launchbot with Chrome on iOS for the time being. 133 | 134 | ## Themes 135 | 136 | **Dark** *(default)* 137 | 138 | ![Dark theme](https://github.com/michaelx/launchbot/blob/master/docs/screenshots/theme-dark.png?raw=true) 139 | 140 | **Light** 141 | 142 | ![Light theme](https://github.com/michaelx/launchbot/blob/master/docs/screenshots/theme-light.png?raw=true) 143 | 144 | ## Author 145 | 146 | Michael Xander 147 | 148 | - 149 | - 150 | -------------------------------------------------------------------------------- /dist/assets/1.main.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[1],{11:function(n,e,t){"use strict";t.r(e),t.d(e,"init",function(){return r});var a=t(0),o=t(1),i=a.a.plugins.find(function(n){return"age"===n.name});Object(o.a)("js-plugin-age");var c=document.getElementById("js-plugin-age"),r=function(){return n=i.birthday,e=i.goal,t=(new Date-new Date(n))/31556952e3,o="left until",(a=100-t/e*100)<0&&(o="over goal of",a=-a),void(c.innerHTML="Age: ".concat(t.toFixed(5),", ").concat(a.toFixed(2),"% ").concat(o," ").concat(e));var n,e,t,a,o}}}]); -------------------------------------------------------------------------------- /dist/assets/2.main.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[2],{12:function(e,t,n){"use strict";n.r(t),n.d(t,"init",function(){return g});var r=n(2),a=n.n(r),c=n(3),o=n(0),i=n(1),u=o.a.plugins.find(function(e){return"weather"===e.name});Object(i.a)("js-plugin-weather");var s=document.getElementById("js-plugin-weather"),l="\n ".concat(u.corsProxy,"/\n https://api.darksky.net/forecast/\n ").concat(u.apiKey,"/\n ").concat(u.latitude,",").concat(u.longitude,"\n ?lang=").concat(u.lang,"\n &units=").concat(u.units,"\n &exclude=minutely,hourly,flags\n").replace(/\s+/g,""),p=function(e){var t={degrees:"F",speed:"mp/h"};"si"===u.units||"ca"===u.units?(t.degrees="C",t.speed="km/h"):"uk2"===u.units&&(t.degrees="C");var n={summary:e.currently.summary,temp:Math.round(e.currently.temperature),tempHi:Math.round(e.daily.data[0].temperatureHigh),wind:Math.round(e.currently.windSpeed),cloud:Math.round(100*e.currently.cloudCover),precip:Math.round(100*e.currently.precipProbability)};s.innerHTML="Weather in ".concat(u.locationAlias,": ").concat(n.summary,",\n ").concat(n.temp,"°").concat(t.degrees," (high ").concat(n.tempHi,"°),\n ").concat(n.wind).concat(t.speed," wind\n ").concat(n.precip,"% precip.,\n ").concat(n.cloud,"% cloud cover.")};function d(){return(d=Object(c.a)(a.a.mark(function e(t){var n,r;return a.a.wrap(function(e){for(;;)switch(e.prev=e.next){case 0:return e.prev=0,e.next=3,fetch(t);case 3:return n=e.sent,e.next=6,n.json();case 6:return r=e.sent,e.abrupt("return",r);case 10:throw e.prev=10,e.t0=e.catch(0),s.innerHTML="Weather plugin: Error, see console for details.",e.t0;case 14:case"end":return e.stop()}},e,null,[[0,10]])}))).apply(this,arguments)}function h(){if(Object(i.c)("localStorage")&&localStorage.getItem("plugin_weather__cache")){var e=JSON.parse(localStorage.getItem("plugin_weather__cache")),t=new Date,n=new Date(1e3*e.currently.time);if((t.getTime()-n.getTime())/1e3<1800)return void p(e)}s.innerHTML="Loading weather data…",function(e){return d.apply(this,arguments)}(l).then(function(e){p(e),Object(i.c)("localStorage")&&localStorage.setItem("plugin_weather__cache",JSON.stringify(e))})}var g=function(){return h()}}}]); -------------------------------------------------------------------------------- /dist/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelx/Launchbot/b1750b3d0cc3f05342e79240a91e265ab296bc16/dist/assets/favicon.png -------------------------------------------------------------------------------- /dist/assets/main.js: -------------------------------------------------------------------------------- 1 | !function(t){function e(e){for(var n,o,i=e[0],a=e[1],c=0,u=[];c\n \n \n \n ')}function i(t){var e=document.createElement("li");e.setAttribute("id",t),e.setAttribute("class","plugins__item"),r.appendChild(e)}function a(t){try{var e=window[t],n="__storage_test__";return e.setItem(n,n),e.removeItem(n),!0}catch(t){return t instanceof DOMException&&(22===t.code||1014===t.code||"QuotaExceededError"===t.name||"NS_ERROR_DOM_QUOTA_REACHED"===t.name)&&0!==storage.length}}},function(t,e,n){t.exports=n(4)},function(t,e,n){"use strict";function r(t,e,n,r,o,i,a){try{var c=t[i](a),s=c.value}catch(t){return void n(t)}c.done?e(s):Promise.resolve(s).then(r,o)}function o(t){return function(){var e=this,n=arguments;return new Promise(function(o,i){var a=t.apply(e,n);function c(t){r(a,o,i,c,s,"next",t)}function s(t){r(a,o,i,c,s,"throw",t)}c(void 0)})}}n.d(e,"a",function(){return o})},function(t,e,n){var r=function(t){"use strict";var e,n=Object.prototype,r=n.hasOwnProperty,o="function"==typeof Symbol?Symbol:{},i=o.iterator||"@@iterator",a=o.asyncIterator||"@@asyncIterator",c=o.toStringTag||"@@toStringTag";function s(t,e,n,r){var o=e&&e.prototype instanceof m?e:m,i=Object.create(o.prototype),a=new O(r||[]);return i._invoke=function(t,e,n){var r=l;return function(o,i){if(r===d)throw new Error("Generator is already running");if(r===h){if("throw"===o)throw i;return I()}for(n.method=o,n.arg=i;;){var a=n.delegate;if(a){var c=j(a,n);if(c){if(c===p)continue;return c}}if("next"===n.method)n.sent=n._sent=n.arg;else if("throw"===n.method){if(r===l)throw r=h,n.arg;n.dispatchException(n.arg)}else"return"===n.method&&n.abrupt("return",n.arg);r=d;var s=u(t,e,n);if("normal"===s.type){if(r=n.done?h:f,s.arg===p)continue;return{value:s.arg,done:n.done}}"throw"===s.type&&(r=h,n.method="throw",n.arg=s.arg)}}}(t,n,a),i}function u(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}t.wrap=s;var l="suspendedStart",f="suspendedYield",d="executing",h="completed",p={};function m(){}function v(){}function y(){}var g={};g[i]=function(){return this};var b=Object.getPrototypeOf,w=b&&b(b(S([])));w&&w!==n&&r.call(w,i)&&(g=w);var E=y.prototype=m.prototype=Object.create(g);function x(t){["next","throw","return"].forEach(function(e){t[e]=function(t){return this._invoke(e,t)}})}function L(t){var e;this._invoke=function(n,o){function i(){return new Promise(function(e,i){!function e(n,o,i,a){var c=u(t[n],t,o);if("throw"!==c.type){var s=c.arg,l=s.value;return l&&"object"==typeof l&&r.call(l,"__await")?Promise.resolve(l.__await).then(function(t){e("next",t,i,a)},function(t){e("throw",t,i,a)}):Promise.resolve(l).then(function(t){s.value=t,i(s)},function(t){return e("throw",t,i,a)})}a(c.arg)}(n,o,e,i)})}return e=e?e.then(i,i):i()}}function j(t,n){var r=t.iterator[n.method];if(r===e){if(n.delegate=null,"throw"===n.method){if(t.iterator.return&&(n.method="return",n.arg=e,j(t,n),"throw"===n.method))return p;n.method="throw",n.arg=new TypeError("The iterator does not provide a 'throw' method")}return p}var o=u(r,t.iterator,n.arg);if("throw"===o.type)return n.method="throw",n.arg=o.arg,n.delegate=null,p;var i=o.arg;return i?i.done?(n[t.resultName]=i.value,n.next=t.nextLoc,"return"!==n.method&&(n.method="next",n.arg=e),n.delegate=null,p):i:(n.method="throw",n.arg=new TypeError("iterator result is not an object"),n.delegate=null,p)}function k(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function _(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function O(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(k,this),this.reset(!0)}function S(t){if(t){var n=t[i];if(n)return n.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var o=-1,a=function n(){for(;++o=0;--i){var a=this.tryEntries[i],c=a.completion;if("root"===a.tryLoc)return o("end");if(a.tryLoc<=this.prev){var s=r.call(a,"catchLoc"),u=r.call(a,"finallyLoc");if(s&&u){if(this.prev=0;--n){var o=this.tryEntries[n];if(o.tryLoc<=this.prev&&r.call(o,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),_(n),p}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var r=n.completion;if("throw"===r.type){var o=r.arg;_(n)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,r){return this.delegate={iterator:S(t),resultName:n,nextLoc:r},"next"===this.method&&(this.arg=e),p}},t}(t.exports);try{regeneratorRuntime=r}catch(t){Function("r","regeneratorRuntime = r")(r)}},function(t,e,n){var r={"./age.js":[11,1],"./weather.js":[12,2]};function o(t){if(!n.o(r,t))return Promise.resolve().then(function(){var e=new Error("Cannot find module '"+t+"'");throw e.code="MODULE_NOT_FOUND",e});var e=r[t],o=e[0];return n.e(e[1]).then(function(){return n(o)})}o.keys=function(){return Object.keys(r)},o.id=5,t.exports=o},function(t,e,n){var r=n(7);"string"==typeof r&&(r=[[t.i,r,""]]);var o={hmr:!0,transform:void 0,insertInto:void 0};n(8)(r,o);r.locals&&(t.exports=r.locals)},function(t,e,n){},function(t,e,n){var r,o,i={},a=(r=function(){return window&&document&&document.all&&!window.atob},function(){return void 0===o&&(o=r.apply(this,arguments)),o}),c=function(t){var e={};return function(t,n){if("function"==typeof t)return t();if(void 0===e[t]){var r=function(t,e){return e?e.querySelector(t):document.querySelector(t)}.call(this,t,n);if(window.HTMLIFrameElement&&r instanceof window.HTMLIFrameElement)try{r=r.contentDocument.head}catch(t){r=null}e[t]=r}return e[t]}}(),s=null,u=0,l=[],f=n(9);function d(t,e){for(var n=0;n=0&&l.splice(e,1)}function v(t){var e=document.createElement("style");if(void 0===t.attrs.type&&(t.attrs.type="text/css"),void 0===t.attrs.nonce){var r=function(){0;return n.nc}();r&&(t.attrs.nonce=r)}return y(e,t.attrs),p(t,e),e}function y(t,e){Object.keys(e).forEach(function(n){t.setAttribute(n,e[n])})}function g(t,e){var n,r,o,i;if(e.transform&&t.css){if(!(i="function"==typeof e.transform?e.transform(t.css):e.transform.default(t.css)))return function(){};t.css=i}if(e.singleton){var a=u++;n=s||(s=v(e)),r=E.bind(null,n,a,!1),o=E.bind(null,n,a,!0)}else t.sourceMap&&"function"==typeof URL&&"function"==typeof URL.createObjectURL&&"function"==typeof URL.revokeObjectURL&&"function"==typeof Blob&&"function"==typeof btoa?(n=function(t){var e=document.createElement("link");return void 0===t.attrs.type&&(t.attrs.type="text/css"),t.attrs.rel="stylesheet",y(e,t.attrs),p(t,e),e}(e),r=function(t,e,n){var r=n.css,o=n.sourceMap,i=void 0===e.convertToAbsoluteUrls&&o;(e.convertToAbsoluteUrls||i)&&(r=f(r));o&&(r+="\n/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(o))))+" */");var a=new Blob([r],{type:"text/css"}),c=t.href;t.href=URL.createObjectURL(a),c&&URL.revokeObjectURL(c)}.bind(null,n,e),o=function(){m(n),n.href&&URL.revokeObjectURL(n.href)}):(n=v(e),r=function(t,e){var n=e.css,r=e.media;r&&t.setAttribute("media",r);if(t.styleSheet)t.styleSheet.cssText=n;else{for(;t.firstChild;)t.removeChild(t.firstChild);t.appendChild(document.createTextNode(n))}}.bind(null,n),o=function(){m(n)});return r(t),function(e){if(e){if(e.css===t.css&&e.media===t.media&&e.sourceMap===t.sourceMap)return;r(t=e)}else o()}}t.exports=function(t,e){if("undefined"!=typeof DEBUG&&DEBUG&&"object"!=typeof document)throw new Error("The style-loader cannot be used in a non-browser environment");(e=e||{}).attrs="object"==typeof e.attrs?e.attrs:{},e.singleton||"boolean"==typeof e.singleton||(e.singleton=a()),e.insertInto||(e.insertInto="head"),e.insertAt||(e.insertAt="bottom");var n=h(t,e);return d(n,e),function(t){for(var r=[],o=0;o0&&Number(e.key)<=9&&Number(e.key)<=t.sets.length&&t.openSet(Number(e.key-1)))}),a.addEventListener("keydown",function(t){t.stopPropagation(),"Escape"===t.key&&a.blur()})),Array.from(c).forEach(function(t){t.addEventListener("click",function(t){t.stopPropagation()})})},f=n(1),d=document.getElementById("js-welcome-modal"),h=document.getElementById("js-welcome-dismiss"),p=function(){Object(f.c)("localStorage")&&!localStorage.getItem("launchbot__welcome")&&(d.style.display="block"),h.addEventListener("click",function(){d.style.display="none",localStorage.setItem("launchbot__welcome",!1)})},m=n(2),v=n.n(m),y=n(3);function g(){return(g=Object(y.a)(v.a.mark(function t(e){return v.a.wrap(function(t){for(;;)switch(t.prev=t.next){case 0:return t.prev=0,t.next=3,n(5)("./"+e.name+".js");case 3:t.sent.init(),t.next=10;break;case 7:throw t.prev=7,t.t0=t.catch(0),t.t0;case 10:case"end":return t.stop()}},t,null,[[0,7]])}))).apply(this,arguments)}function b(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=[],r=!0,o=!1,i=void 0;try{for(var a,c=t[Symbol.iterator]();!(r=(a=c.next()).done)&&(n.push(a.value),!e||n.length!==e);r=!0);}catch(t){o=!0,i=t}finally{try{r||null==c.return||c.return()}finally{if(o)throw i}}return n}(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}()}function w(t){return function(t){if(Array.isArray(t)){for(var e=0,n=new Array(t.length);e\n \n \n \n ')}),L.innerHTML=T;var B="";Object.entries(r.a.options).forEach(function(t){var e=b(t,2),n=e[0],r=e[1],o="",i="input input--settings",a="text";"darkMode"!==n&&"keyboardShortcuts"!==n||(a="checkbox",i="",!0===r&&(o="checked")),B+='
  • \n \n \n
  • ")}),E.innerHTML=B,r.a.plugins.forEach(function(t){var e=document.createElement("ul");e.setAttribute("class","settings-form-list settings-form-list--plugin"),Object.entries(t).forEach(function(t){var n=b(t,2),r=n[0],o=n[1],i="",a="input input--settings",c="text";"enabled"===r&&(c="checkbox",a="",!0===o&&(i="checked")),"name"===r&&(i="disabled"),e.innerHTML+='
  • \n \n \n
  • ")}),x.appendChild(e)}),j.innerHTML="export default ".concat(JSON.stringify(r.a,null,2),";"),S.addEventListener("click",function(){k.style.position="relative",_.classList.add("is-active")}),I.addEventListener("click",function(t){t.preventDefault(),_.classList.remove("is-active"),k.style.position="fixed"}),A.addEventListener("click",function(t){var e;t.preventDefault(),e=function(){var t=[];L.childNodes.forEach(function(e){t.push({name:e.children[0].value,items:w(e.children[1].value.split("\n"))})});var e={};E.childNodes.forEach(function(t){var n=t.children[1],r=n.name,o=n.type,i=n.checked,a=t.children[1].value;"checkbox"===o&&(a=i),e[r]=a});var n=[];return x.childNodes.forEach(function(t){var e={};t.childNodes.forEach(function(t){var n=t.children[1],r=n.name,o=n.type,i=n.checked,a=t.children[1].value;"checkbox"===o&&(a=i),e[r]=a}),n.push(e)}),{sets:t,options:e,plugins:n}}(),localStorage.setItem("config",JSON.stringify(e)),window.location.reload(!0)}),N.addEventListener("click",function(t){t.preventDefault(),localStorage.removeItem("config"),window.location.reload(!0)});var C=function(t){var e=t.target;L.removeChild(e.parentNode)};function M(){L.childNodes.forEach(function(t){t.children[2].addEventListener("click",C)})}M(),O.addEventListener("click",function(){var t=document.createElement("li");t.setAttribute("class","settings-form-list__item"),t.innerHTML='\n \n \n \n ',L.appendChild(t),M()}),function(t){!1===t.options.darkMode&&document.documentElement.classList.add("theme-light");var e="";t.sets.forEach(function(t,n){var o=t.name,i=t.items,a="";i.forEach(function(t){a+=Object(f.b)(t,r.a)}),e+='
  • \n
    \n

    '.concat(o,'

    \n ').concat(n+1,"\n
      ").concat(a,"
    \n
    \n
  • ")}),document.getElementById("js-sets").innerHTML=e,t.options.searchEngine&&(document.getElementById("js-search-form").action=t.options.searchEngine),p(),l(t)}(new i(r.a));n(6)}]); -------------------------------------------------------------------------------- /dist/assets/style.css: -------------------------------------------------------------------------------- 1 | :root{--color-text:#dfe1e8;--color-heading:#c9a8fa;--color-background:#0f0f14;--color-set-name:#dfe1e8;--color-set-id:rgba(201,168,250,0.24);--color-set-background:#131318;--color-set-border:rgba(201,168,250,0.08);--color-set-border--hover:#c9a8fa;--color-set-item:#1a1922;--color-set-item--hover:#dfe1e8;--color-button-text:#c9a8fa;--color-button-text--hover:#99ecfd;--color-button-background:transparent;--color-button-background--hover:transparent;--color-button-border:#c9a8fa;--color-button-border--hover:#99ecfd;--color-button-text--nav:#c9a8fa;--color-button-background--nav:transparent;--color-button-border--nav:rgba(201,168,250,0.08);--color-search-text-placeholder:#c9a8fa;--color-search-background:#1a1922;--color-settings-background:#131318;--color-input-text:#dfe1e8;--color-input-text-placeholder:#c9a8fa;--color-input-background:#1a1922;--color-input-border:rgba(201,168,250,0.08);--color-input-border--focus:#dfe1e8;--color-plugin-text:#c0c5ce;--color-plugin-row-odd:#16161d;--color-plugin-row-even:#1a1922}.theme-light{--color-text:#4f5b66;--color-heading:#4f5b66;--color-background:#f8fbff;--color-set-name:#4f5b66;--color-set-id:#dfe1e8;--color-set-background:#fbfdff;--color-set-border:rgba(153,236,253,0.16);--color-set-border--hover:#99ecfd;--color-set-item:#eff1f5;--color-set-item--hover:#4f5b66;--color-button-text:#4f5b66;--color-button-text--hover:#4f5b66;--color-button-background:#eff1f5;--color-button-background--hover:#eff1f5;--color-button-border:transparent;--color-button-border--hover:#99ecfd;--color-button-text--nav:#a7adba;--color-button-background--nav:#eff1f5;--color-button-border--nav:transparent;--color-search-text-placeholder:#a7adba;--color-search-background:#eff1f5;--color-settings-background:#fbfdff;--color-input-text:#4f5b66;--color-input-text-placeholder:#a7adba;--color-input-background:#eff1f5;--color-input-border:rgba(153,236,253,0.16);--color-input-border--focus:#99ecfd;--color-plugin-text:#a7adba;--color-plugin-row-odd:#fbfdff;--color-plugin-row-even:#eff1f5}.link{color:inherit;border-bottom:1px solid #c9a8fa;border-bottom:1px solid var(--color-button-text)}.link:hover{color:#99ecfd;color:var(--color-button-text--hover);border-color:#99ecfd;border-color:var(--color-button-border--hover)}.u-text-center{text-align:center!important}.u-text-bold{font-weight:600!important}html{-webkit-text-size-adjust:none;-moz-text-size-adjust:none;-ms-text-size-adjust:none;text-size-adjust:none}body{margin:0}footer,header,main,nav,section{display:block}article,body,div,footer,form,header,html,input[type=search],input[type=text],li,main,ol,p,section,textarea,ul{box-sizing:border-box}a{background-color:transparent;-webkit-text-decoration-skip:objects;text-decoration-skip:objects}a:active,a:hover{outline-width:0}img{vertical-align:middle;border-style:none}button,input,textarea{margin:0;font:inherit}body,html{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}body{font-family:SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;font-size:1em;line-height:1.5;color:#dfe1e8;color:var(--color-text);background-color:#0f0f14;background-color:var(--color-background)}a{text-decoration:none}ol,ul{margin-top:0;margin-bottom:0;padding-left:0}hr{width:100%;height:.25em;margin:2.5em 0 2em;border:0;background-color:rgba(201,168,250,.08);background-color:var(--color-set-border)}h1,h2,h3,h4{margin-top:0;margin-bottom:0;font-weight:400;line-height:1.3}.h2,.h3,.h4{margin-bottom:1rem;font-weight:400;color:#c9a8fa;color:var(--color-heading)}.h2{font-size:1.25em}@media only screen and (min-width:35.5em){.h2{font-size:1.5em}}.h3{font-size:1em}@media only screen and (min-width:35.5em){.h3{font-size:1.25em}}@media only screen and (min-width:48em){.h3{font-size:1.375em}}.h4{font-size:1em}p{margin-top:0;margin-bottom:1.2em}.site-wrapper{max-width:120rem;margin-right:auto;margin-left:auto}.header{width:100%;margin-bottom:3em;padding:1em 1em 0}@media only screen and (min-width:48em){.header{margin-bottom:4em;padding:2em 2em 0}}@media only screen and (min-width:64em){.header{margin-bottom:4.5em}}@media only screen and (min-width:90em){.header{margin-bottom:6em}}.nav:after,.nav:before{content:"";display:table}.nav:after{clear:both}.nav__search{float:left}.nav__search[focus-within]{width:100%}.nav__search:focus-within{width:100%}@media only screen and (min-width:35.5em){.nav__search[focus-within]{width:auto}.nav__search:focus-within{width:auto}}.nav-list{float:right;list-style:none}.nav-list__item{display:inline-block;padding:0 .5em;letter-spacing:.025em}.nav-list__item:last-child{padding-right:0}@media only screen and (min-width:35.5em){.nav-list__item{padding-right:1em;padding-left:1em}}@media only screen and (min-width:64em){.nav-list__item{padding-right:1.25em;padding-left:1.25em}}.content-wrapper{margin-bottom:4em}@media only screen and (min-width:35.5em){.content-wrapper{margin-bottom:6em}}@media only screen and (min-width:48em){.content-wrapper{margin-bottom:8em}}@media only screen and (min-width:64em){.content-wrapper{margin-bottom:12em}}.footer{width:100%}@media only screen and (min-width:48em){.footer{position:fixed;bottom:0;left:0}}.settings{display:none;width:100%;padding:1em;background-color:#131318;background-color:var(--color-settings-background)}.settings.is-active{display:block}@media only screen and (min-width:48em){.settings{padding:2em}}.settings-form,.settings-form__group,.settings__export,.settings__intro{margin-bottom:2em}.settings-form-list{margin-bottom:1em;list-style:none}.settings-form-list--plugin{margin-bottom:2em}.settings-form-list__item{margin-bottom:1em}.settings-form-list__item:last-child{margin-bottom:0}.settings-form-list__item--plugin:first-child{font-weight:600;color:#99ecfd;color:var(--color-button-text--hover)}.button{display:inline-block;vertical-align:middle;position:relative;padding:.75rem 1.5rem;box-sizing:border-box;font-size:.875rem;font-weight:400;text-align:center;text-decoration:none;color:#c9a8fa;color:var(--color-button-text);border-radius:4px;border:2px solid #c9a8fa;border-color:var(--color-button-border);background-color:transparent;background-color:var(--color-button-background);cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.button:focus{outline:none}.button.is-active,.button:hover{color:#99ecfd;color:var(--color-button-text--hover);border-color:#99ecfd;border-color:var(--color-button-border--hover);background-color:transparent;background-color:var(--color-button-background--hover)}.button--small{padding:.25rem .5rem}.button--nav{padding-right:1rem;padding-left:1rem;font-size:1em;color:#c9a8fa;color:var(--color-button-text--nav);border-color:rgba(201,168,250,.08);border-color:var(--color-button-border--nav);background-color:transparent;background-color:var(--color-button-background--nav)}@media only screen and (min-width:48em){.button--nav{padding-right:1.5rem;padding-left:1.5rem;font-size:.875rem}}.button-set{list-style:none}.button-set__item{display:inline-block;padding-right:.75rem;padding-bottom:.75rem}.grid{list-style:none}.grid__item{display:inline-block;vertical-align:top;width:100%;padding:0 1em 2em}@media only screen and (min-width:48em){.grid__item{padding:0 2em 2.5em}}@media only screen and (min-width:64em){.grid__item{width:50%}.grid__item:nth-child(odd){padding-right:1.5em}.grid__item:nth-child(2n){padding-left:1.5em}}@media only screen and (min-width:90em){.grid__item{width:33.33%}.grid__item:nth-child(n){padding:0 2em 4em}}.input{display:block;width:100%;padding:.75rem;line-height:1.5;text-align:left;color:#dfe1e8;color:var(--color-input-text);border:2px solid rgba(201,168,250,.08);border:2px solid var(--color-input-border);border-radius:0;background-color:#1a1922;background-color:var(--color-input-background);-webkit-appearance:none;-moz-appearance:none;appearance:none}.input::-webkit-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.input::-moz-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.input:-ms-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.input::-ms-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.input::placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.input:focus{border-color:#dfe1e8;border-color:var(--color-input-border--focus);outline:0}@media only screen and (min-width:48em){.input{font-size:.875rem}}.input--search{width:10rem;letter-spacing:.025rem;text-overflow:ellipsis;border:2px solid transparent;border-radius:4px;background-color:#1a1922;background-color:var(--color-search-background)}.input--search::-webkit-input-placeholder{color:#c9a8fa;color:var(--color-search-text-placeholder)}.input--search::-moz-placeholder{color:#c9a8fa;color:var(--color-search-text-placeholder)}.input--search:-ms-input-placeholder{color:#c9a8fa;color:var(--color-search-text-placeholder)}.input--search::-ms-input-placeholder{color:#c9a8fa;color:var(--color-search-text-placeholder)}.input--search::placeholder{color:#c9a8fa;color:var(--color-search-text-placeholder)}.input--search:focus{width:100%;color:#99ecfd;color:var(--color-button-text--hover);border-color:#99ecfd;border-color:var(--color-button-border--hover);transition:all .15s ease-in}.input--search:focus::-webkit-input-placeholder{color:#99ecfd;color:var(--color-button-text--hover)}.input--search:focus::-moz-placeholder{color:#99ecfd;color:var(--color-button-text--hover)}.input--search:focus:-ms-input-placeholder{color:#99ecfd;color:var(--color-button-text--hover)}.input--search:focus::-ms-input-placeholder{color:#99ecfd;color:var(--color-button-text--hover)}.input--search:focus::placeholder{color:#99ecfd;color:var(--color-button-text--hover)}@media only screen and (min-width:35.5em){.input--search{width:20rem}.input--search:focus{width:25rem}}.input--settings{max-width:25rem}.input--settings:disabled{border-color:transparent}.input--set{max-width:100%;border-bottom:0}.input-label{max-width:100%;padding-bottom:.25rem}.input-label,.textarea{display:block;font-size:.875rem}.textarea{width:100%;height:10rem;padding:.75rem;resize:vertical;line-height:1.5;text-align:left;color:#dfe1e8;color:var(--color-input-text);border:2px solid rgba(201,168,250,.08);border:2px solid var(--color-input-border);background-color:#1a1922;background-color:var(--color-input-background);-webkit-appearance:none;-moz-appearance:none;appearance:none}.textarea::-webkit-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.textarea::-moz-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.textarea:-ms-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.textarea::-ms-input-placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.textarea::placeholder{color:#c9a8fa;color:var(--color-input-text-placeholder)}.textarea:focus{border-color:#dfe1e8;border-color:var(--color-input-border--focus);outline:0}.textarea--large{height:20rem}.list{margin-bottom:1.5em;padding-left:2.5em}.list li,.list li>p{margin-bottom:.75em}.list li:last-child,.list li>p:last-child{margin-bottom:0}.modal{display:none;overflow-y:auto;position:fixed;top:0;left:0;z-index:100;width:100%;height:100%;background-color:rgba(0,0,0,.64)}.modal__content{padding:1rem;font-size:.875rem;color:#dfe1e8;color:var(--color-text);border:2px solid rgba(201,168,250,.08);border:2px solid var(--color-set-border);border-radius:4px;background-color:#131318;background-color:var(--color-set-background);margin-right:auto;margin-left:auto;max-width:43.75rem}@media only screen and (min-width:35.5em){.modal__content{padding:2rem;max-width:47.75rem}}@media only screen and (min-width:48em){.modal__content{margin-top:6rem}}.plugins{max-width:120rem;font-size:.875rem;list-style:none;color:#c0c5ce;color:var(--color-plugin-text);max-width:116rem;margin-right:auto;margin-left:auto}.plugins__item{padding:.5em 1em}.plugins__item:nth-child(odd){background-color:#16161d;background-color:var(--color-plugin-row-odd)}.plugins__item:nth-child(2n){background-color:#1a1922;background-color:var(--color-plugin-row-even)}@media only screen and (min-width:48em){.plugins__item{padding-right:2em;padding-left:2em}}.set{position:relative;padding:1em .625em .75em;text-align:left;border:2px solid rgba(201,168,250,.08);border:2px solid var(--color-set-border);border-radius:4px;background-color:#131318;background-color:var(--color-set-background);cursor:pointer}.set:hover{border-color:#c9a8fa;border-color:var(--color-set-border--hover)}@media only screen and (min-width:48em){.set{padding:1.5em .75em 1em}}.set__name{display:block;padding:0 1.375rem 1rem .375rem;font-size:1em;font-weight:600;letter-spacing:.025em;line-height:1.3;color:#dfe1e8;color:var(--color-set-name)}@media only screen and (min-width:48em){.set__name{padding:0 2.5rem 1.5rem .75rem;font-size:1.125em}}.set__id{position:absolute;top:1em;right:1em;padding-top:2px;font-size:.875rem;line-height:1.3;color:rgba(201,168,250,.24);color:var(--color-set-id)}@media only screen and (min-width:48em){.set__id{top:1.5em;right:1.5em;padding-top:.375rem}}.set-item{display:inline-block;padding:0 .375em .75em}@media only screen and (min-width:48em){.set-item{padding:0 .75em 1.5em}}.set-item__img{border:.75em solid #1a1922;border:.75em solid var(--color-set-item);border-radius:50%;background-color:#1a1922;background-color:var(--color-set-item)}.set-item__img:hover{border-color:#dfe1e8;border-color:var(--color-set-item--hover);background-color:#dfe1e8;background-color:var(--color-set-item--hover)}@media only screen and (min-width:48em){.set-item__img{border-width:1em}} 2 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Launchbot – Your Personal Startpage 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
    20 | 21 | 22 |
    23 | 24 | 35 | 36 |
    37 | 38 | 39 |
    40 |
    41 |
      42 | 43 |
      44 |
      45 | 46 | 47 |
      48 |
        49 |
        50 | 51 | 52 |
        53 | 54 |
        55 |

        Settings

        56 |

        57 | If you’re new to Launchbot, please refer to the documentation to look up all available settings. At the moment there are no input validations, if the documentation isn’t enough I’ll add it. 58 |

        59 |
        60 | 61 | 62 |
        63 | 64 |
        65 |

        Sets

        66 |
          67 | Add a set 68 |
          69 | 70 |
          71 |

          Options

          72 |
            73 |
            74 | 75 |
            76 |

            Plugins

            77 |
            78 |
            79 | 80 |
            81 | 82 |
              83 |
            • 84 | 85 |
            • 87 | 88 |
            • 90 | 91 |
            • 92 |
            93 | 94 |
            95 | 96 | 97 |
            98 |

            Export config

            99 |

            100 | Below is the currently used config. Copy it to make a backup. You can 101 | make it your default embedded config for all builds by replacing the 102 | content of “/src/js/config.js” with it. 103 |

            104 | 105 |
            106 | 107 | 108 |
            109 |

            About Launchbot

            110 |

            111 | Launchbot is free and open source. It’s easy to self-host. I’ve been using it almost daily in my morning routine for over eight years. I hope you’ll find it helpful too. —Michael Xander 112 |

            113 |
            114 | 115 |
            116 | 117 | 118 |
            119 | 120 | 121 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /docs/screenshots/theme-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelx/Launchbot/b1750b3d0cc3f05342e79240a91e265ab296bc16/docs/screenshots/theme-dark.png -------------------------------------------------------------------------------- /docs/screenshots/theme-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelx/Launchbot/b1750b3d0cc3f05342e79240a91e265ab296bc16/docs/screenshots/theme-light.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Launchbot", 3 | "version": "3.0.3", 4 | "description": "Self-hosted open source startpage. Open collections of websites with one click. It’s like restoring tabs, but anywhere.", 5 | "author": "Michael Xander", 6 | "bugs": "https://github.com/michaelx/Launchbot/issues", 7 | "license": "MIT", 8 | "repository": "https://github.com/michaelx/Launchbot", 9 | "devDependencies": { 10 | "@babel/core": "^7.4.5", 11 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 12 | "@babel/plugin-transform-runtime": "^7.5.0", 13 | "@babel/preset-env": "^7.4.5", 14 | "babel-loader": "^8.0.6", 15 | "clean-webpack-plugin": "^3.0.0", 16 | "css-loader": "^3.0.0", 17 | "cssnano": "^4.1.10", 18 | "eslint": "^5.16.0", 19 | "eslint-config-airbnb-base": "^13.1.0", 20 | "eslint-loader": "^2.1.2", 21 | "eslint-plugin-import": "^2.18.0", 22 | "handlebars": "^4.5.3", 23 | "handlebars-loader": "^1.7.1", 24 | "html-webpack-plugin": "^3.2.0", 25 | "mini-css-extract-plugin": "^0.7.0", 26 | "postcss": "^7.0.17", 27 | "postcss-advanced-variables": "^3.0.0", 28 | "postcss-import": "^12.0.1", 29 | "postcss-loader": "^3.0.0", 30 | "postcss-nested": "^4.1.2", 31 | "postcss-preset-env": "^6.6.0", 32 | "style-loader": "^0.23.1", 33 | "stylelint": "^10.1.0", 34 | "stylelint-order": "^3.0.0", 35 | "stylelint-webpack-plugin": "^0.10.5", 36 | "webpack": "^4.35.0", 37 | "webpack-cli": "^3.3.5", 38 | "webpack-dev-server": "^3.7.2" 39 | }, 40 | "scripts": { 41 | "build": "webpack --config webpack.config.js --mode=production --display-error-details", 42 | "start:dev": "webpack-dev-server" 43 | }, 44 | "dependencies": { 45 | "@babel/runtime": "^7.5.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'postcss-advanced-variables': {}, 5 | 'postcss-nested': {}, 6 | 'postcss-preset-env': { 7 | autoprefixer: { 8 | cascade: true 9 | } 10 | }, 11 | 'cssnano': {} 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelx/Launchbot/b1750b3d0cc3f05342e79240a91e265ab296bc16/src/images/favicon.png -------------------------------------------------------------------------------- /src/index.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Launchbot – Your Personal Startpage 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
            20 | 21 | 22 |
            23 | 24 | 35 | 36 |
            37 | 38 | 39 |
            40 |
            41 |
              42 | 43 |
              44 |
              45 | 46 | 47 |
              48 |
                49 |
                50 | 51 | 52 |
                53 | 54 |
                55 |

                Settings

                56 |

                57 | If you’re new to Launchbot, please refer to the documentation to look up all available settings. At the moment there are no input validations, if the documentation isn’t enough I’ll add it. 58 |

                59 |
                60 | 61 | 62 |
                63 | 64 |
                65 |

                Sets

                66 |
                  67 | Add a set 68 |
                  69 | 70 |
                  71 |

                  Options

                  72 |
                    73 |
                    74 | 75 |
                    76 |

                    Plugins

                    77 |
                    78 |
                    79 | 80 |
                    81 | 82 |
                      83 |
                    • 84 | 85 |
                    • 87 | 88 |
                    • 90 | 91 |
                    • 92 |
                    93 | 94 |
                    95 | 96 | 97 |
                    98 |

                    Export config

                    99 |

                    100 | Below is the currently used config. Copy it to make a backup. You can 101 | make it your default embedded config for all builds by replacing the 102 | content of “/src/js/config.js” with it. 103 |

                    104 | 105 |
                    106 | 107 | 108 |
                    109 |

                    About Launchbot

                    110 |

                    111 | Launchbot is free and open source. It’s easy to self-host. I’ve been using it almost daily in my morning routine for over eight years. I hope you’ll find it helpful too. —Michael Xander 112 |

                    113 |
                    114 | 115 |
                    116 | 117 | 118 |
                    119 | 120 | 121 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './js/main'; 2 | import './styles/main.css'; 3 | -------------------------------------------------------------------------------- /src/js/components/configLoader.js: -------------------------------------------------------------------------------- 1 | import embeddedConfig from '../config'; 2 | import { storageAvailable } from './helpers'; 3 | 4 | 5 | function getConfig() { 6 | if (storageAvailable('localStorage')) { 7 | // If localStorage is available and not yet initialized, 8 | // store the embedded config in it. 9 | if (!localStorage.getItem('config')) { 10 | localStorage.setItem('config', JSON.stringify(embeddedConfig)); 11 | } 12 | return JSON.parse(localStorage.getItem('config')); 13 | } 14 | 15 | // Otherwise use the embedded config and hide the settings button 16 | document.getElementById('js-settings-button').style.display = 'none'; 17 | return embeddedConfig; 18 | } 19 | 20 | 21 | export default getConfig(); 22 | -------------------------------------------------------------------------------- /src/js/components/handlers.js: -------------------------------------------------------------------------------- 1 | const searchDOM = document.getElementById('js-search-text'); 2 | const setItemsDOM = document.getElementsByClassName('js-set-item-link'); 3 | const setsDOM = document.getElementsByClassName('set'); 4 | const settingsDOM = document.getElementById('js-settings'); 5 | 6 | 7 | export default function (launchbot) { 8 | // Open sets with a click 9 | Array.from(setsDOM).forEach((set, i) => { 10 | set.addEventListener('click', () => { 11 | launchbot.openSet(i); 12 | }); 13 | }); 14 | 15 | 16 | if (launchbot.options.keyboardShortcuts === true) { 17 | // Global keyboard shortcuts 18 | window.addEventListener('keydown', (event) => { 19 | // Prevent global shortcuts to run while the settings are open 20 | if (settingsDOM.classList.contains('is-active')) return; 21 | 22 | // Search 23 | if (event.key === 's' || event.key === '/') { 24 | searchDOM.focus(); 25 | // Prevent 's' to be added to the search field 26 | event.preventDefault(); 27 | } 28 | 29 | // Open sets 30 | // 31 | // Note: Currently only the first 9 sets have a shortcut. 32 | if ( 33 | Number(event.key) > 0 34 | && Number(event.key) <= 9 35 | && Number(event.key) <= launchbot.sets.length 36 | ) { 37 | launchbot.openSet(Number(event.key - 1)); 38 | } 39 | }); 40 | 41 | // Search 42 | searchDOM.addEventListener('keydown', (event) => { 43 | // Prevent global shortcuts to run while typing text 44 | event.stopPropagation(); 45 | 46 | if (event.key === 'Escape') searchDOM.blur(); 47 | }); 48 | } 49 | 50 | 51 | // Stop set item events from bubbling up 52 | Array.from(setItemsDOM).forEach((item) => { 53 | item.addEventListener('click', (event) => { 54 | event.stopPropagation(); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/js/components/helpers.js: -------------------------------------------------------------------------------- 1 | const pluginsDefaultDOM = document.getElementById('js-plugins-default'); 2 | 3 | 4 | // Link style with favicon 5 | export function formatItemBar(item, config) { 6 | return `
                  • 7 | 13 | Icon for Launchbot set item 19 | 20 |
                  • `; 21 | } 22 | 23 | 24 | // Add plugin(s) to default plugin space 25 | export function addToDefaultPluginDOM(id) { 26 | const li = document.createElement('li'); 27 | li.setAttribute('id', id); 28 | li.setAttribute('class', 'plugins__item'); 29 | 30 | pluginsDefaultDOM.appendChild(li); 31 | } 32 | 33 | 34 | // Detect whether localStorage is supported and available 35 | // 36 | // via https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API 37 | /* eslint-disable operator-linebreak */ 38 | export function storageAvailable(type) { 39 | try { 40 | const storage = window[type]; 41 | const x = '__storage_test__'; 42 | storage.setItem(x, x); 43 | storage.removeItem(x); 44 | return true; 45 | } catch (e) { 46 | return e instanceof DOMException && ( 47 | // everything except Firefox 48 | e.code === 22 || 49 | // Firefox 50 | e.code === 1014 || 51 | // test name field too, because code might not be present 52 | // everything except Firefox 53 | e.name === 'QuotaExceededError' || 54 | // Firefox 55 | e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && 56 | // acknowledge QuotaExceededError only if there's something already stored 57 | storage.length !== 0; // eslint-disable-line no-undef 58 | } 59 | } 60 | /* eslint-enable operator-linebreak */ 61 | -------------------------------------------------------------------------------- /src/js/components/launchbot.js: -------------------------------------------------------------------------------- 1 | export default class Launchbot { 2 | constructor(config) { 3 | this.options = config.options; 4 | this.sets = config.sets; 5 | } 6 | 7 | 8 | // Get a complete set (id starts at 1) 9 | // 10 | // Valid args: 11 | // obj = {id: 1}; 12 | // obj = {name: 'First Set Name'}; 13 | // obj = {id: 1, name: 'First Set Name'}; 14 | getSet(obj) { 15 | const { id, name } = obj; 16 | 17 | if (Number.isInteger(id)) { 18 | const searchedSet = this.sets[id - 1]; 19 | if (searchedSet) return searchedSet; 20 | throw new Error(` 21 | No set with the id "${id}" exists. 22 | Id’s start at 1, you have ${this.sets.length} configured. 23 | `); 24 | } 25 | 26 | if (name) { 27 | const searchedSet = this.sets.find(set => set.name.toLowerCase() === name.toLowerCase()); 28 | if (searchedSet) return searchedSet; 29 | throw new Error(`No set with the name "${name}" exists.`); 30 | } 31 | 32 | throw new Error(` 33 | No set found. Required object format (one property is enough): 34 | {id: 1, name: 'First Set Name'} 35 | `); 36 | } 37 | 38 | 39 | getAllSets() { 40 | return this.sets; 41 | } 42 | 43 | 44 | openSet(i) { 45 | const { items } = this.sets[i]; 46 | items.forEach(item => window.open(item)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/js/components/pluginLoader.js: -------------------------------------------------------------------------------- 1 | import config from './configLoader'; 2 | 3 | 4 | // Get all enabled plugins 5 | const plugins = config.plugins.filter(plugin => plugin.enabled === true); 6 | 7 | // Dynamically import and initialize the enabled plugins 8 | async function loadPlugin(src) { 9 | try { 10 | const plugin = await import('../plugins/' + src.name + '.js'); 11 | plugin.init(); 12 | } catch (err) { 13 | throw err; 14 | } 15 | } 16 | 17 | plugins.forEach(plugin => loadPlugin(plugin)); 18 | -------------------------------------------------------------------------------- /src/js/components/settings.js: -------------------------------------------------------------------------------- 1 | import config from './configLoader'; 2 | 3 | 4 | // DOM 5 | const configOptionsDOM = document.getElementById('js-settings-options'); 6 | const configPluginsDOM = document.getElementById('js-settings-plugins'); 7 | const configSetsDOM = document.getElementById('js-settings-sets'); 8 | const exportDOM = document.getElementById('js-settings-export'); 9 | const footerDOM = document.getElementById('js-footer'); 10 | const settingsDOM = document.getElementById('js-settings'); 11 | const settingsAddSetDOM = document.getElementById('js-settings-add-set'); 12 | const settingsButtonDOM = document.getElementById('js-settings-button'); 13 | const settingsCancelDOM = document.getElementById('js-settings-cancel'); 14 | const settingsRestoreDOM = document.getElementById('js-settings-restore'); 15 | const settingsSaveDOM = document.getElementById('js-settings-save'); 16 | 17 | 18 | function saveConfig(obj) { 19 | localStorage.setItem('config', JSON.stringify(obj)); 20 | window.location.reload(true); 21 | } 22 | 23 | 24 | function getFormData() { 25 | const sets = []; 26 | configSetsDOM.childNodes.forEach((set) => { 27 | sets.push({ 28 | name: set.children[0].value, 29 | items: [...set.children[1].value.split('\n')], 30 | }); 31 | }); 32 | 33 | const options = {}; 34 | configOptionsDOM.childNodes.forEach((opt) => { 35 | const { name, type, checked } = opt.children[1]; 36 | let { value } = opt.children[1]; 37 | if (type === 'checkbox') value = checked; 38 | options[name] = value; 39 | }); 40 | 41 | const plugins = []; 42 | configPluginsDOM.childNodes.forEach((plugin) => { 43 | const pluginOptions = {}; 44 | plugin.childNodes.forEach((opt) => { 45 | const { name, type, checked } = opt.children[1]; 46 | let { value } = opt.children[1]; 47 | if (type === 'checkbox') value = checked; 48 | pluginOptions[name] = value; 49 | }); 50 | plugins.push(pluginOptions); 51 | }); 52 | 53 | return { 54 | sets, 55 | options, 56 | plugins, 57 | }; 58 | } 59 | 60 | 61 | // Render config sets 62 | let renderSets = ''; 63 | config.sets.forEach(({ name, items }) => { 64 | renderSets += `
                  • 65 | 66 | 67 | 68 |
                  • `; 69 | }); 70 | 71 | configSetsDOM.innerHTML = renderSets; 72 | 73 | 74 | // Render config options 75 | let renderOptions = ''; 76 | 77 | // Make config.options obj iterable 78 | Object.entries(config.options).forEach(([key, value]) => { 79 | let attribute = ''; 80 | let inputStyles = 'input input--settings'; 81 | 82 | // Map input type 83 | let inputType = 'text'; // default 84 | if (key === 'darkMode' || key === 'keyboardShortcuts') { 85 | inputType = 'checkbox'; 86 | inputStyles = ''; // Use default style for checkboxes 87 | if (value === true) attribute = 'checked'; 88 | } 89 | 90 | renderOptions += `
                  • 91 | 92 | 93 |
                  • `; 94 | }); 95 | 96 | configOptionsDOM.innerHTML = renderOptions; 97 | 98 | 99 | // Render plugins and their options 100 | config.plugins.forEach((plugin) => { 101 | const pluginDOM = document.createElement('ul'); 102 | pluginDOM.setAttribute('class', 'settings-form-list settings-form-list--plugin'); 103 | 104 | Object.entries(plugin).forEach(([key, value]) => { 105 | let attribute = ''; 106 | let inputStyles = 'input input--settings'; 107 | 108 | // Map input type 109 | let inputType = 'text'; // default 110 | if (key === 'enabled') { 111 | inputType = 'checkbox'; 112 | inputStyles = ''; // Use default style for checkboxes 113 | if (value === true) attribute = 'checked'; 114 | } 115 | 116 | // Disable plugin name changes through the UI 117 | if (key === 'name') attribute = 'disabled'; 118 | 119 | pluginDOM.innerHTML += `
                  • 120 | 121 | 122 |
                  • `; 123 | }); 124 | 125 | configPluginsDOM.appendChild(pluginDOM); 126 | }); 127 | 128 | 129 | // Export config 130 | exportDOM.innerHTML = `export default ${JSON.stringify(config, null, 2)};`; 131 | 132 | 133 | // Handlers 134 | // 135 | // Settings handlers are defined here, as some are generated dynamically by 136 | // this component. 137 | 138 | // Open settings 139 | settingsButtonDOM.addEventListener('click', () => { 140 | footerDOM.style.position = 'relative'; 141 | settingsDOM.classList.add('is-active'); 142 | }); 143 | 144 | // Cancel and close settings 145 | settingsCancelDOM.addEventListener('click', (event) => { 146 | event.preventDefault(); 147 | settingsDOM.classList.remove('is-active'); 148 | footerDOM.style.position = 'fixed'; 149 | }); 150 | 151 | // Save 152 | settingsSaveDOM.addEventListener('click', (event) => { 153 | event.preventDefault(); 154 | saveConfig(getFormData()); 155 | }); 156 | 157 | // Restore embedded config 158 | settingsRestoreDOM.addEventListener('click', (event) => { 159 | event.preventDefault(); 160 | localStorage.removeItem('config'); 161 | window.location.reload(true); 162 | }); 163 | 164 | 165 | // Remove set from settings form 166 | // 167 | // removeSetHandler() is defined here, so that the event can reference it 168 | // correctly by obj reference. 169 | const removeSetHandler = ({ target }) => { 170 | configSetsDOM.removeChild(target.parentNode); 171 | }; 172 | 173 | function initRemoveSetHandlers() { 174 | configSetsDOM.childNodes.forEach((set) => { 175 | const el = set.children[2]; 176 | el.addEventListener('click', removeSetHandler); 177 | }); 178 | } 179 | 180 | initRemoveSetHandlers(); 181 | 182 | 183 | // Add set to settings form 184 | settingsAddSetDOM.addEventListener('click', () => { 185 | const el = document.createElement('li'); 186 | el.setAttribute('class', 'settings-form-list__item'); 187 | el.innerHTML = ` 188 | 189 | 190 | 191 | `; 192 | 193 | configSetsDOM.appendChild(el); 194 | initRemoveSetHandlers(); 195 | }); 196 | -------------------------------------------------------------------------------- /src/js/components/welcomeModal.js: -------------------------------------------------------------------------------- 1 | import { storageAvailable } from './helpers'; 2 | 3 | 4 | // DOM 5 | const welcomeDOM = document.getElementById('js-welcome-modal'); 6 | const welcomeButtonDOM = document.getElementById('js-welcome-dismiss'); 7 | 8 | 9 | export default function () { 10 | // Only display the welcome modal if localStorage is available, 11 | // so that we can easily keep track of it. 12 | if ( 13 | storageAvailable('localStorage') 14 | && !localStorage.getItem('launchbot__welcome') 15 | ) { 16 | welcomeDOM.style.display = 'block'; 17 | } 18 | 19 | 20 | // Handler: Dismiss welcome modal 21 | // 22 | // Set flag in localStorage, so that it doesn’t get displayed again. 23 | welcomeButtonDOM.addEventListener('click', () => { 24 | welcomeDOM.style.display = 'none'; 25 | localStorage.setItem('launchbot__welcome', false); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/js/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "sets": [ 3 | { 4 | "name": "Daily", 5 | "items": [ 6 | "https://mymorningroutine.com", 7 | "https://www.nytimes.com", 8 | "https://www.newyorker.com", 9 | "https://news.ycombinator.com", 10 | "https://reddit.com", 11 | "https://macstories.net", 12 | "https://brettterpstra.com" 13 | ] 14 | }, 15 | { 16 | "name": "Travel", 17 | "items": [ 18 | "https://michaelxander.com/misc/city-cams/", 19 | "https://www.google.com/flights/", 20 | "https://en.wikivoyage.org", 21 | "https://wikitravel.org" 22 | ] 23 | }, 24 | { 25 | "name": "Favorites", 26 | "items": [ 27 | "https://michaelxander.com", 28 | "https://mymorningroutine.com", 29 | "https://mail.google.com", 30 | "https://drive.google.com", 31 | "https://app.asana.com", 32 | "https://twitter.com", 33 | "https://reddit.com", 34 | "https://www.nytimes.com", 35 | "https://news.ycombinator.com", 36 | "https://devdocs.io" 37 | ] 38 | } 39 | ], 40 | "options": { 41 | "darkMode": true, 42 | "searchEngine": "https://www.google.com/search", 43 | "faviconSize": 16, 44 | "faviconService": "https://www.google.com/s2/favicons?domain=", 45 | "linkTarget": "_blank", 46 | "keyboardShortcuts": true 47 | }, 48 | "plugins": [ 49 | { 50 | "name": "weather", 51 | "enabled": false, 52 | "corsProxy": "", 53 | "apiKey": "", 54 | "locationAlias": "", 55 | "latitude": "", 56 | "longitude": "", 57 | "lang": "en", 58 | "units": "si" 59 | }, 60 | { 61 | "name": "age", 62 | "enabled": false, 63 | "birthday": "1970-06-21", 64 | "goal": 100 65 | } 66 | ] 67 | }; -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import config from './components/configLoader'; 2 | import Launchbot from './components/launchbot'; 3 | import handlers from './components/handlers'; 4 | import welcomeModal from './components/welcomeModal'; 5 | import { formatItemBar } from './components/helpers'; 6 | import './components/pluginLoader'; 7 | import './components/settings'; 8 | 9 | 10 | function render(launchbot) { 11 | // Initialize theme 12 | if (launchbot.options.darkMode === false) { 13 | document.documentElement.classList.add('theme-light'); 14 | } 15 | 16 | // Render sets 17 | let outputSets = ''; 18 | launchbot.sets.forEach(({ name, items }, i) => { 19 | let itemBar = ''; 20 | items.forEach((item) => { 21 | itemBar += formatItemBar(item, config); 22 | }); 23 | outputSets += `
                  • 24 |
                    25 |

                    ${name}

                    26 | ${i + 1} 27 |
                      ${itemBar}
                    28 |
                    29 |
                  • `; 30 | }); 31 | 32 | document.getElementById('js-sets').innerHTML = outputSets; 33 | 34 | // Set search engine 35 | if (launchbot.options.searchEngine) { 36 | document.getElementById('js-search-form').action = launchbot.options.searchEngine; 37 | } 38 | 39 | // Initialize welcome modal 40 | welcomeModal(); 41 | 42 | // Initialize handlers and keyboard shortcuts after DOM is generated 43 | handlers(launchbot); 44 | } 45 | 46 | 47 | render(new Launchbot(config)); 48 | -------------------------------------------------------------------------------- /src/js/plugins/age.js: -------------------------------------------------------------------------------- 1 | import config from '../components/configLoader'; 2 | import { addToDefaultPluginDOM } from '../components/helpers'; 3 | 4 | 5 | const pluginConfig = config.plugins.find(obj => obj.name === 'age'); 6 | 7 | 8 | // DOM setup 9 | const pluginId = 'js-plugin-age'; 10 | addToDefaultPluginDOM(pluginId); 11 | const ageDOM = document.getElementById(pluginId); 12 | 13 | 14 | const renderAge = () => { 15 | const { birthday, goal } = pluginConfig; 16 | 17 | // Inspired by: 18 | // Alex MacCaw https://github.com/maccman/motivation 19 | const now = new Date(); 20 | const age = (now - new Date(birthday)) / 3.1556952e+10; // divided by 1 year in ms 21 | let remainder = 100 - (age / goal * 100); 22 | 23 | let goalPrefix = 'left until'; 24 | if (remainder < 0) { 25 | goalPrefix = 'over goal of'; 26 | remainder = -remainder; 27 | } 28 | 29 | ageDOM.innerHTML = `Age: ${age.toFixed(5)}, ${remainder.toFixed(2)}% ${goalPrefix} ${goal}`; 30 | }; 31 | 32 | 33 | // Initialize plugin 34 | export const init = () => renderAge(); // eslint-disable-line import/prefer-default-export 35 | -------------------------------------------------------------------------------- /src/js/plugins/weather.js: -------------------------------------------------------------------------------- 1 | import config from '../components/configLoader'; 2 | import { storageAvailable, addToDefaultPluginDOM } from '../components/helpers'; 3 | 4 | 5 | const pluginConfig = config.plugins.find(obj => obj.name === 'weather'); 6 | 7 | 8 | // DOM setup 9 | const pluginId = 'js-plugin-weather'; 10 | addToDefaultPluginDOM(pluginId); 11 | const weatherDOM = document.getElementById(pluginId); 12 | 13 | 14 | // API query 15 | const query = ` 16 | ${pluginConfig.corsProxy}/ 17 | https://api.darksky.net/forecast/ 18 | ${pluginConfig.apiKey}/ 19 | ${pluginConfig.latitude},${pluginConfig.longitude} 20 | ?lang=${pluginConfig.lang} 21 | &units=${pluginConfig.units} 22 | &exclude=minutely,hourly,flags 23 | `.replace(/\s+/g, ''); 24 | 25 | 26 | const renderWeather = (data) => { 27 | // Map units with defaults 28 | const mapUnits = { 29 | degrees: 'F', 30 | speed: 'mp/h', 31 | }; 32 | 33 | // Update units if necessary 34 | if (pluginConfig.units === 'si' || pluginConfig.units === 'ca') { 35 | mapUnits.degrees = 'C'; 36 | mapUnits.speed = 'km/h'; 37 | } else if (pluginConfig.units === 'uk2') { 38 | mapUnits.degrees = 'C'; 39 | } 40 | 41 | const weather = { 42 | summary: data.currently.summary, 43 | temp: Math.round(data.currently.temperature), 44 | tempHi: Math.round(data.daily.data[0].temperatureHigh), 45 | wind: Math.round(data.currently.windSpeed), 46 | cloud: Math.round(data.currently.cloudCover * 100), 47 | precip: Math.round(data.currently.precipProbability * 100), 48 | }; 49 | 50 | weatherDOM.innerHTML = `Weather in ${pluginConfig.locationAlias}: ${weather.summary}, 51 | ${weather.temp}°${mapUnits.degrees} (high ${weather.tempHi}°), 52 | ${weather.wind}${mapUnits.speed} wind 53 | ${weather.precip}% precip., 54 | ${weather.cloud}% cloud cover.`; 55 | }; 56 | 57 | 58 | async function callWeatherAPI(src) { 59 | try { 60 | const response = await fetch(src); 61 | const data = await response.json(); // Read response body and parse as JSON 62 | return data; 63 | } catch (err) { 64 | weatherDOM.innerHTML = 'Weather plugin: Error, see console for details.'; 65 | throw err; 66 | } 67 | } 68 | 69 | 70 | function getWeatherData() { 71 | if ( 72 | storageAvailable('localStorage') 73 | && localStorage.getItem('plugin_weather__cache') 74 | ) { 75 | const data = JSON.parse(localStorage.getItem('plugin_weather__cache')); 76 | const now = new Date(); 77 | 78 | // Equalize UNIX timestamp with JS (ms based) 79 | const cacheDate = new Date(data.currently.time * 1000); 80 | 81 | const diffInMinutes = (now.getTime() - cacheDate.getTime()) / 1000; 82 | 83 | // Use cached weather data for 30 minutes 84 | if (diffInMinutes < 30 * 60) { 85 | renderWeather(data); 86 | return; 87 | } 88 | } 89 | 90 | // Request weather from API and cache in localStorage, if available. 91 | weatherDOM.innerHTML = 'Loading weather data…'; 92 | callWeatherAPI(query).then((data) => { 93 | renderWeather(data); 94 | if (storageAvailable('localStorage')) { 95 | localStorage.setItem('plugin_weather__cache', JSON.stringify(data)); 96 | } 97 | }); 98 | } 99 | 100 | 101 | // Initialize plugin 102 | export const init = () => getWeatherData(); // eslint-disable-line import/prefer-default-export 103 | -------------------------------------------------------------------------------- /src/styles/base/base.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | 3 | 4 | html, 5 | body { 6 | -moz-osx-font-smoothing: grayscale; 7 | -webkit-font-smoothing: antialiased; 8 | } 9 | 10 | 11 | body { 12 | font-family: $base-font; 13 | font-size: $base-font-size; 14 | line-height: $base-line-height; 15 | color: var(--color-text); 16 | 17 | background-color: var(--color-background); 18 | } 19 | 20 | 21 | /* Links */ 22 | a { text-decoration: none; } 23 | 24 | 25 | /* Lists */ 26 | ul, 27 | ol { 28 | margin-top: 0; 29 | margin-bottom: 0; 30 | padding-left: 0; 31 | } 32 | 33 | 34 | /* Horizontal line */ 35 | hr { 36 | width: 100%; 37 | height: .25em; 38 | margin: 2.5em 0 2em; 39 | 40 | border: 0; 41 | background-color: var(--color-set-border); 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/base/reset.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Resets 3 | * 4 | * CSS resets relevant to Launchbot. 5 | */ 6 | 7 | 8 | html { text-size-adjust: none; } 9 | 10 | 11 | body { margin: 0; } 12 | 13 | 14 | footer, 15 | header, 16 | main, 17 | nav, 18 | section { 19 | display: block; 20 | } 21 | 22 | 23 | html, 24 | body, 25 | div, 26 | article, 27 | section, 28 | main, 29 | footer, 30 | header, 31 | form, 32 | p, 33 | ul, 34 | ol, 35 | li, 36 | textarea, 37 | input[type="text"], 38 | input[type="search"] { 39 | box-sizing: border-box; 40 | } 41 | 42 | 43 | a { 44 | background-color: transparent; 45 | text-decoration-skip: objects; 46 | 47 | &:active, 48 | &:hover { 49 | outline-width: 0; 50 | } 51 | } 52 | 53 | 54 | img { 55 | vertical-align: middle; 56 | border-style: none; 57 | } 58 | 59 | 60 | button, 61 | input, 62 | textarea { 63 | margin: 0; 64 | font: inherit; 65 | } 66 | -------------------------------------------------------------------------------- /src/styles/base/typography.css: -------------------------------------------------------------------------------- 1 | /* Base typography styles */ 2 | 3 | 4 | /** 5 | * Headings 6 | * 7 | * Heading tags (h1, h2, etc.) are only used to set the base. 8 | * They are otherwise not directly styled. Instead, classes are 9 | * used to allow styles to be completely separated from document 10 | * semantics. 11 | * 12 | * Components can have their own heading styles! 13 | */ 14 | h1, 15 | h2, 16 | h3, 17 | h4 { 18 | margin-top: 0; 19 | margin-bottom: 0; 20 | 21 | font-weight: $font-weight-normal; 22 | line-height: $heading-line-height; 23 | } 24 | 25 | 26 | /* General headings */ 27 | .h2, 28 | .h3, 29 | .h4 { 30 | margin-bottom: 1rem; 31 | 32 | font-weight: $font-weight-normal; 33 | color: var(--color-heading); 34 | } 35 | 36 | 37 | .h2 { 38 | font-size: 1.25em; 39 | 40 | @media only screen and (min-width: $screen-m) { 41 | font-size: 1.5em; 42 | } 43 | } 44 | 45 | 46 | .h3 { 47 | font-size: 1em; 48 | 49 | @media only screen and (min-width: $screen-m) { 50 | font-size: 1.25em; 51 | } 52 | 53 | @media only screen and (min-width: $screen-l) { 54 | font-size: 1.375em; 55 | } 56 | } 57 | 58 | 59 | .h4 { font-size: 1em; } 60 | 61 | 62 | /* Default paragraph style */ 63 | p { 64 | margin-top: 0; 65 | margin-bottom: 1.2em; 66 | } 67 | -------------------------------------------------------------------------------- /src/styles/components/buttons.css: -------------------------------------------------------------------------------- 1 | /* Button styles */ 2 | 3 | 4 | /* Default button styles */ 5 | .button { 6 | display: inline-block; 7 | vertical-align: middle; 8 | position: relative; 9 | padding: .75rem 1.5rem; 10 | box-sizing: border-box; 11 | 12 | font-size: $font-size-small; 13 | font-weight: normal; 14 | text-align: center; 15 | text-decoration: none; 16 | color: var(--color-button-text); 17 | 18 | border: 2px solid; 19 | border-radius: $border-radius; 20 | border-color: var(--color-button-border); 21 | background-color: var(--color-button-background); 22 | 23 | cursor: pointer; 24 | user-select: none; 25 | appearance: none; /* Corrects inability to style clickable `input` types in iOS. */ 26 | 27 | &:focus { outline: none; } 28 | 29 | &:hover, 30 | &.is-active { 31 | color: var(--color-button-text--hover); 32 | border-color: var(--color-button-border--hover); 33 | background-color: var(--color-button-background--hover); 34 | } 35 | } 36 | 37 | 38 | /* Modifier for small buttons */ 39 | .button--small { padding: .25rem .5rem; } 40 | 41 | 42 | /* Modifier for buttons in the navigation */ 43 | .button--nav { 44 | padding-right: 1rem; 45 | padding-left: 1rem; 46 | 47 | font-size: $base-font-size; 48 | color: var(--color-button-text--nav); 49 | border-color: var(--color-button-border--nav); 50 | background-color: var(--color-button-background--nav); 51 | 52 | @media only screen and (min-width: $screen-l) { 53 | padding-right: 1.5rem; 54 | padding-left: 1.5rem; 55 | font-size: $font-size-small; 56 | } 57 | } 58 | 59 | 60 | /** 61 | * Button set 62 | * 63 | * A list containing multiple buttons. 64 | */ 65 | .button-set { list-style: none; } 66 | 67 | .button-set__item { 68 | display: inline-block; 69 | padding-right: .75rem; 70 | padding-bottom: .75rem; 71 | } 72 | -------------------------------------------------------------------------------- /src/styles/components/grid.css: -------------------------------------------------------------------------------- 1 | /* Grid styles */ 2 | 3 | 4 | /* Base grid style */ 5 | .grid { list-style: none; } 6 | 7 | 8 | /** 9 | * Default grid item style 10 | * 11 | * Full-wide until $screen-xl, then 2 columns, then 3. 12 | */ 13 | .grid__item { 14 | display: inline-block; 15 | vertical-align: top; 16 | width: 100%; 17 | padding: 0 1em 2em; 18 | 19 | @media only screen and (min-width: $screen-l) { 20 | padding: 0 2em 2.5em; 21 | } 22 | 23 | @media only screen and (min-width: $screen-xl) { 24 | width: 50%; 25 | &:nth-child(odd) { padding-right: 1.5em; } 26 | &:nth-child(even) { padding-left: 1.5em; } 27 | } 28 | 29 | @media only screen and (min-width: $screen-xxl) { 30 | width: 33.33%; 31 | &:nth-child(n) { padding: 0 2em 4em; } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/styles/components/inputs.css: -------------------------------------------------------------------------------- 1 | /* Input styles */ 2 | 3 | 4 | /* Default input style */ 5 | .input { 6 | display: block; 7 | width: 100%; 8 | padding: .75rem; 9 | 10 | line-height: 1.5; 11 | text-align: left; 12 | color: var(--color-input-text); 13 | 14 | border: 2px solid var(--color-input-border); 15 | border-radius: 0; /* Resets round search inputs on iOS */ 16 | background-color: var(--color-input-background); 17 | 18 | appearance: none; /* Removes platform specific styling */ 19 | 20 | &::placeholder { 21 | color: var(--color-input-text-placeholder); 22 | } 23 | 24 | &:focus { 25 | border-color: var(--color-input-border--focus); 26 | outline: 0; 27 | } 28 | 29 | @media only screen and (min-width: $screen-l) { 30 | font-size: $font-size-small; 31 | } 32 | } 33 | 34 | 35 | /* Modifier for search */ 36 | .input--search { 37 | width: 10rem; 38 | 39 | letter-spacing: .025rem; 40 | text-overflow: ellipsis; 41 | 42 | border: 2px solid transparent; 43 | border-radius: $border-radius; 44 | background-color: var(--color-search-background); 45 | 46 | &::placeholder { 47 | color: var(--color-search-text-placeholder); 48 | } 49 | 50 | &:focus { 51 | width: 100%; 52 | color: var(--color-button-text--hover); 53 | border-color: var(--color-button-border--hover); 54 | 55 | &::placeholder { 56 | color: var(--color-button-text--hover); 57 | } 58 | 59 | @include transition(all); 60 | } 61 | 62 | @media only screen and (min-width: $screen-m) { 63 | width: 20rem; 64 | &:focus { width: 25rem; } 65 | } 66 | } 67 | 68 | 69 | /* Modifier for inputs on the settings page */ 70 | .input--settings { 71 | max-width: 25rem; 72 | 73 | &:disabled { border-color: transparent; } 74 | } 75 | 76 | 77 | /* Modifier for Launchbot Set input fields */ 78 | .input--set { 79 | max-width: 100%; 80 | border-bottom: 0; 81 | } 82 | 83 | 84 | /* Default input label style */ 85 | .input-label { 86 | display: block; 87 | max-width: 100%; 88 | padding-bottom: .25rem; 89 | 90 | font-size: $font-size-small; 91 | } 92 | 93 | 94 | /* Default textarea style */ 95 | .textarea { 96 | display: block; 97 | width: 100%; 98 | height: 10rem; 99 | padding: .75rem; 100 | 101 | resize: vertical; 102 | 103 | font-size: $font-size-small; 104 | line-height: 1.5; 105 | text-align: left; 106 | color: var(--color-input-text); 107 | 108 | border: 2px solid var(--color-input-border); 109 | background-color: var(--color-input-background); 110 | 111 | appearance: none; /* Removes platform specific styling */ 112 | 113 | &::placeholder { 114 | color: var(--color-input-text-placeholder); 115 | } 116 | 117 | &:focus { 118 | border-color: var(--color-input-border--focus); 119 | outline: 0; 120 | } 121 | } 122 | 123 | /* Modifier for larger textareas */ 124 | .textarea--large { height: 20rem; } 125 | -------------------------------------------------------------------------------- /src/styles/components/lists.css: -------------------------------------------------------------------------------- 1 | /* List styles */ 2 | 3 | 4 | /* Default list style for content text */ 5 | .list { 6 | margin-bottom: 1.5em; 7 | padding-left: 2.5em; 8 | 9 | li > p, 10 | li { 11 | margin-bottom: .75em; 12 | &:last-child { margin-bottom: 0; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/components/modals.css: -------------------------------------------------------------------------------- 1 | /* Modal styles */ 2 | 3 | 4 | /* Modal-container */ 5 | .modal { 6 | display: none; /* Visibility is controlled by JavaScript */ 7 | overflow-y: auto; 8 | position: fixed; 9 | top: 0; 10 | left: 0; 11 | z-index: 100; 12 | 13 | width: 100%; 14 | height: 100%; 15 | 16 | background-color: rgba(0,0,0,.64); 17 | } 18 | 19 | 20 | /* Modal content element */ 21 | .modal__content { 22 | padding: 1rem; 23 | 24 | font-size: $font-size-small; 25 | color: var(--color-text); 26 | 27 | border: 2px solid var(--color-set-border); 28 | border-radius: $border-radius; 29 | background-color: var(--color-set-background); 30 | 31 | @include centered; 32 | @include container($container-text, +, 0); 33 | 34 | @media only screen and (min-width: $screen-m) { 35 | padding: 2rem; 36 | @include container($container-text, +, 4rem); 37 | } 38 | 39 | @media only screen and (min-width: $screen-l) { 40 | margin-top: 6rem; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/components/plugins.css: -------------------------------------------------------------------------------- 1 | /* Styles for default plugins */ 2 | 3 | 4 | /* Default plugin style */ 5 | .plugins { 6 | max-width: $container-max; 7 | 8 | font-size: $font-size-small; 9 | list-style: none; 10 | color: var(--color-plugin-text); 11 | 12 | @include container($container-max, -, 4rem); 13 | @include centered; 14 | } 15 | 16 | 17 | /* Plugin element */ 18 | .plugins__item { 19 | padding: .5em 1em; 20 | 21 | &:nth-child(odd) { 22 | background-color: var(--color-plugin-row-odd); 23 | } 24 | &:nth-child(even) { 25 | background-color: var(--color-plugin-row-even); 26 | } 27 | 28 | @media only screen and (min-width: $screen-l) { 29 | padding-right: 2em; 30 | padding-left: 2em; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/styles/components/sets.css: -------------------------------------------------------------------------------- 1 | /* Launchbot set styles */ 2 | 3 | 4 | /* Default set style */ 5 | .set { 6 | position: relative; 7 | padding: 1em .625em .75em; /* px and pb: to balance with list of set-item’s */ 8 | 9 | text-align: left; 10 | 11 | border: 2px solid var(--color-set-border); 12 | border-radius: $border-radius; 13 | background-color: var(--color-set-background); 14 | 15 | cursor: pointer; 16 | 17 | &:hover { 18 | border-color: var(--color-set-border--hover); 19 | } 20 | 21 | @media only screen and (min-width: $screen-l) { 22 | padding: 1.5em .75em 1em; 23 | } 24 | } 25 | 26 | 27 | /* Set name element */ 28 | .set__name { 29 | display: block; 30 | 31 | /* r: spacing for .set__id; l: to balance with list of set-item’s */ 32 | padding: 0 1.375rem 1rem .375rem; 33 | 34 | font-size: 1em; 35 | font-weight: $font-weight-bold; 36 | letter-spacing: .025em; 37 | line-height: 1.3; 38 | color: var(--color-set-name); 39 | 40 | @media only screen and (min-width: $screen-l) { 41 | padding: 0 2.5rem 1.5rem .75rem; 42 | font-size: 1.125em; 43 | } 44 | } 45 | 46 | 47 | /* Set id element */ 48 | .set__id { 49 | position: absolute; 50 | top: 1em; 51 | right: 1em; 52 | padding-top: 2px; /* to align with set__name */ 53 | 54 | font-size: $font-size-small; 55 | line-height: 1.3; /* to align with set__name */ 56 | color: var(--color-set-id); 57 | 58 | @media only screen and (min-width: $screen-l) { 59 | top: 1.5em; 60 | right: 1.5em; 61 | padding-top: .375rem; 62 | } 63 | } 64 | 65 | 66 | /** 67 | * Set item 68 | * 69 | * Item within a set. These are your configured URL’s displayed as images 70 | * with a border. 71 | */ 72 | .set-item { 73 | display: inline-block; 74 | padding: 0 .375em .75em; 75 | 76 | @media only screen and (min-width: $screen-l) { 77 | padding: 0 .75em 1.5em; 78 | } 79 | } 80 | 81 | .set-item__img { 82 | border: .75em solid var(--color-set-item); 83 | border-radius: 50%; 84 | background-color: var(--color-set-item); 85 | 86 | &:hover { 87 | border-color: var(--color-set-item--hover); 88 | background-color: var(--color-set-item--hover); 89 | } 90 | 91 | @media only screen and (min-width: $screen-l) { 92 | border-width: 1em; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/styles/layout/content.css: -------------------------------------------------------------------------------- 1 | /* Content styles */ 2 | 3 | 4 | /** 5 | * Content wrapper 6 | * 7 | * Margin to footer. 8 | */ 9 | .content-wrapper { 10 | margin-bottom: $spacing-content-s; 11 | 12 | @media only screen and (min-width: $screen-m) { 13 | margin-bottom: $spacing-content-m; 14 | } 15 | 16 | @media only screen and (min-width: $screen-l) { 17 | margin-bottom: $spacing-content-l; 18 | } 19 | 20 | @media only screen and (min-width: $screen-xl) { 21 | margin-bottom: $spacing-content-xl; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/styles/layout/footer.css: -------------------------------------------------------------------------------- 1 | /* Footer styles */ 2 | 3 | 4 | /** 5 | * Footer container 6 | * 7 | * On larger screens the footer becomes sticky. 8 | */ 9 | .footer { 10 | width: 100%; 11 | 12 | @media only screen and (min-width: $screen-l) { 13 | position: fixed; 14 | bottom: 0; 15 | left: 0; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/layout/header.css: -------------------------------------------------------------------------------- 1 | /* Header styles */ 2 | 3 | 4 | /** 5 | * Header container 6 | * 7 | * Contains nav (search and nav-list). Margin to content. 8 | */ 9 | .header { 10 | width: 100%; 11 | margin-bottom: $spacing-header-s; 12 | padding: 1em 1em 0; 13 | 14 | @media only screen and (min-width: $screen-l) { 15 | margin-bottom: $spacing-header-l; 16 | padding: 2em 2em 0; 17 | } 18 | 19 | @media only screen and (min-width: $screen-xl) { 20 | margin-bottom: $spacing-header-xl; 21 | } 22 | 23 | @media only screen and (min-width: $screen-xxl) { 24 | margin-bottom: $spacing-header-xxl; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/styles/layout/nav.css: -------------------------------------------------------------------------------- 1 | /* Navigation styles */ 2 | 3 | 4 | /* Navigation container */ 5 | .nav { @include clearfix; } 6 | 7 | 8 | /* Nav search element */ 9 | .nav__search { 10 | float: left; 11 | 12 | &:focus-within { width: 100%; } /* Full-wide on focus */ 13 | 14 | @media only screen and (min-width: $screen-m) { 15 | &:focus-within { width: auto; } /* Reset to input style */ 16 | } 17 | } 18 | 19 | 20 | /* Navigation list container */ 21 | .nav-list { 22 | float: right; 23 | list-style: none; 24 | } 25 | 26 | .nav-list__item { 27 | display: inline-block; 28 | padding: 0 .5em; 29 | 30 | letter-spacing: .025em; 31 | 32 | &:last-child { padding-right: 0; } 33 | 34 | @media only screen and (min-width: $screen-m) { 35 | padding-right: 1em; 36 | padding-left: 1em; 37 | } 38 | 39 | @media only screen and (min-width: $screen-xl) { 40 | padding-right: 1.25em; 41 | padding-left: 1.25em; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/layout/site.css: -------------------------------------------------------------------------------- 1 | /* Site styles */ 2 | 3 | 4 | /** 5 | * Site wrapper 6 | * 7 | * Controls site width. 8 | */ 9 | .site-wrapper { 10 | @include container($container-max, +, 0); 11 | @include centered; 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/main.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | 4 | /* Configuration, theming, and helpers, order relevant! */ 5 | @import "utilities/variables"; 6 | @import "utilities/theming"; 7 | @import "utilities/mixins"; 8 | 9 | /* … order not relevant, arrange alphabetically */ 10 | @import "utilities/links"; 11 | @import "utilities/typography"; 12 | 13 | 14 | /* Base */ 15 | @import "base/reset"; 16 | @import "base/base"; 17 | @import "base/typography"; 18 | 19 | 20 | /* Layout */ 21 | @import "layout/site"; 22 | @import "layout/header"; 23 | @import "layout/nav"; 24 | @import "layout/content"; 25 | @import "layout/footer"; 26 | 27 | 28 | /* Page-specific styles */ 29 | @import "pages/settings"; 30 | 31 | 32 | /* Components */ 33 | @import "components/buttons"; 34 | @import "components/grid"; 35 | @import "components/inputs"; 36 | @import "components/lists"; 37 | @import "components/modals"; 38 | @import "components/plugins"; 39 | @import "components/sets"; 40 | -------------------------------------------------------------------------------- /src/styles/pages/settings.css: -------------------------------------------------------------------------------- 1 | /* Settings styles */ 2 | 3 | 4 | /* Settings page */ 5 | .settings { 6 | display: none; 7 | width: 100%; 8 | padding: 1em; 9 | 10 | background-color: var(--color-settings-background); 11 | 12 | &.is-active { display: block; } 13 | 14 | @media only screen and (min-width: $screen-l) { 15 | padding: 2em; 16 | } 17 | } 18 | 19 | 20 | /* Settings spacing */ 21 | .settings__intro, 22 | .settings__export, 23 | .settings-form, 24 | .settings-form__group { 25 | margin-bottom: 2em; 26 | } 27 | 28 | .settings-form-list { 29 | margin-bottom: 1em; 30 | list-style: none; 31 | } 32 | 33 | /* Modifier for plugins */ 34 | .settings-form-list--plugin { margin-bottom: 2em; } 35 | 36 | 37 | /* Key/value pairs */ 38 | .settings-form-list__item { 39 | margin-bottom: 1em; 40 | &:last-child { margin-bottom: 0; } 41 | } 42 | 43 | /* Modifier for plugin items */ 44 | .settings-form-list__item--plugin { 45 | &:first-child { 46 | font-weight: $font-weight-bold; 47 | color: var(--color-button-text--hover); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/utilities/links.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Link styles 3 | * 4 | * Can be mixed in by components for design consistency. 5 | * Not prefixed with u-. 6 | */ 7 | 8 | 9 | /* Default link style */ 10 | @mixin link { 11 | color: inherit; 12 | border-bottom: 1px solid var(--color-button-text); 13 | 14 | &:hover { 15 | color: var(--color-button-text--hover); 16 | border-color: var(--color-button-border--hover); 17 | } 18 | } 19 | 20 | .link { @include link; } 21 | -------------------------------------------------------------------------------- /src/styles/utilities/mixins.css: -------------------------------------------------------------------------------- 1 | /* Mixins */ 2 | 3 | 4 | /** 5 | * Clearfix 6 | * 7 | * Clears floats. 8 | */ 9 | @mixin clearfix { 10 | &::before { 11 | content: ""; 12 | display: table; 13 | } 14 | 15 | &::after { 16 | content: ""; 17 | display: table; 18 | clear: both; 19 | } 20 | } 21 | 22 | 23 | /* Horizontal center content */ 24 | @mixin centered { 25 | margin-right: auto; 26 | margin-left: auto; 27 | } 28 | 29 | 30 | /** 31 | * Containers 32 | * 33 | * Stick to the container sizes defined in variables.css. 34 | */ 35 | @mixin container($container-size, $operator, $padding) { 36 | max-width: calc($container-size $operator $padding); 37 | } 38 | 39 | 40 | /** 41 | * Animations 42 | * 43 | * Uses the defined animation speed and style from variables.css. 44 | */ 45 | @mixin transition($property) { 46 | transition: $property $transition-duration $transition-timing; 47 | } 48 | -------------------------------------------------------------------------------- /src/styles/utilities/theming.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Themes 3 | * 4 | * Colors are defined in variables.css. Below is setting up the CSS 5 | * variables. 6 | */ 7 | 8 | 9 | /** 10 | * Default theme (dark) 11 | * 12 | * CSS variables. 13 | */ 14 | @mixin theme-default { 15 | --color-text: $color-text; 16 | --color-heading: $color-heading; 17 | --color-background: $color-background; 18 | 19 | --color-set-name: $color-set-name; 20 | --color-set-id: $color-set-id; 21 | --color-set-background: $color-set-background; 22 | --color-set-border: $color-set-border; 23 | --color-set-border--hover: $color-set-border--hover; 24 | --color-set-item: $color-set-item; 25 | --color-set-item--hover: $color-set-item--hover; 26 | 27 | --color-button-text: $color-button-text; 28 | --color-button-text--hover: $color-button-text--hover; 29 | --color-button-background: $color-button-background; 30 | --color-button-background--hover: $color-button-background--hover; 31 | --color-button-border: $color-button-border; 32 | --color-button-border--hover: $color-button-border--hover; 33 | 34 | --color-button-text--nav: $color-button-text--nav; 35 | --color-button-background--nav: $color-button-background--nav; 36 | --color-button-border--nav: $color-button-border--nav; 37 | 38 | --color-search-text-placeholder: $color-search-text-placeholder; 39 | --color-search-background: $color-search-background; 40 | 41 | --color-settings-background: $color-settings-background; 42 | --color-input-text: $color-input-text; 43 | --color-input-text-placeholder: $color-input-text-placeholder; 44 | --color-input-background: $color-input-background; 45 | --color-input-border: $color-input-border; 46 | --color-input-border--focus: $color-input-border--focus; 47 | 48 | --color-plugin-text: $color-plugin-text; 49 | --color-plugin-row-odd: $color-plugin-row-odd; 50 | --color-plugin-row-even: $color-plugin-row-even; 51 | } 52 | 53 | :root { @include theme-default; } 54 | 55 | 56 | /** 57 | * Light theme 58 | * 59 | * CSS variables mixin, so that it can be applied through JS. 60 | * 61 | * @TODO: Implement generator function (theme postfix). 62 | */ 63 | @mixin theme-light { 64 | --color-text: $color-lt-text; 65 | --color-heading: $color-lt-heading; 66 | --color-background: $color-lt-background; 67 | 68 | --color-set-name: $color-lt-set-name; 69 | --color-set-id: $color-lt-set-id; 70 | --color-set-background: $color-lt-set-background; 71 | --color-set-border: $color-lt-set-border; 72 | --color-set-border--hover: $color-lt-set-border--hover; 73 | --color-set-item: $color-lt-set-item; 74 | --color-set-item--hover: $color-lt-set-item--hover; 75 | 76 | --color-button-text: $color-lt-button-text; 77 | --color-button-text--hover: $color-lt-button-text--hover; 78 | --color-button-background: $color-lt-button-background; 79 | --color-button-background--hover: $color-lt-button-background--hover; 80 | --color-button-border: $color-lt-button-border; 81 | --color-button-border--hover: $color-lt-button-border--hover; 82 | 83 | --color-button-text--nav: $color-lt-button-text--nav; 84 | --color-button-background--nav: $color-lt-button-background--nav; 85 | --color-button-border--nav: $color-lt-button-border--nav; 86 | 87 | --color-search-text-placeholder: $color-lt-search-text-placeholder; 88 | --color-search-background: $color-lt-search-background; 89 | 90 | --color-settings-background: $color-lt-settings-background; 91 | --color-input-text: $color-lt-input-text; 92 | --color-input-text-placeholder: $color-lt-input-text-placeholder; 93 | --color-input-background: $color-lt-input-background; 94 | --color-input-border: $color-lt-input-border; 95 | --color-input-border--focus: $color-lt-input-border--focus; 96 | 97 | --color-plugin-text: $color-lt-plugin-text; 98 | --color-plugin-row-odd: $color-lt-plugin-row-odd; 99 | --color-plugin-row-even: $color-lt-plugin-row-even; 100 | } 101 | 102 | .theme-light { @include theme-light; } 103 | 104 | 105 | /** 106 | * Select theme by OS preference 107 | * 108 | * Note: Currently not used, because even with 'no-preference', Safari on older 109 | * systems always uses the light version. 110 | */ 111 | 112 | /* 113 | @media (prefers-color-scheme: light) { 114 | :root { @include theme-light; } 115 | } 116 | 117 | @media (prefers-color-scheme: dark) { 118 | :root { @include theme-default; } 119 | } 120 | */ 121 | -------------------------------------------------------------------------------- /src/styles/utilities/typography.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable declaration-no-important */ 2 | 3 | 4 | /* Typography utilities */ 5 | 6 | 7 | /* Text alignments */ 8 | .u-text-center { text-align: center !important; } 9 | 10 | /* Text font weights */ 11 | .u-text-bold { font-weight: $font-weight-bold !important; } 12 | 13 | 14 | /* stylelint-enable */ 15 | -------------------------------------------------------------------------------- /src/styles/utilities/variables.css: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | 4 | /* Font base */ 5 | $base-font: SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; 6 | $base-font-size: 1em; 7 | $base-line-height: 1.5; 8 | 9 | /* Font weights */ 10 | $font-weight-normal: 400; 11 | $font-weight-bold: 600; 12 | 13 | /* Alternative font sizes */ 14 | $font-size-small: .875rem; /* 14px */ 15 | 16 | /* Headings */ 17 | $heading-line-height: 1.3; 18 | 19 | 20 | /* Border */ 21 | $border-radius: 4px; 22 | 23 | 24 | /* Animations */ 25 | $transition-duration: .15s; 26 | $transition-timing: ease-in; 27 | 28 | 29 | /* Breakpoints, in t-shirt sizes */ 30 | $screen-s: 20em; /* 320px */ 31 | $screen-m: 35.5em; /* 568px */ 32 | $screen-l: 48em; /* 768px */ 33 | $screen-xl: 64em; /* 1024px */ 34 | $screen-xxl: 90em; /* 1680px */ 35 | 36 | 37 | /** 38 | * Containers 39 | * 40 | * rem unit to keep it independent from its parent element font-size. 41 | */ 42 | $container-text: 43.75rem; /* 700px */ 43 | $container-max: 120rem; /* 1920px */ 44 | 45 | 46 | /* Spacing from header to content (per breakpoint) */ 47 | $spacing-header-s: 3em; /* 48px */ 48 | $spacing-header-l: 4em; /* 64px */ 49 | $spacing-header-xl: 4.5em; /* 72px */ 50 | $spacing-header-xxl: 6em; /* 96px */ 51 | 52 | 53 | /* Spacing from content to footer (per breakpoint) */ 54 | $spacing-content-s: 4em; /* 64px */ 55 | $spacing-content-m: 6em; /* 96px */ 56 | $spacing-content-l: 8em; /* 128px */ 57 | $spacing-content-xl: 12em; /* 192px */ 58 | $spacing-content-xxl: $spacing-content-xl; 59 | 60 | 61 | /** 62 | * Dark theme colors 63 | * 64 | * Default theme. 65 | */ 66 | 67 | /* General */ 68 | $color-text: #dfe1e8; 69 | $color-heading: rgb(201,168,250); 70 | $color-background: #0f0f14; 71 | 72 | /* Colors: Sets */ 73 | $color-set-name: $color-text; 74 | $color-set-id: rgba(201,168,250,.24); 75 | $color-set-background: #131318; 76 | $color-set-border: rgba(201,168,250,.08); 77 | $color-set-border--hover: $color-heading; 78 | $color-set-item: #1a1922; 79 | $color-set-item--hover: $color-text; 80 | 81 | /* Colors: Buttons */ 82 | $color-button-text: $color-heading; 83 | $color-button-text--hover: #99ecfd; 84 | $color-button-background: transparent; 85 | $color-button-background--hover: transparent; 86 | $color-button-border: $color-heading; 87 | $color-button-border--hover: $color-button-text--hover; 88 | 89 | /* Colors: Nav buttons */ 90 | $color-button-text--nav: $color-button-text; 91 | $color-button-background--nav: $color-button-background; 92 | $color-button-border--nav: $color-set-border; 93 | 94 | /* Colors: Search bar */ 95 | $color-search-text-placeholder: $color-heading; 96 | $color-search-background: $color-set-item; 97 | 98 | /* Colors: Settings page */ 99 | $color-settings-background: $color-set-background; 100 | $color-input-text: $color-set-name; 101 | $color-input-text-placeholder: $color-heading; 102 | $color-input-background: $color-search-background; 103 | $color-input-border: $color-set-border; 104 | $color-input-border--focus: $color-text; 105 | 106 | /* Colors: Default plugins */ 107 | $color-plugin-text: #c0c5ce; 108 | $color-plugin-row-odd: #16161d; 109 | $color-plugin-row-even: $color-set-item; 110 | 111 | 112 | /** 113 | * Light theme colors 114 | * 115 | * Alternative theme. 116 | */ 117 | 118 | /* General */ 119 | $color-lt-text: #4f5b66; 120 | $color-lt-heading: $color-lt-text; 121 | $color-lt-background: #f8fbff; 122 | 123 | /* Colors: Sets */ 124 | $color-lt-set-name: $color-lt-text; 125 | $color-lt-set-id: #dfe1e8; 126 | $color-lt-set-background: #fbfdff; 127 | $color-lt-set-border: rgba(153,236,253,.16); 128 | $color-lt-set-border--hover: rgb(153,236,253); 129 | $color-lt-set-item: #eff1f5; 130 | $color-lt-set-item--hover: $color-lt-text; 131 | 132 | /* Colors: Buttons */ 133 | $color-lt-button-text: $color-lt-text; 134 | $color-lt-button-text--hover: $color-lt-set-name; 135 | $color-lt-button-background: $color-lt-set-item; 136 | $color-lt-button-background--hover: $color-lt-button-background; 137 | $color-lt-button-border: transparent; 138 | $color-lt-button-border--hover: $color-lt-set-border--hover; 139 | 140 | /* Colors: Nav buttons */ 141 | $color-lt-button-text--nav: #a7adba; 142 | $color-lt-button-background--nav: $color-lt-button-background; 143 | $color-lt-button-border--nav: transparent; 144 | 145 | /* Colors: Search bar */ 146 | $color-lt-search-text-placeholder: $color-lt-button-text--nav; 147 | $color-lt-search-background: $color-lt-set-item; 148 | 149 | /* Colors: Settings page */ 150 | $color-lt-settings-background: $color-lt-set-background; 151 | $color-lt-input-text: $color-lt-set-name; 152 | $color-lt-input-text-placeholder: $color-lt-search-text-placeholder; 153 | $color-lt-input-background: $color-lt-set-item; 154 | $color-lt-input-border: $color-lt-set-border; 155 | $color-lt-input-border--focus: $color-lt-button-border--hover; 156 | 157 | /* Colors: Default plugins */ 158 | $color-lt-plugin-text: $color-lt-button-text--nav; 159 | $color-lt-plugin-row-odd: $color-lt-set-background; 160 | $color-lt-plugin-row-even: $color-lt-set-item; 161 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const StyleLintPlugin = require('stylelint-webpack-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | 8 | module.exports = { 9 | entry: { main: './src/index.js' }, 10 | output: { 11 | path: path.resolve(__dirname, './dist/assets'), 12 | publicPath: 'assets/', 13 | filename: 'main.js' 14 | }, 15 | devServer: { 16 | contentBase: './dist', 17 | watchContentBase: true, 18 | open: true 19 | }, 20 | 21 | // Loaders 22 | module: { 23 | rules : [ 24 | // JavaScript (and ESLint) 25 | { 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | use: ['babel-loader', 'eslint-loader'] 29 | }, 30 | 31 | // CSS 32 | { 33 | test: /\.css$/, 34 | exclude: /node_modules/, 35 | use: [ 36 | 'style-loader', 37 | MiniCssExtractPlugin.loader, 38 | 'css-loader', 39 | 'postcss-loader' 40 | ] 41 | }, 42 | 43 | // Templates 44 | { 45 | test: /\.hbs$/, 46 | loader: 'handlebars-loader' 47 | } 48 | ] 49 | }, 50 | 51 | // Plugins 52 | plugins: [ 53 | new CleanWebpackPlugin(), 54 | new MiniCssExtractPlugin({ 55 | filename: 'style.css' 56 | }), 57 | new StyleLintPlugin({ 58 | files: './src/styles/**/*.css' 59 | }), 60 | new HtmlWebpackPlugin({ 61 | filename: path.resolve(__dirname, './dist/index.html'), 62 | template: './src/index.hbs', 63 | favicon: './src/images/favicon.png' 64 | }) 65 | ] 66 | }; 67 | --------------------------------------------------------------------------------