A fast, local-first "redirection engine" for !bang users with a few extra features ^-^

feat: add settings menu

+282 -34
+16
public/gear.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings"> 2 + <style> 3 + @media (prefers-color-scheme: light) { 4 + .lucide-settings { 5 + stroke: #1a1a1a; 6 + } 7 + } 8 + @media (prefers-color-scheme: dark) { 9 + .lucide-settings { 10 + stroke: #e0e0e0; 11 + } 12 + } 13 + </style> 14 + <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/> 15 + <circle cx="12" cy="12" r="3"/> 16 + </svg>
+135 -2
public/global.css
··· 56 56 margin: 0; 57 57 padding: 0; 58 58 box-sizing: border-box; 59 + outline: none; 60 + } 61 + 62 + *:focus { 63 + border: 2px solid var(--text-color-secondary); 59 64 } 60 65 61 66 html, ··· 80 85 h6 { 81 86 font-weight: 600; 82 87 line-height: 1.2; 83 - padding: 0.75rem; 88 + padding-bottom: 0.75rem; 84 89 } 85 90 86 91 a { ··· 118 123 } 119 124 120 125 /* Update url-input width to be 100% since container will control max width */ 121 - .url-input { 126 + input { 122 127 padding: 8px 12px; 123 128 border: 1px solid var(--border-color); 124 129 border-radius: 4px; ··· 174 179 .footer a:hover { 175 180 color: var(--text-color-hover); 176 181 } 182 + 183 + /* Add styles for the settings button */ 184 + .settings-button { 185 + padding: 8px; 186 + color: var(--text-color-secondary); 187 + border-radius: 4px; 188 + transition: all 0.2s; 189 + display: flex; 190 + align-items: center; 191 + justify-content: center; 192 + } 193 + 194 + .settings-button:hover { 195 + background: var(--bg-color-hover); 196 + } 197 + 198 + .settings-button:hover { 199 + background: var(--bg-color-active); 200 + } 201 + 202 + .settings-button:hover img { 203 + transform: rotate(180deg); 204 + transition: transform 0.6s ease; 205 + } 206 + 207 + .settings-button:active { 208 + transform: scale(0.95); 209 + } 210 + 211 + .settings { 212 + transition: transform 0.6s ease; 213 + } 214 + 215 + .settings-button:not(:hover) .settings { 216 + transform: rotate(0deg); 217 + } 218 + 219 + .modal { 220 + display: none; 221 + position: fixed; 222 + top: 0; 223 + left: 0; 224 + width: 100%; 225 + height: 100%; 226 + background-color: rgba(0, 0, 0, 0.5); 227 + z-index: 1000; 228 + } 229 + 230 + .modal-content { 231 + position: relative; 232 + background-color: var(--bg-color); 233 + border: 1px solid var(--border-color); 234 + margin: 15% auto; 235 + padding: 20px; 236 + border-radius: 8px; 237 + width: 80%; 238 + max-width: 500px; 239 + } 240 + 241 + .close-modal { 242 + position: absolute; 243 + right: 10px; 244 + top: 10px; 245 + cursor: pointer; 246 + font-size: 24px; 247 + color: var(--text-color-secondary); 248 + padding-left: 8px; 249 + padding-right: 8px; 250 + } 251 + 252 + .bang-select { 253 + width: 100%; 254 + padding: 8px; 255 + margin-top: 10px; 256 + border-radius: 4px; 257 + } 258 + 259 + .bang-select-container { 260 + position: relative; 261 + display: inline-block; 262 + width: 100%; 263 + } 264 + 265 + .bang-select-container::after { 266 + content: "↵"; 267 + position: absolute; 268 + right: 10px; 269 + top: 33%; 270 + color: var(--text-color-secondary); 271 + pointer-events: none; 272 + font-size: 1.2em; 273 + } 274 + 275 + /* Update the bang-select class to account for the icon */ 276 + .bang-select { 277 + padding-right: 30px; /* Make room for the icon */ 278 + } 279 + 280 + @keyframes shake { 281 + 0%, 282 + 100% { 283 + transform: translateX(0); 284 + } 285 + 25% { 286 + transform: translateX(-5px); 287 + } 288 + 75% { 289 + transform: translateX(5px); 290 + } 291 + } 292 + 293 + @keyframes flash-red { 294 + 0%, 295 + 100% { 296 + background-color: transparent; 297 + } 298 + 50% { 299 + background-color: rgba(255, 0, 0, 0.2); 300 + } 301 + } 302 + 303 + .shake { 304 + animation: shake 0.2s ease-in-out; 305 + } 306 + 307 + .flash-red { 308 + animation: flash-red 0.3s ease-in-out; 309 + }
public/heavier-tick-sprite.mp3

This is a binary file and will not be displayed.

+131 -32
src/main.ts
··· 1 1 import { bangs } from "./bang"; 2 2 3 + function getFocusableElements( 4 + root: HTMLElement = document.body, 5 + ): HTMLElement[] { 6 + return Array.from( 7 + root.querySelectorAll<HTMLElement>( 8 + 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', 9 + ), 10 + ); 11 + } 12 + 13 + function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) { 14 + const modalElements = getFocusableElements(modal); 15 + const allElements = getFocusableElements(); 16 + 17 + for (const element of allElements) { 18 + if (!modalElements.includes(element)) { 19 + element.setAttribute("tabindex", tabindex.toString()); 20 + } 21 + } 22 + } 23 + 3 24 function noSearchDefaultPageRender() { 4 25 const app = document.querySelector<HTMLDivElement>("#app"); 5 26 if (!app) throw new Error("App element not found"); 6 27 app.innerHTML = ` 7 - <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 8 - <div class="content-container"> 9 - <h1>┐( ˘_˘ )┌</h1> 10 - <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> 11 - <div class="url-container"> 12 - <input 13 - type="text" 14 - class="url-input" 15 - value="https://unduck.link?q=%s" 16 - readonly 17 - /> 18 - <button class="copy-button"> 19 - <img src="/clipboard.svg" alt="Copy" /> 20 - </button> 28 + <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;"> 29 + <header style="position: absolute; top: 1rem; right: 1rem;"> 30 + <button class="settings-button"> 31 + <img src="/gear.svg" alt="Settings" class="settings" /> 32 + </button> 33 + </header> 34 + <div class="content-container"> 35 + <h1>┐( ˘_˘ )┌</h1> 36 + <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> 37 + <div class="url-container"> 38 + <input 39 + type="text" 40 + class="url-input" 41 + value="https://unduck.link?q=%s" 42 + readonly 43 + /> 44 + <button class="copy-button"> 45 + <img src="/clipboard.svg" alt="Copy" /> 46 + </button> 47 + </div> 48 + </div> 49 + <footer class="footer"> 50 + 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 51 + </footer> 52 + <div class="modal" id="settings-modal"> 53 + <div class="modal-content"> 54 + <button class="close-modal">&times;</button> 55 + <h2>Settings</h2> 56 + <div> 57 + <label for="default-bang" id="bang-description">${bangs.find((b) => b.t === LS_DEFAULT_BANG)?.s || "Unknown bang"}</label> 58 + <div class="bang-select-container"> 59 + <input type="text" id="default-bang" class="bang-select" value="${LS_DEFAULT_BANG}"> 60 + </div> 61 + </div> 62 + </div> 21 63 </div> 22 64 </div> 23 - <footer class="footer"> 24 - 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 25 - </footer> 26 - </div> 27 - `; 65 + </div> 66 + `; 28 67 29 68 const copyButton = app.querySelector<HTMLButtonElement>(".copy-button"); 30 69 if (!copyButton) throw new Error("Copy button not found"); ··· 32 71 if (!copyIcon) throw new Error("Copy icon not found"); 33 72 const urlInput = app.querySelector<HTMLInputElement>(".url-input"); 34 73 if (!urlInput) throw new Error("URL input not found"); 74 + const settingsButton = 75 + app.querySelector<HTMLButtonElement>(".settings-button"); 76 + if (!settingsButton) throw new Error("Settings button not found"); 77 + const modal = app.querySelector<HTMLDivElement>("#settings-modal"); 78 + if (!modal) throw new Error("Modal not found"); 79 + const closeModal = app.querySelector<HTMLSpanElement>(".close-modal"); 80 + if (!closeModal) throw new Error("Close modal not found"); 81 + const defaultBangSelect = 82 + app.querySelector<HTMLSelectElement>("#default-bang"); 83 + if (!defaultBangSelect) throw new Error("Default bang select not found"); 84 + const description = 85 + app.querySelector<HTMLParagraphElement>("#bang-description"); 86 + if (!description) throw new Error("Bang description not found"); 35 87 36 88 urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`; 37 89 ··· 43 95 copyIcon.src = "/clipboard.svg"; 44 96 }, 2000); 45 97 }); 98 + 99 + const prefersReducedMotion = window.matchMedia( 100 + "(prefers-reduced-motion: reduce)", 101 + ).matches; 102 + if (!prefersReducedMotion) { 103 + const audio = new Audio("/heavier-tick-sprite.mp3"); 104 + 105 + settingsButton.addEventListener("mouseenter", () => { 106 + audio.play(); 107 + }); 108 + 109 + settingsButton.addEventListener("mouseleave", () => { 110 + audio.pause(); 111 + audio.currentTime = 0; 112 + }); 113 + } 114 + 115 + settingsButton.addEventListener("click", () => { 116 + modal.style.display = "block"; 117 + setOutsideElementsTabindex(modal, -1); 118 + }); 119 + 120 + closeModal.addEventListener("click", () => { 121 + modal.style.display = "none"; 122 + setOutsideElementsTabindex(modal, 0); 123 + }); 124 + 125 + window.addEventListener("click", (event) => { 126 + if (event.target === modal) { 127 + modal.style.display = "none"; 128 + setOutsideElementsTabindex(modal, 0); 129 + } 130 + }); 131 + 132 + // Save default bang 133 + defaultBangSelect.addEventListener("change", (event) => { 134 + const newDefaultBang = (event.target as HTMLSelectElement).value; 135 + const bang = bangs.find((b) => b.t === newDefaultBang); 136 + 137 + if (!bang) { 138 + // Invalid bang entered 139 + defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value 140 + defaultBangSelect.classList.add("shake", "flash-red"); 141 + 142 + // Remove animation classes after animation completes 143 + setTimeout(() => { 144 + defaultBangSelect.classList.remove("shake", "flash-red"); 145 + }, 300); 146 + 147 + return; 148 + } 149 + 150 + localStorage.setItem("default-bang", newDefaultBang); 151 + description.innerText = bang.s; 152 + }); 46 153 } 47 154 48 155 const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg"; ··· 57 164 } 58 165 59 166 const match = query.match(/!(\S+)/i); 60 - 61 - const bangCandidate = match?.[1]?.toLowerCase(); 62 - const selectedBang = bangs.find((b) => b.t === bangCandidate) ?? defaultBang; 63 - 64 - // Remove the first bang from the query 65 - const cleanQuery = query.replace(/!\S+\s*/i, "").trim(); 167 + const selectedBang = match 168 + ? bangs.find((b) => b.t === match[1].toLowerCase()) 169 + : defaultBang; 170 + const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query; 66 171 67 - // Format of the url is: 68 - // https://www.google.com/search?q={{{s}}} 69 - const searchUrl = selectedBang?.u.replace( 172 + return selectedBang?.u.replace( 70 173 "{{{s}}}", 71 - // Replace %2F with / to fix formats like "!ghr+t3dotgg/unduck" 72 174 encodeURIComponent(cleanQuery).replace(/%2F/g, "/"), 73 175 ); 74 - if (!searchUrl) return null; 75 - 76 - return searchUrl; 77 176 } 78 177 79 178 function doRedirect() {