A fast, local-first "redirection engine" for !bang users with a few extra features ^-^
1import { bangs } from "./bang"; 2import "./global.css"; 3 4function addToSearchHistory( 5 query: string, 6 bang: { bang: string; name: string; url: string }, 7) { 8 const history = getSearchHistory(); 9 history.unshift({ 10 query, 11 bang: bang.bang, 12 name: bang.name, 13 timestamp: Date.now(), 14 }); 15 // Keep only last 500 searches 16 history.splice(500); 17 localStorage.setItem("search-history", JSON.stringify(history)); 18} 19 20function getSearchHistory(): Array<{ 21 query: string; 22 bang: string; 23 name: string; 24 timestamp: number; 25}> { 26 try { 27 return JSON.parse(localStorage.getItem("search-history") || "[]"); 28 } catch { 29 return []; 30 } 31} 32 33function clearSearchHistory() { 34 localStorage.setItem("search-history", "[]"); 35} 36 37function getFocusableElements( 38 root: HTMLElement = document.body, 39): HTMLElement[] { 40 return Array.from( 41 root.querySelectorAll<HTMLElement>( 42 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', 43 ), 44 ); 45} 46 47function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) { 48 const modalElements = getFocusableElements(modal); 49 const allElements = getFocusableElements(); 50 51 for (const element of allElements) { 52 if (!modalElements.includes(element)) { 53 element.setAttribute("tabindex", tabindex.toString()); 54 } 55 } 56} 57 58function noSearchDefaultPageRender() { 59 const searchCount = localStorage.getItem("search-count") || "0"; 60 const historyEnabled = localStorage.getItem("history-enabled") === "true"; 61 const searchHistory = getSearchHistory(); 62 const app = document.querySelector<HTMLDivElement>("#app"); 63 if (!app) throw new Error("App element not found"); 64 app.innerHTML = ` 65 <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 66 <header style="position: absolute; top: 1rem; width: 100%;"> 67 <div style="display: flex; justify-content: space-between; padding: 0 1rem;"> 68 <span>${searchCount} ${searchCount === "1" ? "search" : "searches"}</span> 69 <button class="settings-button"> 70 <img src="/gear.svg" alt="Settings" class="settings" /> 71 </button> 72 </div> 73 </header> 74 <div class="content-container"> 75 <h1>┐( ˘_˘ )┌</h1> 76 <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> 77 <div class="url-container"> 78 <input 79 type="text" 80 class="url-input" 81 value="https://unduck.link?q=%s" 82 readonly 83 /> 84 <button class="copy-button"> 85 <img src="/clipboard.svg" alt="Copy" /> 86 </button> 87 </div> 88 ${ 89 historyEnabled 90 ? ` 91 <h2 style="margin-top: 24px;">Recent Searches</h2> 92 <div style="max-height: 200px; overflow-y: auto; text-align: left;"> 93 ${ 94 searchHistory.length === 0 95 ? `<div style="padding: 8px; text-align: center;">No search history</div>` 96 : searchHistory 97 .map( 98 (search) => ` 99 <div style="padding: 8px; border-bottom: 1px solid var(--border-color);"> 100 <a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a> 101 <span style="float: right; color: var(--text-color-secondary);"> 102 ${new Date(search.timestamp).toLocaleString()} 103 </span> 104 </div> 105 `, 106 ) 107 .join("") 108 } 109 </div> 110 ` 111 : "" 112 } 113 </div> 114 <footer class="footer"> 115 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 116 </footer> 117 <div class="modal" id="settings-modal"> 118 <div class="modal-content"> 119 <button class="close-modal">&times;</button> 120 <h2>Settings</h2> 121 <div class="settings-section"> 122 <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === LS_DEFAULT_BANG)?.s || "Unknown bang"}</label> 123 <div class="bang-select-container"> 124 <input type="text" id="default-bang" class="bang-select" value="${LS_DEFAULT_BANG}"> 125 </div> 126 </div> 127 <div class="settings-section"> 128 <h3>Search History (${searchHistory.length}/500)</h3> 129 <div style="display: flex; justify-content: space-between; align-items: center;"> 130 <label class="switch"> 131 <label for="history-toggle">Enable Search History</label> 132 <input type="checkbox" id="history-toggle" ${historyEnabled ? "checked" : ""}> 133 <span class="slider round"></span> 134 </label> 135 <button class="clear-history">Clear History</button> 136 </div> 137 </div> 138 </div> 139 </div> 140 </div> 141 </div> 142 `; 143 144 const copyInput = app.querySelector<HTMLInputElement>(".url-input"); 145 if (!copyInput) throw new Error("Copy input not found"); 146 const copyButton = app.querySelector<HTMLButtonElement>(".copy-button"); 147 if (!copyButton) throw new Error("Copy button not found"); 148 const copyIcon = copyButton.querySelector("img"); 149 if (!copyIcon) throw new Error("Copy icon not found"); 150 const urlInput = app.querySelector<HTMLInputElement>(".url-input"); 151 if (!urlInput) throw new Error("URL input not found"); 152 const settingsButton = 153 app.querySelector<HTMLButtonElement>(".settings-button"); 154 if (!settingsButton) throw new Error("Settings button not found"); 155 const modal = app.querySelector<HTMLDivElement>("#settings-modal"); 156 if (!modal) throw new Error("Modal not found"); 157 const closeModal = app.querySelector<HTMLSpanElement>(".close-modal"); 158 if (!closeModal) throw new Error("Close modal not found"); 159 const defaultBangSelect = 160 app.querySelector<HTMLSelectElement>("#default-bang"); 161 if (!defaultBangSelect) throw new Error("Default bang select not found"); 162 const description = 163 app.querySelector<HTMLParagraphElement>("#bang-description"); 164 if (!description) throw new Error("Bang description not found"); 165 const historyToggle = app.querySelector<HTMLInputElement>("#history-toggle"); 166 if (!historyToggle) throw new Error("History toggle not found"); 167 const clearHistory = app.querySelector<HTMLButtonElement>(".clear-history"); 168 if (!clearHistory) throw new Error("Clear history button not found"); 169 170 urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`; 171 172 const prefersReducedMotion = window.matchMedia( 173 "(prefers-reduced-motion: reduce)", 174 ).matches; 175 if (!prefersReducedMotion) { 176 const spinAudio = new Audio("/heavier-tick-sprite.mp3"); 177 const toggleOffAudio = new Audio("/toggle-button-off.mp3"); 178 const toggleOnAudio = new Audio("/toggle-button-on.mp3"); 179 const clickAudio = new Audio("/click-button.mp3"); 180 const warningAudio = new Audio("/double-button.mp3"); 181 const copyAudio = new Audio("/foot-switch.mp3"); 182 183 copyButton.addEventListener("click", () => { 184 copyAudio.currentTime = 0; 185 copyAudio.play(); 186 }); 187 188 settingsButton.addEventListener("mouseenter", () => { 189 spinAudio.play(); 190 }); 191 192 settingsButton.addEventListener("mouseleave", () => { 193 spinAudio.pause(); 194 spinAudio.currentTime = 0; 195 }); 196 197 historyToggle.addEventListener("change", () => { 198 if (historyToggle.checked) { 199 toggleOffAudio.pause(); 200 toggleOffAudio.currentTime = 0; 201 toggleOnAudio.currentTime = 0; 202 toggleOnAudio.play(); 203 } else { 204 toggleOnAudio.pause(); 205 toggleOnAudio.currentTime = 0; 206 toggleOffAudio.currentTime = 0; 207 toggleOffAudio.play(); 208 } 209 }); 210 211 clearHistory.addEventListener("click", () => { 212 warningAudio.play(); 213 }); 214 215 defaultBangSelect.addEventListener("bangError", () => { 216 warningAudio.currentTime = 0; 217 warningAudio.play(); 218 }); 219 220 defaultBangSelect.addEventListener("bangSuccess", () => { 221 clickAudio.currentTime = 0; 222 clickAudio.play(); 223 }); 224 225 closeModal.addEventListener("closed", () => { 226 settingsButton.classList.remove("rotate"); 227 spinAudio.playbackRate = 0.7; 228 spinAudio.currentTime = 0; 229 spinAudio.play(); 230 spinAudio.onended = () => { 231 spinAudio.playbackRate = 1; 232 }; 233 }); 234 } 235 236 copyButton.addEventListener("click", async () => { 237 await navigator.clipboard.writeText(urlInput.value); 238 copyIcon.src = "/clipboard-check.svg"; 239 240 if (!prefersReducedMotion) copyInput.classList.add("flash-white"); 241 242 setTimeout(() => { 243 copyInput.classList.remove("flash-white"); 244 copyIcon.src = "/clipboard.svg"; 245 }, 375); 246 }); 247 248 settingsButton.addEventListener("click", () => { 249 settingsButton.classList.add("rotate"); 250 modal.style.display = "block"; 251 setOutsideElementsTabindex(modal, -1); 252 }); 253 254 closeModal.addEventListener("click", () => { 255 closeModal.dispatchEvent(new Event("closed")); 256 modal.style.display = "none"; 257 setOutsideElementsTabindex(modal, 0); 258 }); 259 260 window.addEventListener("click", (event) => { 261 if (event.target === modal) { 262 closeModal.dispatchEvent(new Event("closed")); 263 modal.style.display = "none"; 264 setOutsideElementsTabindex(modal, 0); 265 } 266 }); 267 268 // Save default bang 269 defaultBangSelect.addEventListener("change", (event) => { 270 const newDefaultBang = (event.target as HTMLSelectElement).value; 271 const bang = bangs.find((b) => b.t === newDefaultBang); 272 273 if (!bang) { 274 // Invalid bang entered 275 defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value 276 defaultBangSelect.classList.add("shake", "flash-red"); 277 278 // Dispatch error event 279 defaultBangSelect.dispatchEvent(new CustomEvent("bangError")); 280 281 // Remove animation classes after animation completes 282 setTimeout(() => { 283 defaultBangSelect.classList.remove("shake", "flash-red"); 284 }, 300); 285 286 return; 287 } 288 289 defaultBangSelect.dispatchEvent(new CustomEvent("bangSuccess")); 290 291 localStorage.setItem("default-bang", newDefaultBang); 292 description.innerText = bang.s; 293 }); 294 295 // Enable/disable search history 296 historyToggle.addEventListener("change", (event) => { 297 localStorage.setItem( 298 "history-enabled", 299 (event.target as HTMLInputElement).checked.toString(), 300 ); 301 }); 302 clearHistory.addEventListener("click", () => { 303 clearSearchHistory(); 304 if (!prefersReducedMotion) 305 setTimeout(() => { 306 window.location.reload(); 307 }, 375); 308 else window.location.reload(); 309 }); 310} 311 312const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg"; 313const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG); 314 315function getBangredirectUrl() { 316 const url = new URL(window.location.href); 317 const query = url.searchParams.get("q")?.trim() ?? ""; 318 if (!query) { 319 noSearchDefaultPageRender(); 320 return null; 321 } 322 323 // increment search count 324 const count = ( 325 Number.parseInt(localStorage.getItem("search-count") || "0") + 1 326 ).toString(); 327 localStorage.setItem("search-count", count); 328 329 const match = query.match(/!(\S+)/i); 330 const selectedBang = match 331 ? bangs.find((b) => b.t === match[1].toLowerCase()) 332 : defaultBang; 333 const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query; 334 335 // Add search to history 336 if (localStorage.getItem("history-enabled") === "true") { 337 addToSearchHistory(cleanQuery, { 338 bang: selectedBang?.t || "", 339 name: selectedBang?.s || "", 340 url: selectedBang?.u || "", 341 }); 342 } 343 344 return selectedBang?.u.replace( 345 "{{{s}}}", 346 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"), 347 ); 348} 349 350function doRedirect() { 351 const searchUrl = getBangredirectUrl(); 352 if (!searchUrl) return; 353 window.location.replace(searchUrl); 354} 355 356doRedirect();