├── README.md ├── css └── style.css ├── ext ├── manifest.json ├── r.htm └── r.js ├── index.html ├── js ├── date.js ├── misc │ ├── isurl.js │ └── utils.js └── search.js └── media ├── new-dark.png ├── new-light.png ├── old.gif ├── startpage.gif ├── stpg-new.png └── stpg.png /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 3 | ┌─┐┌┬┐┌─┐┬─┐┌┬┐┌─┐┌─┐┌─┐┌─┐ 4 | └─┐ │ ├─┤├┬┘ │ ├─┘├─┤│ ┬├┤ 5 | └─┘ ┴ ┴ ┴┴└─ ┴ ┴ ┴ ┴└─┘└─┘ 6 | 7 | ``` 8 |  9 |  10 | 11 | A simple html startpage and [new tab](ext "custom new tab extension"). 12 | 13 | > Now with automatic light/dark themes! 14 | 15 | ## motivation 16 | 17 | A part from the obvious aesthetics, I also made this so I could use **[Vimium](https://github.com/philc/vimium)** on a decent new tab, rather than pages/blank.html (see this [issue](https://github.com/philc/vimium/issues/1515 "issue link")). 18 | 19 | ## search bar 20 | I use Vomnibar for most searches (my own bangs > duckduckgo's), but I have added in a "hidden" ddg search bar, which is focused by pressing `Space`. (it also takes autofocus, but if you use Vimium it only takes autofocus before vimium loads, so if you press `t` to open a new tab and then immediately start typing). 21 | 22 | > [Live Preview](https://bachoseven.github.io/startpage/ "Live Preview") 23 | 24 | 25 | ## credits 26 | 27 | - https://stpg.tk/guides/terminal-like-startpage for providing useful beginner guides 28 | - https://github.com/Hungry-Hobo/Homepage for the boxes 29 | - https://www.reddit.com/r/startpages/comments/f6hfoq/term_tree/ for the tree structure through CSS borders 30 | -------------------------------------------------------------------------------- /css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: #ebdbb2; 3 | --fg: #282828; 4 | --red: var(--bright-red); 5 | --green: var(--bright-green); 6 | --yellow: var(--bright-yellow); 7 | --blue: var(--bright-blue); 8 | --purple: var(--bright-purple); 9 | --aqua: var(--bright-aqua); 10 | --orange: var(--bright-orange); 11 | --gray: var(--bright-gray); 12 | --bright-red: #fb4934; 13 | --bright-green: #b8bb26; 14 | --bright-yellow: #fabd2f; 15 | --bright-blue: #83a598; 16 | --bright-purple: #d3869b; 17 | --bright-aqua: #8ec07c; 18 | --bright-orange: #fe8019; 19 | --bright-gray: #a89984; 20 | --bright-bg: #fbf1c7; 21 | --link-shadow: 235, 219, 178; 22 | --body-shadow: 168, 153, 132; 23 | --text: #3c3836; 24 | --link-text: #7c6f64; 25 | } 26 | 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | --fg: #ebdbb2; 30 | --bg: #282828; 31 | --red: #cc241d; 32 | --green: #98971a; 33 | --yellow: #d79921; 34 | --blue: #458588; 35 | --purple: #b16286; 36 | --aqua: #689d6a; 37 | --orange: #d65d0e; 38 | --gray: #928374; 39 | --bright-red: #fb4934; 40 | --bright-green: #b8bb26; 41 | --bright-yellow: #fabd2f; 42 | --bright-blue: #83a598; 43 | --bright-purple: #d3869b; 44 | --bright-aqua: #8ec07c; 45 | --bright-orange: #fe8019; 46 | --bright-gray: #a89984; 47 | --bright-bg: #32302f; 48 | --body-shadow: 22, 24, 32; 49 | --link-shadow: 29, 32, 33; 50 | --text: var(--bg); 51 | --link-text: var(--gray); 52 | } 53 | } 54 | 55 | html { 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | background-color: var(--bg); 60 | font: 20px scientifica; 61 | height: 100%; 62 | } 63 | 64 | *, *:before, *:after { 65 | box-sizing: border-box; 66 | } 67 | 68 | body { 69 | background-color: var(--bright-bg); 70 | border-radius: 14px; 71 | box-shadow: 0px 0px 5px 3px rgba(var(--body-shadow), 0.4); 72 | flex-direction: column; 73 | min-height: 30ch; 74 | min-width: 20ww; 75 | padding: 2em; 76 | } 77 | p { 78 | margin: 0; 79 | } 80 | p span { 81 | color: var(--green); 82 | font-weight: bold; 83 | } 84 | 85 | p #logo { 86 | color: #60c0d0; 87 | vertical-align:-3px; 88 | font-size: 28px; 89 | } 90 | 91 | p #cursor { 92 | background-color: #add8e6; 93 | animation: blink 2s infinite; 94 | } 95 | @keyframes blink { 96 | 0% { opacity: 0; } 97 | 49% { opacity: 0; } 98 | 50% { opacity: 1; } 99 | } 100 | 101 | a { 102 | color: inherit; 103 | text-decoration: none; 104 | } 105 | 106 | a:focus, 107 | a:hover { 108 | font-weight: bold; 109 | } 110 | 111 | ul { 112 | list-style: none; 113 | margin: 1em 0; 114 | padding: 0; 115 | } 116 | 117 | .tree > .all { 118 | margin: 0; 119 | padding-left: 1rem; 120 | } 121 | 122 | .all { 123 | display: inline-block; 124 | vertical-align: top; 125 | margin-left: 1em; 126 | padding-left: 0.5em; 127 | } 128 | 129 | .all:nth-child(1) { 130 | margin-left: 0; 131 | } 132 | 133 | li { 134 | position: relative; 135 | } 136 | li::before, li::after { 137 | content: ""; 138 | position: absolute; 139 | left: -0.75rem; 140 | } 141 | li::before { 142 | border-top: 2px solid var(--bright-blue); 143 | top: 0.45em; 144 | width: 0.8rem; 145 | } 146 | li::after { 147 | border-left: 2px solid var(--bright-blue); 148 | height: 100%; 149 | top: 0.2rem; 150 | } 151 | li:last-child::after { 152 | height: 0.3rem; 153 | } 154 | 155 | h3 { 156 | position: relative; 157 | color: var(--text); 158 | } 159 | h3::before, h3::after { 160 | position: absolute; 161 | left: -0.75rem; 162 | content: ""; 163 | } 164 | h3::before { 165 | border-top: 2px solid var(--bright-blue); 166 | top: 0.5rem; 167 | width: 0.6em; 168 | } 169 | h3::after { 170 | border-left: 2px solid var(--bright-blue); 171 | height: 200%; 172 | top: 0.5rem; 173 | } 174 | 175 | .linksbox { 176 | text-align: center; 177 | color: var(--link-text); 178 | text-decoration: none; 179 | width: 8em; 180 | box-shadow: 5px 5px 3px rgba(var(--link-shadow), 0.7); 181 | padding: 0.2em; 182 | } 183 | 184 | .title { 185 | text-align: center; 186 | width: 8em; 187 | margin-left: 0.2em; 188 | } 189 | 190 | .math { 191 | background-color: var(--aqua); 192 | } 193 | 194 | .\.\. { 195 | background-color: var(--gray); 196 | } 197 | 198 | .linux { 199 | background-color: var(--blue); 200 | } 201 | 202 | .self { 203 | background-color: #d4be98; 204 | } 205 | 206 | .social { 207 | background-color: var(--purple); 208 | } 209 | 210 | .feed { 211 | background-color: var(--red); 212 | } 213 | 214 | .reddit { 215 | background-color: var(--orange); 216 | } 217 | 218 | input { 219 | background: none; 220 | color: var(--bright-bg); 221 | width: 17px; 222 | font: inherit; 223 | text-align: center; 224 | outline: none; 225 | border: none; 226 | border-radius: 0; 227 | transition: .1s; /* uwu */ 228 | } 229 | textarea:focus, 230 | input:focus { 231 | color: #d4be98; 232 | outline: none; 233 | width: 30%; 234 | } 235 | -------------------------------------------------------------------------------- /ext/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ntp", 4 | "chrome_url_overrides": { 5 | "newtab": "r.htm" 6 | }, 7 | "host_permissions": [ "file:///*" ], 8 | "version": "1.0.5" 9 | } 10 | -------------------------------------------------------------------------------- /ext/r.htm: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | ~
11 | 12 |❯
78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /js/date.js: -------------------------------------------------------------------------------- 1 | // Show a smol digital clock 2 | document.onreadystatechange = () => { 3 | const date = new Date() 4 | document.getElementById("time").innerText = `${date.toTimeString([], { hour: '2-digit', minute: '2-digit' }).replace(/ GMT.*/, "")}` 5 | } 6 | 7 | // update indicator every second 8 | setInterval(() => { 9 | const date = new Date() 10 | document.getElementById("time").innerText = `${date.toTimeString([], { hour: '2-digit', minute: '2-digit' }).replace(/ GMT.*/, "")}` 11 | }, 1000) 12 | -------------------------------------------------------------------------------- /js/misc/isurl.js: -------------------------------------------------------------------------------- 1 | // First Method (~YAY) 2 | var inputElement = doc.createElement('input'); 3 | inputElement.type = 'url'; // this is important 4 | // inputElement.value = url; 5 | if (!inputElement.checkValidity()) { 6 | throw new TypeError('Invalid URL'); 7 | } 8 | 9 | // Second Method (NAY) 10 | function validURL(str) { 11 | var pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol 12 | '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name 13 | '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address 14 | '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path 15 | '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string 16 | '(\\#[-a-z\\d_]*)?$','i'); // fragment locator 17 | return !!pattern.test(str); 18 | } 19 | 20 | // Third Method (Vimium) [includes guessing protocol, chrome:// pages, ...] 21 | // usage: 22 | if (isUrl(q)) { 23 | window.location=createFullUrl(q); 24 | } else { 25 | // (...) 26 | } 27 | -------------------------------------------------------------------------------- /js/misc/utils.js: -------------------------------------------------------------------------------- 1 | // utils.js is the first file required by our background scripts and content scripts. When running tests, the 2 | // environment is nodejs, which has a `global` object that defines the global scope. In the browser, that 3 | // global scope is `window`. The rest of our code attaches exported functions into this global scope. 4 | // We don't use ES modules because they don't work in Chrome extension content scripts. 5 | if (typeof(global) == "undefined") 6 | global = window; 7 | 8 | // Only pass events to the handler if they are marked as trusted by the browser. 9 | // This is kept in the global namespace for brevity and ease of use. 10 | if (window.forTrusted == null) { 11 | window.forTrusted = handler => (function(event) { 12 | if (event && event.isTrusted) { 13 | return handler.apply(this, arguments); 14 | } else { 15 | return true; 16 | } 17 | }); 18 | } 19 | 20 | // TODO(philc): The comment below about its usage doesn't make sense to me. Can we replace this with 21 | // window.navigator? 22 | let browserInfo = null; 23 | if (window.browser && browser.runtime && browser.runtime.getBrowserInfo) 24 | browserInfo = browser.runtime.getBrowserInfo(); 25 | 26 | 27 | var Utils = { 28 | isFirefox: (function() { 29 | // NOTE(mrmr1993): This test only works in the background page, this is overwritten by isEnabledForUrl for 30 | // content scripts, and the "settings" message from UIComponent is for pages like HUD. 31 | let isFirefox = false; 32 | if (browserInfo) { 33 | browserInfo.then(browserInfo => isFirefox = browserInfo.name === "Firefox"); 34 | } 35 | return () => isFirefox; 36 | })(), 37 | 38 | firefoxVersion: (function() { 39 | // NOTE(mrmr1993): This only works in the background page. 40 | let ffVersion = undefined; 41 | if (browserInfo) { 42 | browserInfo.then(browserInfo => ffVersion = browserInfo != null ? browserInfo.version : undefined); 43 | } 44 | return () => ffVersion; 45 | })(), 46 | 47 | getCurrentVersion() { 48 | return chrome.runtime.getManifest().version; 49 | }, 50 | 51 | // Returns true whenever the current page (or the page supplied as an argument) is from the extension's 52 | // origin (and thus can access the extension's localStorage). 53 | isExtensionPage(win) { 54 | if (win == null) { win = window; } 55 | try { 56 | return ((win.document.location != null ? win.document.location.origin : undefined) + "/") 57 | === chrome.extension.getURL(""); 58 | } catch (error) {} 59 | }, 60 | 61 | // Returns true whenever the current page is the extension's background page. 62 | isBackgroundPage() { 63 | // NOTE(philc): chrome.extension.getBackgroundPage is undefined under some circumstances, but I wasn't 64 | // able to determine precisely which. 65 | return this.isBackgroundPage && chrome.extension.getBackgroundPage && 66 | chrome.extension.getBackgroundPage() === window; 67 | }, 68 | 69 | // Escape all special characters, so RegExp will parse the string 'as is'. 70 | // Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex 71 | escapeRegexSpecialCharacters: (function() { 72 | const escapeRegex = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g; 73 | return str => str.replace(escapeRegex, "\\$&"); 74 | })(), 75 | 76 | escapeHtml(string) { return string.replace(//g, ">"); }, 77 | 78 | // Generates a unique ID 79 | createUniqueId: (function() { 80 | let id = 0; 81 | return () => id += 1; 82 | })(), 83 | 84 | hasChromePrefix: (function() { 85 | const chromePrefixes = ["about:", "view-source:", "extension:", "chrome-extension:", "data:"]; 86 | return url => chromePrefixes.some(prefix => url.startsWith(prefix)); 87 | })(), 88 | 89 | hasJavascriptPrefix(url) { 90 | return url.startsWith("javascript:"); 91 | }, 92 | 93 | hasFullUrlPrefix: (function() { 94 | const urlPrefix = new RegExp("^[a-z][-+.a-z0-9]{2,}://."); 95 | return url => urlPrefix.test(url); 96 | })(), 97 | 98 | // Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding 99 | // Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked). 100 | // See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636. 101 | decodeURIByParts(uri) { 102 | return uri.split(/(?=%)/).map(function(uriComponent) { 103 | try { 104 | return decodeURIComponent(uriComponent); 105 | } catch (error) { 106 | return uriComponent; 107 | } 108 | }).join(""); 109 | }, 110 | 111 | // Completes a partial URL (without scheme) 112 | createFullUrl(partialUrl) { 113 | if (this.hasFullUrlPrefix(partialUrl)) 114 | return partialUrl; 115 | else 116 | return ("http://" + partialUrl); 117 | }, 118 | 119 | // Tries to detect if :str is a valid URL. 120 | isUrl(str) { 121 | // Must not contain spaces 122 | if (str.includes(' ')) { return false; } 123 | 124 | // Starts with a scheme: URL 125 | if (this.hasFullUrlPrefix(str)) { return true; } 126 | 127 | // More or less RFC compliant URL host part parsing. This should be sufficient for our needs 128 | const urlRegex = new RegExp( 129 | '^(?:([^:]+)(?::([^:]+))?@)?' + // user:password (optional) => \1, \2 130 | '([^:]+|\\[[^\\]]+\\])' + // host name (IPv6 addresses in square brackets allowed) => \3 131 | '(?::(\\d+))?$' // port number (optional) => \4 132 | ); 133 | 134 | // Official ASCII TLDs that are longer than 3 characters + inofficial .onion TLD used by TOR 135 | const longTlds = ['arpa', 'asia', 'coop', 'info', 'jobs', 'local', 'mobi', 'museum', 'name', 'onion']; 136 | 137 | const specialHostNames = ['localhost']; 138 | 139 | // Try to parse the URL into its meaningful parts. If matching fails we're pretty sure that we don't have 140 | // some kind of URL here. 141 | const match = urlRegex.exec((str.split('/'))[0]); 142 | if (!match) { return false; } 143 | const hostName = match[3]; 144 | 145 | // Allow known special host names 146 | if (specialHostNames.includes(hostName)) { return true; } 147 | 148 | // Allow IPv6 addresses (need to be wrapped in brackets as required by RFC). It is sufficient to check for 149 | // a colon, as the regex wouldn't match colons in the host name unless it's an v6 address 150 | if (hostName.includes(':')) { return true; } 151 | 152 | // At this point we have to make a decision. As a heuristic, we check if the input has dots in it. If yes, 153 | // and if the last part could be a TLD, treat it as an URL 154 | const dottedParts = hostName.split('.'); 155 | 156 | if (dottedParts.length > 1) { 157 | const lastPart = dottedParts.pop(); 158 | if ((2 <= lastPart.length && lastPart.length <= 3) || longTlds.includes(lastPart)) { return true; } 159 | } 160 | 161 | // Allow IPv4 addresses 162 | if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostName)) { return true; } 163 | 164 | // Fallback: no URL 165 | return false; 166 | }, 167 | 168 | // Map a search query to its URL encoded form. The query may be either a string or an array of strings. 169 | // E.g. "BBC Sport" -> "BBC+Sport". 170 | createSearchQuery(query) { 171 | if (typeof(query) === "string") { query = query.split(/\s+/); } 172 | return query.map(encodeURIComponent).join("+"); 173 | }, 174 | 175 | // Create a search URL from the given :query (using either the provided search URL, or the default one). 176 | // It would be better to pull the default search engine from chrome itself. However, chrome does not provide 177 | // an API for doing so. 178 | createSearchUrl(query, searchUrl) { 179 | if (searchUrl == null) { searchUrl = Settings.get("searchUrl"); } 180 | if (!['%s', '%S'].some(token => searchUrl.indexOf(token) >= 0)) { searchUrl += "%s"; } 181 | searchUrl = searchUrl.replace(/%S/g, query); 182 | return searchUrl.replace(/%s/g, this.createSearchQuery(query)); 183 | }, 184 | 185 | // Extract a query from url if it appears to be a URL created from the given search URL. 186 | // For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars". 187 | extractQuery: (() => { 188 | const queryTerminator = new RegExp("[?/]"); 189 | const httpProtocolRegexp = new RegExp("^https?://"); 190 | return function(searchUrl, url) { 191 | let suffixTerms; 192 | url = url.replace(httpProtocolRegexp); 193 | searchUrl = searchUrl.replace(httpProtocolRegexp); 194 | [ searchUrl, ...suffixTerms ] = searchUrl.split("%s"); 195 | // We require the URL to start with the search URL. 196 | if (!url.startsWith(searchUrl)) { return null; } 197 | // We require any remaining terms in the search URL to also be present in the URL. 198 | for (let suffix of suffixTerms) { 199 | if (!(0 <= url.indexOf(suffix))) { return null; } 200 | } 201 | // We use try/catch because decodeURIComponent can throw an exception. 202 | try { 203 | return url.slice(searchUrl.length).split(queryTerminator)[0].split("+").map(decodeURIComponent).join(" "); 204 | } catch (error) { 205 | return null; 206 | } 207 | }; 208 | })(), 209 | 210 | // Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters 211 | // as Chrome will do that for us. 212 | convertToUrl(string) { 213 | string = string.trim(); 214 | 215 | // Special-case about:[url], view-source:[url] and the like 216 | if (Utils.hasChromePrefix(string)) { 217 | return string; 218 | } else if (Utils.hasJavascriptPrefix(string)) { 219 | // In Chrome versions older than 46.0.2467.2, encoded javascript URIs weren't handled correctly. 220 | if (Utils.haveChromeVersion("46.0.2467.2")) 221 | return string; 222 | else 223 | return Utils.decodeURIByParts(string); 224 | } else if (Utils.isUrl(string)) { 225 | return Utils.createFullUrl(string); 226 | } else { 227 | return Utils.createSearchUrl(string); 228 | } 229 | }, 230 | 231 | // detects both literals and dynamically created strings 232 | isString(obj) { return (typeof obj === 'string') || obj instanceof String; }, 233 | 234 | // Transform "zjkjkabz" into "abjkz". 235 | distinctCharacters(str) { 236 | const chars = str.split(""); 237 | return Array.from(new Set(chars)).sort().join(""); 238 | }, 239 | 240 | // Compares two version strings (e.g. "1.1" and "1.5") and returns 241 | // -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB. 242 | compareVersions(versionA, versionB) { 243 | versionA = versionA.split("."); 244 | versionB = versionB.split("."); 245 | for (let i = 0, end = Math.max(versionA.length, versionB.length); i < end; i++) { 246 | const a = parseInt(versionA[i] || 0, 10); 247 | const b = parseInt(versionB[i] || 0, 10); 248 | if (a < b) { 249 | return -1; 250 | } else if (a > b) { 251 | return 1; 252 | } 253 | } 254 | return 0; 255 | }, 256 | 257 | // True if the current Chrome version is at least the required version. 258 | haveChromeVersion(required) { 259 | const match = navigator.appVersion.match(/Chrom(e|ium)\/(.*?) /); 260 | const chromeVersion = match ? match[2] : null; 261 | return chromeVersion && (0 <= Utils.compareVersions(chromeVersion, required)); 262 | }, 263 | 264 | // Zip two (or more) arrays: 265 | // - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ] 266 | // - Length of result is `arrays[0].length`. 267 | // - Adapted from: http://stackoverflow.com/questions/4856717/javascript-equivalent-of-pythons-zip-function 268 | zip(arrays) { 269 | return arrays[0].map((_, i) => arrays.map(array => array[i])); 270 | }, 271 | 272 | // locale-sensitive uppercase detection 273 | hasUpperCase(s) { return s.toLowerCase() !== s; }, 274 | 275 | // Does string match any of these regexps? 276 | matchesAnyRegexp(regexps, string) { 277 | for (let re of regexps) { 278 | if (re.test(string)) { return true; } 279 | } 280 | return false; 281 | }, 282 | 283 | // Convenience wrapper for setTimeout (with the arguments around the other way). 284 | setTimeout(ms, func) { return setTimeout(func, ms); }, 285 | 286 | // Like Nodejs's nextTick. 287 | nextTick(func) { return this.setTimeout(0, func); }, 288 | 289 | // Make an idempotent function. 290 | makeIdempotent(func) { 291 | return function(...args) { 292 | let previousFunc, ref; 293 | const result = ([previousFunc, func] = Array.from(ref = [func, null]), ref)[0]; 294 | if (result) { 295 | return result(...Array.from(args || [])); 296 | } 297 | }; 298 | }, 299 | 300 | monitorChromeStorage(key, setter) { 301 | return chrome.storage.local.get(key, obj => { 302 | if (obj[key] != null) { setter(obj[key]); } 303 | return chrome.storage.onChanged.addListener((changes, area) => { 304 | if (changes[key] && (changes[key].newValue !== undefined)) { 305 | return setter(changes[key].newValue); 306 | } 307 | }); 308 | }); 309 | } 310 | }; 311 | 312 | // This creates a new function out of an existing function, where the new function takes fewer arguments. This 313 | // allows us to pass around functions instead of functions + a partial list of arguments. 314 | Function.prototype.curry = function() { 315 | const fixedArguments = Array.copy(arguments); 316 | const fn = this; 317 | return function() { return fn.apply(this, fixedArguments.concat(Array.copy(arguments))); }; 318 | }; 319 | 320 | Array.copy = array => Array.prototype.slice.call(array, 0); 321 | 322 | String.prototype.reverse = function() { return this.split("").reverse().join(""); }; 323 | 324 | // A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded. 325 | // At most 2 * @entries entries are retained. 326 | class SimpleCache { 327 | // expiry: expiry time in milliseconds (default, one hour) 328 | // entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too) 329 | constructor(expiry, entries) { 330 | if (expiry == null) { expiry = 60 * 60 * 1000; } 331 | this.expiry = expiry; 332 | if (entries == null) { entries = 1000; } 333 | this.entries = entries; 334 | this.cache = {}; 335 | this.previous = {}; 336 | this.lastRotation = new Date(); 337 | } 338 | 339 | has(key) { 340 | this.rotate(); 341 | return (key in this.cache) || key in this.previous; 342 | } 343 | 344 | // Set value, and return that value. If value is null, then delete key. 345 | set(key, value = null) { 346 | this.rotate(); 347 | delete this.previous[key]; 348 | if (value != null) { 349 | return this.cache[key] = value; 350 | } else { 351 | delete this.cache[key]; 352 | return null; 353 | } 354 | } 355 | 356 | get(key) { 357 | this.rotate(); 358 | if (key in this.cache) { 359 | return this.cache[key]; 360 | } else if (key in this.previous) { 361 | this.cache[key] = this.previous[key]; 362 | delete this.previous[key]; 363 | return this.cache[key]; 364 | } else { 365 | return null; 366 | } 367 | } 368 | 369 | rotate(force) { 370 | if (force == null) { force = false; } 371 | Utils.nextTick(() => { 372 | if (force || (this.entries < Object.keys(this.cache).length) || (this.expiry < (new Date() - this.lastRotation))) { 373 | this.lastRotation = new Date(); 374 | this.previous = this.cache; 375 | return this.cache = {}; 376 | } 377 | }); 378 | } 379 | 380 | clear() { 381 | this.rotate(true); 382 | return this.rotate(true); 383 | } 384 | } 385 | 386 | // This is a simple class for the common case where we want to use some data value which may be immediately 387 | // available, or for which we may have to wait. It implements a use-immediately-or-wait queue, and calls the 388 | // fetch function to fetch the data asynchronously. 389 | class AsyncDataFetcher { 390 | constructor(fetch) { 391 | this.data = null; 392 | this.queue = []; 393 | Utils.nextTick(() => { 394 | return fetch(data => { 395 | this.data = data; 396 | for (let callback of this.queue) { callback(this.data); } 397 | return this.queue = null; 398 | }); 399 | }); 400 | } 401 | 402 | use(callback) { 403 | if (this.data != null) { return callback(this.data); } else { return this.queue.push(callback); } 404 | } 405 | } 406 | 407 | // This takes a list of jobs (functions) and runs them, asynchronously. Functions queued with @onReady() are 408 | // run once all of the jobs have completed. 409 | class JobRunner { 410 | constructor(jobs) { 411 | this.jobs = jobs; 412 | this.fetcher = new AsyncDataFetcher(callback => { 413 | return this.jobs.map((job) => 414 | (job => { 415 | Utils.nextTick(() => { 416 | return job(() => { 417 | this.jobs = this.jobs.filter(j => j !== job); 418 | if (this.jobs.length === 0) { return callback(true); } 419 | }); 420 | }); 421 | return null; 422 | })(job)); 423 | }); 424 | } 425 | 426 | onReady(callback) { 427 | return this.fetcher.use(callback); 428 | } 429 | } 430 | 431 | Object.assign(global, {Utils, SimpleCache, AsyncDataFetcher, JobRunner}); 432 | -------------------------------------------------------------------------------- /js/search.js: -------------------------------------------------------------------------------- 1 | // Implements a simple search bar with custom bangs 2 | 3 | function search(i) { 4 | var q = i.value; 5 | q = q.replace(/^[ ]/g,'') // Remove leading spaces 6 | if (i.checkValidity()) { // If query is an URL, go to it. 7 | window.location=q; 8 | } else if (q.substr(1, 1) == ' ') { // check if it's a bang (i.e. `y youtubequery` and `a query` are parsed here, but `query` is not) 9 | switch(q.substr(0, 1)){ 10 | case 'y': 11 | q = q.substr(2); 12 | window.location=( 13 | 'https://www.youtube.com/results?search_query=' + 14 | q.replace(' ', '%20')); // %20 is Space 15 | break; 16 | case 'w': 17 | q = q.substr(2); 18 | window.location=( 19 | 'https://wikipedia.org/w/index.php?search=' + 20 | q.replace(' ', '%20')); 21 | break; 22 | case 'g': 23 | q = q.substr(2); 24 | window.location=( 25 | 'https://github.com/search?q=' + 26 | q.replace(' ', '%20')); 27 | break; 28 | case 'l': 29 | q = q.substr(2); 30 | window.location=( 31 | 'http://libgen.rs/search.php?req=' + 32 | q.replace(' ', '%20')); 33 | break; 34 | case 'r': 35 | q = q.substr(2); 36 | window.location=( 37 | 'https://www.reddit.com/r/' + 38 | q.replace(' ', '')); 39 | break; 40 | case 'a': 41 | q = q.substr(2); 42 | window.location=( 43 | 'https://www.amazon.it/s/?field-keywords=' + 44 | q.replace(' ', '%20')); 45 | break; 46 | default: 47 | window.location=('http://192.168.1.107:8888/search?q=' + 48 | q.replace(' ', '%20')); 49 | } 50 | } else { // this is were `normal q` will be parsed 51 | window.location=('http://192.168.1.107:8888/search?q=' + 52 | q.replace(' ', '%20')); 53 | } 54 | } 55 | 56 | i = document.getElementById('q'); 57 | // Pressing space (in Insert mode) focuses search bar 58 | document.addEventListener('keydown', event => { 59 | if (event.code == 'Space') { 60 | i.focus(); 61 | } 62 | }); 63 | // Enter accepts the search 64 | if (!!i) { 65 | i.addEventListener('keydown', event => { 66 | if (event.code == 'Enter') { 67 | i.type = 'url'; 68 | search(i); 69 | } 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /media/new-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/new-dark.png -------------------------------------------------------------------------------- /media/new-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/new-light.png -------------------------------------------------------------------------------- /media/old.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/old.gif -------------------------------------------------------------------------------- /media/startpage.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/startpage.gif -------------------------------------------------------------------------------- /media/stpg-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/stpg-new.png -------------------------------------------------------------------------------- /media/stpg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BachoSeven/startpage/7b444a2ed2b083fd1c2dd5aad2b7072b650b1c61/media/stpg.png --------------------------------------------------------------------------------