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

feat: add settings menu

Changed files
+282 -34
public
src
+16
public/gear.svg
···
+
<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">
+
<style>
+
@media (prefers-color-scheme: light) {
+
.lucide-settings {
+
stroke: #1a1a1a;
+
}
+
}
+
@media (prefers-color-scheme: dark) {
+
.lucide-settings {
+
stroke: #e0e0e0;
+
}
+
}
+
</style>
+
<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"/>
+
<circle cx="12" cy="12" r="3"/>
+
</svg>
+135 -2
public/global.css
···
margin: 0;
padding: 0;
box-sizing: border-box;
+
outline: none;
+
}
+
+
*:focus {
+
border: 2px solid var(--text-color-secondary);
}
html,
···
h6 {
font-weight: 600;
line-height: 1.2;
-
padding: 0.75rem;
+
padding-bottom: 0.75rem;
}
a {
···
}
/* Update url-input width to be 100% since container will control max width */
-
.url-input {
+
input {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
···
.footer a:hover {
color: var(--text-color-hover);
}
+
+
/* Add styles for the settings button */
+
.settings-button {
+
padding: 8px;
+
color: var(--text-color-secondary);
+
border-radius: 4px;
+
transition: all 0.2s;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
}
+
+
.settings-button:hover {
+
background: var(--bg-color-hover);
+
}
+
+
.settings-button:hover {
+
background: var(--bg-color-active);
+
}
+
+
.settings-button:hover img {
+
transform: rotate(180deg);
+
transition: transform 0.6s ease;
+
}
+
+
.settings-button:active {
+
transform: scale(0.95);
+
}
+
+
.settings {
+
transition: transform 0.6s ease;
+
}
+
+
.settings-button:not(:hover) .settings {
+
transform: rotate(0deg);
+
}
+
+
.modal {
+
display: none;
+
position: fixed;
+
top: 0;
+
left: 0;
+
width: 100%;
+
height: 100%;
+
background-color: rgba(0, 0, 0, 0.5);
+
z-index: 1000;
+
}
+
+
.modal-content {
+
position: relative;
+
background-color: var(--bg-color);
+
border: 1px solid var(--border-color);
+
margin: 15% auto;
+
padding: 20px;
+
border-radius: 8px;
+
width: 80%;
+
max-width: 500px;
+
}
+
+
.close-modal {
+
position: absolute;
+
right: 10px;
+
top: 10px;
+
cursor: pointer;
+
font-size: 24px;
+
color: var(--text-color-secondary);
+
padding-left: 8px;
+
padding-right: 8px;
+
}
+
+
.bang-select {
+
width: 100%;
+
padding: 8px;
+
margin-top: 10px;
+
border-radius: 4px;
+
}
+
+
.bang-select-container {
+
position: relative;
+
display: inline-block;
+
width: 100%;
+
}
+
+
.bang-select-container::after {
+
content: "↵";
+
position: absolute;
+
right: 10px;
+
top: 33%;
+
color: var(--text-color-secondary);
+
pointer-events: none;
+
font-size: 1.2em;
+
}
+
+
/* Update the bang-select class to account for the icon */
+
.bang-select {
+
padding-right: 30px; /* Make room for the icon */
+
}
+
+
@keyframes shake {
+
0%,
+
100% {
+
transform: translateX(0);
+
}
+
25% {
+
transform: translateX(-5px);
+
}
+
75% {
+
transform: translateX(5px);
+
}
+
}
+
+
@keyframes flash-red {
+
0%,
+
100% {
+
background-color: transparent;
+
}
+
50% {
+
background-color: rgba(255, 0, 0, 0.2);
+
}
+
}
+
+
.shake {
+
animation: shake 0.2s ease-in-out;
+
}
+
+
.flash-red {
+
animation: flash-red 0.3s ease-in-out;
+
}
public/heavier-tick-sprite.mp3

This is a binary file and will not be displayed.

+131 -32
src/main.ts
···
import { bangs } from "./bang";
+
function getFocusableElements(
+
root: HTMLElement = document.body,
+
): HTMLElement[] {
+
return Array.from(
+
root.querySelectorAll<HTMLElement>(
+
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
+
),
+
);
+
}
+
+
function setOutsideElementsTabindex(modal: HTMLElement, tabindex: number) {
+
const modalElements = getFocusableElements(modal);
+
const allElements = getFocusableElements();
+
+
for (const element of allElements) {
+
if (!modalElements.includes(element)) {
+
element.setAttribute("tabindex", tabindex.toString());
+
}
+
}
+
}
+
function noSearchDefaultPageRender() {
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("App element not found");
app.innerHTML = `
-
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
-
<div class="content-container">
-
<h1>┐( ˘_˘ )┌</h1>
-
<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>
-
<div class="url-container">
-
<input
-
type="text"
-
class="url-input"
-
value="https://unduck.link?q=%s"
-
readonly
-
/>
-
<button class="copy-button">
-
<img src="/clipboard.svg" alt="Copy" />
-
</button>
+
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
+
<header style="position: absolute; top: 1rem; right: 1rem;">
+
<button class="settings-button">
+
<img src="/gear.svg" alt="Settings" class="settings" />
+
</button>
+
</header>
+
<div class="content-container">
+
<h1>┐( ˘_˘ )┌</h1>
+
<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>
+
<div class="url-container">
+
<input
+
type="text"
+
class="url-input"
+
value="https://unduck.link?q=%s"
+
readonly
+
/>
+
<button class="copy-button">
+
<img src="/clipboard.svg" alt="Copy" />
+
</button>
+
</div>
+
</div>
+
<footer class="footer">
+
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
+
</footer>
+
<div class="modal" id="settings-modal">
+
<div class="modal-content">
+
<button class="close-modal">&times;</button>
+
<h2>Settings</h2>
+
<div>
+
<label for="default-bang" id="bang-description">${bangs.find((b) => b.t === LS_DEFAULT_BANG)?.s || "Unknown bang"}</label>
+
<div class="bang-select-container">
+
<input type="text" id="default-bang" class="bang-select" value="${LS_DEFAULT_BANG}">
+
</div>
+
</div>
+
</div>
</div>
</div>
-
<footer class="footer">
-
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
-
</footer>
-
</div>
-
`;
+
</div>
+
`;
const copyButton = app.querySelector<HTMLButtonElement>(".copy-button");
if (!copyButton) throw new Error("Copy button not found");
···
if (!copyIcon) throw new Error("Copy icon not found");
const urlInput = app.querySelector<HTMLInputElement>(".url-input");
if (!urlInput) throw new Error("URL input not found");
+
const settingsButton =
+
app.querySelector<HTMLButtonElement>(".settings-button");
+
if (!settingsButton) throw new Error("Settings button not found");
+
const modal = app.querySelector<HTMLDivElement>("#settings-modal");
+
if (!modal) throw new Error("Modal not found");
+
const closeModal = app.querySelector<HTMLSpanElement>(".close-modal");
+
if (!closeModal) throw new Error("Close modal not found");
+
const defaultBangSelect =
+
app.querySelector<HTMLSelectElement>("#default-bang");
+
if (!defaultBangSelect) throw new Error("Default bang select not found");
+
const description =
+
app.querySelector<HTMLParagraphElement>("#bang-description");
+
if (!description) throw new Error("Bang description not found");
urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
···
copyIcon.src = "/clipboard.svg";
}, 2000);
});
+
+
const prefersReducedMotion = window.matchMedia(
+
"(prefers-reduced-motion: reduce)",
+
).matches;
+
if (!prefersReducedMotion) {
+
const audio = new Audio("/heavier-tick-sprite.mp3");
+
+
settingsButton.addEventListener("mouseenter", () => {
+
audio.play();
+
});
+
+
settingsButton.addEventListener("mouseleave", () => {
+
audio.pause();
+
audio.currentTime = 0;
+
});
+
}
+
+
settingsButton.addEventListener("click", () => {
+
modal.style.display = "block";
+
setOutsideElementsTabindex(modal, -1);
+
});
+
+
closeModal.addEventListener("click", () => {
+
modal.style.display = "none";
+
setOutsideElementsTabindex(modal, 0);
+
});
+
+
window.addEventListener("click", (event) => {
+
if (event.target === modal) {
+
modal.style.display = "none";
+
setOutsideElementsTabindex(modal, 0);
+
}
+
});
+
+
// Save default bang
+
defaultBangSelect.addEventListener("change", (event) => {
+
const newDefaultBang = (event.target as HTMLSelectElement).value;
+
const bang = bangs.find((b) => b.t === newDefaultBang);
+
+
if (!bang) {
+
// Invalid bang entered
+
defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value
+
defaultBangSelect.classList.add("shake", "flash-red");
+
+
// Remove animation classes after animation completes
+
setTimeout(() => {
+
defaultBangSelect.classList.remove("shake", "flash-red");
+
}, 300);
+
+
return;
+
}
+
+
localStorage.setItem("default-bang", newDefaultBang);
+
description.innerText = bang.s;
+
});
}
const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg";
···
}
const match = query.match(/!(\S+)/i);
-
-
const bangCandidate = match?.[1]?.toLowerCase();
-
const selectedBang = bangs.find((b) => b.t === bangCandidate) ?? defaultBang;
-
-
// Remove the first bang from the query
-
const cleanQuery = query.replace(/!\S+\s*/i, "").trim();
+
const selectedBang = match
+
? bangs.find((b) => b.t === match[1].toLowerCase())
+
: defaultBang;
+
const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query;
-
// Format of the url is:
-
// https://www.google.com/search?q={{{s}}}
-
const searchUrl = selectedBang?.u.replace(
+
return selectedBang?.u.replace(
"{{{s}}}",
-
// Replace %2F with / to fix formats like "!ghr+t3dotgg/unduck"
encodeURIComponent(cleanQuery).replace(/%2F/g, "/"),
);
-
if (!searchUrl) return null;
-
-
return searchUrl;
}
function doRedirect() {