A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
at main 18 kB view raw
1import "./global.css"; 2import { bangs } from "./bangs/hashbang.ts"; 3import { 4 addToSearchHistory, 5 clearSearchHistory, 6 createAudio, 7 getSearchHistory, 8 storage, 9} from "./libs.ts"; 10 11import notFoundPageRender from "./404.ts"; 12 13export const CONSTANTS = { 14 MAX_HISTORY: 500, 15 ANIMATION_DURATION: 375, 16 LOCAL_STORAGE_KEYS: { 17 SEARCH_HISTORY: "search-history", 18 SEARCH_COUNT: "search-count", 19 HISTORY_ENABLED: "history-enabled", 20 DEFAULT_BANG: "default-bang", 21 CUSTOM_BANGS: "custom-bangs", 22 }, 23 CUTIES: { 24 NOTFOUND: [ 25 "(╯︵╰,)", 26 "(。•́︿•̀。)", 27 "(⊙_☉)", 28 "(╯°□°)╯︵ ┻━┻", 29 "(ಥ﹏ಥ)", 30 "(✿◕‿◕✿)", 31 "(╥﹏╥)", 32 "(。•́︿•̀。)", 33 "(✧ω✧)", 34 "(•́_•̀)", 35 "(╯°□°)╯︵ ┻━┻", 36 ], 37 LEFT: ["╰(°□°╰)", "(◕‿◕´)", "(・ω・´)"], 38 RIGHT: ["(╯°□°)╯", "(`◕‿◕)", "(`・ω・)"], 39 UP: ["(↑°□°)↑", "(´◕‿◕)↑", "↑(´・ω・)↑"], 40 DOWN: ["(↓°□°)↓", "(´◕‿◕)↓", "↓(´・ω・)↓"], 41 }, 42}; 43const customBangs: { 44 [key: string]: { 45 c?: string; 46 d: string; 47 r: number; 48 s: string; 49 sc?: string; 50 t: string; 51 u: string; 52 }; 53} = JSON.parse(localStorage.getItem("custom-bangs") || "{}"); 54 55function getFocusableElements( 56 root: HTMLElement = document.body, 57): HTMLElement[] { 58 return Array.from( 59 root.querySelectorAll<HTMLElement>( 60 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', 61 ), 62 ); 63} 64 65function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) { 66 const modalElements = getFocusableElements(modal); 67 const allElements = getFocusableElements(); 68 69 for (const element of allElements) { 70 if (!modalElements.includes(element)) { 71 element.setAttribute("tabindex", tabindex.toString()); 72 } 73 } 74} 75 76const createTemplate = (data: { 77 searchCount: string; 78 historyEnabled: boolean; 79 searchHistory: Array<{ 80 bang: string; 81 query: string; 82 name: string; 83 timestamp: number; 84 }>; 85 LS_DEFAULT_BANG: string; 86}) => ` 87 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 88 <header style="position: absolute; top: 1rem; width: 100%;"> 89 <div style="display: flex; justify-content: space-between; padding: 0 1rem;"> 90 <span>${data.searchCount} ${data.searchCount === "1" ? "search" : "searches"}</span> 91 <button class="settings-button"> 92 <img src="/gear.svg" alt="Settings" class="settings" /> 93 </button> 94 </div> 95 </header> 96 <div class="content-container"> 97 <h1 id="cutie">┐( ˘_˘ )┌</h1> 98 <p>DuckDuckGo's bang redirects are too slow. Add the following URL as a custom search engine to your browser. Enables <a href="https://duckduckgo.com/bang.html" target="_blank">all of DuckDuckGo's bangs.</a></p> 99 <div class="url-container"> 100 <input 101 type="text" 102 class="url-input" 103 value="https://unduck.link?q=%s" 104 readonly 105 /> 106 <button class="copy-button"> 107 <img src="/clipboard.svg" alt="Copy" /> 108 </button> 109 </div> 110 ${ 111 data.historyEnabled 112 ? ` 113 <h2 style="margin-top: 24px;">Recent Searches</h2> 114 <div style="max-height: 200px; overflow-y: auto; text-align: left;"> 115 ${ 116 data.searchHistory.length === 0 117 ? `<div style="padding: 8px; text-align: center;">No search history</div>` 118 : data.searchHistory 119 .map( 120 (search) => ` 121 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);"> 122 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a> 123 <span style="float: right; color: var(--text-color-secondary);"> 124 ${new Date(search.timestamp).toLocaleString()} 125 </span> 126 </div> 127 `, 128 ) 129 .join("") 130 } 131 </div> 132 ` 133 : "" 134 } 135 </div> 136 <footer class="footer"> 137 made with ♥ by <a href="https://github.com/taciturnaxolotl" target="_blank">Kieran Klukas</a> as <a href="https://github.com/taciturnaxolotl/unduck" target="_blank">open source</a> software 138 </footer> 139 <div class="modal" id="settings-modal"> 140 <div class="modal-content"> 141 <button class="close-modal">&times;</button> 142 <h2>Settings</h2> 143 <div class="settings-section"> 144 <h3>Bangs</h3> 145 <label for="default-bang" id="bang-description">Default Bang: ${bangs[data.LS_DEFAULT_BANG].s || "Unknown bang"}</label> 146 <div class="bang-select-container"> 147 <input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}"> 148 </div> 149 <p class="help-text">The best way to add new bangs is by submitting them on <a href="https://duckduckgo.com/newbang" target="_blank">DuckDuckGo</a> but you can also add them below</p> 150 <div style="margin-top: 16px;"> 151 <h4>Add Custom Bang</h4> 152 <div class="custom-bang-inputs"> 153 <input type="text" placeholder="Bang name" id="bang-name" class="bang-name"> 154 <input type="text" placeholder="Shortcut (e.g. !ddg)" id="bang-shortcut" class="bang-shortcut"> 155 <input type="text" placeholder="Search URL with {{{s}}}" id="bang-search-url" class="bang-search-url"> 156 <input type="text" placeholder="Base domain" id="bang-base-url" class="bang-base-url"> 157 <div style="text-align: right;"> 158 <button class="add-bang">Add Bang</button> 159 </div> 160 </div> 161 ${ 162 Object.keys(customBangs).length > 0 163 ? ` 164 <h4>Your Custom Bangs</h4> 165 <div class="custom-bangs-list"> 166 ${Object.entries(customBangs) 167 .map( 168 ([shortcut, bang]) => ` 169 <div class="custom-bang-item"> 170 <table class="custom-bang-info"> 171 <tr> 172 <td class="custom-bang-name">${bang.t}</td> 173 <td class="custom-bang-shortcut"><code>!${shortcut}</code></td> 174 <td class="custom-bang-base">${bang.d}</td> 175 </tr> 176 </table> 177 <div class="custom-bang-url">${bang.u}</div> 178 <button class="remove-bang" data-shortcut="${shortcut}">Remove</button> 179 </div> 180 `, 181 ) 182 .join("")} 183 </div> 184 ` 185 : "" 186 } 187 </div> 188 </div> 189 <div class="settings-section"> 190 <h3>Search History (${data.searchHistory.length}/500)</h3> 191 <div style="display: flex; justify-content: space-between; align-items: center;"> 192 <label class="switch"> 193 <label for="history-toggle">Enable Search History</label> 194 <input type="checkbox" id="history-toggle" ${data.historyEnabled ? "checked" : ""}> 195 <span class="slider round"></span> 196 </label> 197 <button class="clear-history">Clear History</button> 198 </div> 199 </div> 200 </div> 201 </div> 202 </div> 203 </div> 204`; 205 206function noSearchDefaultPageRender() { 207 const searchCount = 208 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0"; 209 const historyEnabled = 210 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true"; 211 const searchHistory = getSearchHistory(); 212 const app = document.querySelector<HTMLDivElement>("#app"); 213 if (!app) throw new Error("App element not found"); 214 215 app.innerHTML = createTemplate({ 216 searchCount, 217 historyEnabled, 218 searchHistory, 219 LS_DEFAULT_BANG, 220 }); 221 222 const elements = { 223 app, 224 cutie: app.querySelector<HTMLHeadingElement>("#cutie"), 225 copyInput: app.querySelector<HTMLInputElement>(".url-input"), 226 copyButton: app.querySelector<HTMLButtonElement>(".copy-button"), 227 copyIcon: app.querySelector<HTMLImageElement>(".copy-button img"), 228 urlInput: app.querySelector<HTMLInputElement>(".url-input"), 229 settingsButton: app.querySelector<HTMLButtonElement>(".settings-button"), 230 modal: app.querySelector<HTMLDivElement>("#settings-modal"), 231 closeModal: app.querySelector<HTMLSpanElement>(".close-modal"), 232 defaultBangSelect: app.querySelector<HTMLSelectElement>("#default-bang"), 233 description: app.querySelector<HTMLParagraphElement>("#bang-description"), 234 historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"), 235 clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"), 236 bangName: app.querySelector<HTMLInputElement>(".bang-name"), 237 bangShortcut: app.querySelector<HTMLInputElement>(".bang-shortcut"), 238 bangSearchUrl: app.querySelector<HTMLInputElement>(".bang-search-url"), 239 bangBaseUrl: app.querySelector<HTMLInputElement>(".bang-base-url"), 240 addBang: app.querySelector<HTMLButtonElement>(".add-bang"), 241 removeBangs: app.querySelectorAll<HTMLButtonElement>(".remove-bang"), 242 } as const; 243 244 // Validate all elements exist 245 for (const [key, element] of Object.entries(elements)) { 246 if (!element) throw new Error(`${key} not found`); 247 } 248 249 // After validation, we can assert elements are non-null 250 const validatedElements = elements as { 251 [K in keyof typeof elements]: NonNullable<(typeof elements)[K]>; 252 }; 253 254 validatedElements.urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`; 255 256 const prefersReducedMotion = window.matchMedia( 257 "(prefers-reduced-motion: reduce)", 258 ).matches; 259 260 if (!prefersReducedMotion) { 261 // Add mouse tracking behavior 262 document.addEventListener("click", (e) => { 263 const x = e.clientX; 264 const y = e.clientY; 265 const centerX = window.innerWidth / 2; 266 const centerY = window.innerHeight / 2; 267 const differenceX = x - centerX; 268 const differenceY = y - centerY; 269 270 if ( 271 Math.abs(differenceX) > Math.abs(differenceY) && 272 Math.abs(differenceX) > 100 273 ) { 274 validatedElements.cutie.textContent = 275 differenceX < 0 276 ? CONSTANTS.CUTIES.LEFT[ 277 Math.floor(Math.random() * CONSTANTS.CUTIES.LEFT.length) 278 ] 279 : CONSTANTS.CUTIES.RIGHT[ 280 Math.floor(Math.random() * CONSTANTS.CUTIES.RIGHT.length) 281 ]; 282 } else if (Math.abs(differenceY) > 100) { 283 validatedElements.cutie.textContent = 284 differenceY < 0 285 ? CONSTANTS.CUTIES.UP[ 286 Math.floor(Math.random() * CONSTANTS.CUTIES.UP.length) 287 ] 288 : CONSTANTS.CUTIES.DOWN[ 289 Math.floor(Math.random() * CONSTANTS.CUTIES.DOWN.length) 290 ]; 291 } 292 }); 293 294 const audio = { 295 spin: createAudio("/heavier-tick-sprite.opus"), 296 toggleOff: createAudio("/toggle-button-off.opus"), 297 toggleOn: createAudio("/toggle-button-on.opus"), 298 click: createAudio("/click-button.opus"), 299 warning: createAudio("/double-button.opus"), 300 copy: createAudio("/foot-switch.opus"), 301 }; 302 303 validatedElements.copyButton.addEventListener("click", () => { 304 audio.copy.currentTime = 0; 305 audio.copy.play(); 306 }); 307 308 validatedElements.settingsButton.addEventListener("mouseenter", () => { 309 audio.spin.play(); 310 }); 311 312 validatedElements.settingsButton.addEventListener("mouseleave", () => { 313 audio.spin.pause(); 314 audio.spin.currentTime = 0; 315 }); 316 317 validatedElements.historyToggle.addEventListener("change", () => { 318 if (validatedElements.historyToggle.checked) { 319 audio.toggleOff.pause(); 320 audio.toggleOff.currentTime = 0; 321 audio.toggleOn.currentTime = 0; 322 audio.toggleOn.play(); 323 } else { 324 audio.toggleOn.pause(); 325 audio.toggleOn.currentTime = 0; 326 audio.toggleOff.currentTime = 0; 327 audio.toggleOff.play(); 328 } 329 }); 330 331 validatedElements.clearHistory.addEventListener("click", () => { 332 audio.warning.play(); 333 }); 334 335 validatedElements.defaultBangSelect.addEventListener("bangError", () => { 336 audio.warning.currentTime = 0; 337 audio.warning.play(); 338 }); 339 340 validatedElements.defaultBangSelect.addEventListener("bangSuccess", () => { 341 audio.click.currentTime = 0; 342 audio.click.play(); 343 }); 344 345 validatedElements.closeModal.addEventListener("closed", () => { 346 validatedElements.settingsButton.classList.remove("rotate"); 347 audio.spin.playbackRate = 0.7; 348 audio.spin.currentTime = 0; 349 audio.spin.play(); 350 audio.spin.onended = () => { 351 audio.spin.playbackRate = 1; 352 }; 353 }); 354 355 validatedElements.addBang.addEventListener("click", () => { 356 audio.click.currentTime = 0.1; 357 audio.click.playbackRate = 2; 358 audio.click.play(); 359 }); 360 361 validatedElements.removeBangs.forEach((button) => { 362 button.addEventListener("click", () => { 363 audio.warning.currentTime = 0; 364 audio.warning.play(); 365 }); 366 }); 367 } 368 369 validatedElements.copyButton.addEventListener("click", async () => { 370 await navigator.clipboard.writeText(validatedElements.urlInput.value); 371 validatedElements.copyIcon.src = "/clipboard-check.svg"; 372 373 if (!prefersReducedMotion) 374 validatedElements.copyInput.classList.add("flash-white"); 375 376 setTimeout(() => { 377 validatedElements.copyInput.classList.remove("flash-white"); 378 validatedElements.copyIcon.src = "/clipboard.svg"; 379 }, 375); 380 }); 381 382 validatedElements.settingsButton.addEventListener("click", () => { 383 validatedElements.settingsButton.classList.add("rotate"); 384 validatedElements.modal.style.display = "block"; 385 setOutsideElementsTabindex(validatedElements.modal, -1); 386 }); 387 388 validatedElements.closeModal.addEventListener("click", () => { 389 validatedElements.closeModal.dispatchEvent(new Event("closed")); 390 }); 391 392 window.addEventListener("click", (event) => { 393 if (event.target === validatedElements.modal) { 394 validatedElements.closeModal.dispatchEvent(new Event("closed")); 395 } 396 }); 397 398 validatedElements.closeModal.addEventListener("closed", () => { 399 validatedElements.modal.style.display = "none"; 400 setOutsideElementsTabindex(validatedElements.modal, 0); 401 402 if (validatedElements.historyToggle.checked !== historyEnabled) 403 if (!prefersReducedMotion) 404 setTimeout(() => { 405 window.location.reload(); 406 }, 300); 407 else window.location.reload(); 408 }); 409 410 validatedElements.defaultBangSelect.addEventListener("change", (event) => { 411 const newDefaultBang = (event.target as HTMLSelectElement).value.replace( 412 /^!+/, 413 "", 414 ); 415 const bang = customBangs[newDefaultBang] || bangs[newDefaultBang]; 416 417 if (!bang) { 418 validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG; 419 validatedElements.defaultBangSelect.classList.add("shake", "flash-red"); 420 validatedElements.defaultBangSelect.dispatchEvent( 421 new CustomEvent("bangError"), 422 ); 423 setTimeout(() => { 424 validatedElements.defaultBangSelect.classList.remove( 425 "shake", 426 "flash-red", 427 ); 428 }, 300); 429 return; 430 } 431 432 validatedElements.defaultBangSelect.dispatchEvent( 433 new CustomEvent("bangSuccess"), 434 ); 435 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang); 436 validatedElements.description.innerText = "Default Bang: " + bang.s; 437 }); 438 439 validatedElements.historyToggle.addEventListener("change", (event) => { 440 storage.set( 441 CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED, 442 (event.target as HTMLInputElement).checked.toString(), 443 ); 444 }); 445 446 validatedElements.clearHistory.addEventListener("click", () => { 447 clearSearchHistory(); 448 if (!prefersReducedMotion) 449 setTimeout(() => { 450 window.location.reload(); 451 }, 375); 452 else window.location.reload(); 453 }); 454 455 validatedElements.addBang.addEventListener("click", () => { 456 const name = validatedElements.bangName.value.trim(); 457 const shortcut = validatedElements.bangShortcut.value 458 .trim() 459 .replace(/^!+/, ""); 460 const searchUrl = validatedElements.bangSearchUrl.value.trim(); 461 const baseUrl = validatedElements.bangBaseUrl.value.trim(); 462 463 if (!name || !searchUrl || !baseUrl) return; 464 465 customBangs[shortcut] = { 466 t: name, 467 s: shortcut, 468 u: searchUrl, 469 d: baseUrl, 470 r: 0, 471 }; 472 storage.set( 473 CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS, 474 JSON.stringify(customBangs), 475 ); 476 477 if (!prefersReducedMotion) 478 setTimeout(() => { 479 window.location.reload(); 480 }, 375); 481 else window.location.reload(); 482 }); 483 484 validatedElements.removeBangs.forEach((button) => { 485 button.addEventListener("click", (event) => { 486 const shortcut = (event.target as HTMLButtonElement).dataset 487 .shortcut as string; 488 delete customBangs[shortcut]; 489 storage.set( 490 CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS, 491 JSON.stringify(customBangs), 492 ); 493 494 if (!prefersReducedMotion) 495 setTimeout(() => { 496 window.location.reload(); 497 }, 375); 498 else window.location.reload(); 499 }); 500 }); 501} 502 503const LS_DEFAULT_BANG = 504 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG) ?? "ddg"; 505const defaultBang = bangs[LS_DEFAULT_BANG]; 506 507function ensureProtocol(url: string, defaultProtocol = "https://") { 508 try { 509 const parsedUrl = new URL(url); 510 return parsedUrl.href; // If valid, return as is 511 } catch (e) { 512 return `${defaultProtocol}${url}`; 513 } 514} 515 516function getBangredirectUrl() { 517 const url = new URL(window.location.href); 518 const query = url.searchParams.get("q")?.trim() ?? ""; 519 520 switch (url.pathname.replace(/\/$/, "")) { 521 case "": 522 case "/search": { 523 if (!query || query === "!" || query === "!settings") { 524 noSearchDefaultPageRender(); 525 return null; 526 } 527 528 const match = query.toLowerCase().match(/^!(\S+)|!(\S+)$/i); 529 const selectedBang = match 530 ? customBangs[match[1] || match[2]] || bangs[match[1] || match[2]] 531 : defaultBang; 532 const cleanQuery = match 533 ? query.replace(/!\S+\s*|^(\S+!|!\S+)$/i, "").trim() 534 : query; 535 536 // Redirect to base domain if cleanQuery is empty 537 if (!cleanQuery && selectedBang?.d) { 538 return ensureProtocol(selectedBang.d); 539 } 540 541 const redirectUrl = selectedBang?.u.replace( 542 "{{{s}}}", 543 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"), 544 ); 545 546 // Do these operations after determining redirect URL to minimize delay 547 setTimeout(() => { 548 const count = ( 549 Number.parseInt( 550 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0", 551 ) + 1 552 ).toString(); 553 storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count); 554 555 if ( 556 storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true" 557 ) { 558 addToSearchHistory(cleanQuery, { 559 bang: selectedBang?.t || "", 560 name: selectedBang?.s || "", 561 url: selectedBang?.u || "", 562 }); 563 } 564 }, 0); 565 566 return redirectUrl; 567 } 568 default: { 569 notFoundPageRender(); 570 return null; 571 } 572 } 573} 574 575function doRedirect() { 576 const searchUrl = getBangredirectUrl(); 577 if (!searchUrl) return; 578 window.location.replace(searchUrl); 579} 580 581doRedirect();