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

feat: add opt in search history and more sounds

.github/images/dark-history.webp

This is a binary file and will not be displayed.

.github/images/light-history.webp

This is a binary file and will not be displayed.

+3 -1
README.md
···
- [x] Settings (for things like disabling search history and changing default bang)
- [x] Search counter
- [x] [OpenSearch](https://developer.mozilla.org/en-US/docs/Web/XML/Guides/OpenSearch) support
+
- [x] Search History (clearable, all local, and disabled by default ofc)
- [ ] ~Search suggestions~ (as far as I can tell this essentially impossible to do natively with either firefox or chrome; please correct me if I'm wrong though. In this case I would very much love to be wrong)
-
- [ ] Search History (clearable ofc and all local)
and then some more really ambitious stuff like:
···
### Light Mode
![Light Mode](.github/images/light.webp)
+
![Light Mode with Search History](.github/images/light-history.webp)
### Dark Mode 💪
![Dark Mode](.github/images/dark.webp)
+
![Dark Mode with Search History](.github/images/dark-history.webp)
</details>
public/click-button.mp3

This is a binary file and will not be displayed.

public/double-button.mp3

This is a binary file and will not be displayed.

public/toggle-button-off.mp3

This is a binary file and will not be displayed.

public/toggle-button-on.mp3

This is a binary file and will not be displayed.

+68 -2
src/global.css
···
--bg-color-secondary: #f5f5f5;
--bg-color-hover: #f0f0f0;
--bg-color-active: #e5e5e5;
+
--bg-color-danger: #e9808a;
--border-color: #ddd;
}
···
--bg-color-secondary: #1e1e1e;
--bg-color-hover: #2a2a2a;
--bg-color-active: #333;
+
--bg-color-danger: #f15f6d;
--border-color: #444;
}
}
···
box-sizing: border-box;
outline: none;
}
-
*:focus {
-
border: 2px solid var(--text-color-secondary);
+
outline: 2px solid var(--text-color-secondary);
}
html,
···
.flash-red {
animation: flash-red 0.3s ease-in-out;
}
+
/* Settings section spacing */
+
.settings-section {
+
margin-bottom: 1rem;
+
}
+
+
/* Toggle switch styles */
+
.switch {
+
display: flex;
+
align-items: center;
+
gap: 8px;
+
margin: 8px 0;
+
}
+
+
.switch input {
+
width: 40px;
+
height: 20px;
+
appearance: none;
+
background-color: var(--bg-color-secondary);
+
border-radius: 20px;
+
position: relative;
+
cursor: pointer;
+
transition: background-color 0.3s;
+
}
+
+
.switch input:checked {
+
background-color: var(--text-color);
+
}
+
+
.switch input:before {
+
content: "";
+
width: 16px;
+
height: 16px;
+
background-color: var(--text-color);
+
position: absolute;
+
border-radius: 50%;
+
top: 2px;
+
left: 2px;
+
transition:
+
left 0.3s,
+
background-color 0.3s;
+
}
+
+
.switch input:checked:before {
+
background-color: var(--bg-color-secondary);
+
left: 22px;
+
}
+
+
/* Clear history button */
+
.clear-history {
+
margin-top: 8px;
+
padding: 8px 16px;
+
background-color: var(--bg-color-danger);
+
color: var(--text-color);
+
border-radius: 4px;
+
transition: all 0.2s;
+
}
+
+
.clear-history:hover {
+
background-color: var(--bg-color-hover);
+
}
+
+
.clear-history:active {
+
background-color: var(--bg-color-active);
+
transform: scale(0.98);
+
}
+142 -5
src/main.ts
···
import { bangs } from "./bang";
import "./global.css";
+
function addToSearchHistory(
+
query: string,
+
bang: { bang: string; name: string; url: string },
+
) {
+
const history = getSearchHistory();
+
history.unshift({
+
query,
+
bang: bang.bang,
+
name: bang.name,
+
timestamp: Date.now(),
+
});
+
// Keep only last 500 searches
+
history.splice(500);
+
localStorage.setItem("search-history", JSON.stringify(history));
+
}
+
+
function getSearchHistory(): Array<{
+
query: string;
+
bang: string;
+
name: string;
+
timestamp: number;
+
}> {
+
try {
+
return JSON.parse(localStorage.getItem("search-history") || "[]");
+
} catch {
+
return [];
+
}
+
}
+
+
function clearSearchHistory() {
+
localStorage.setItem("search-history", "[]");
+
}
+
function getFocusableElements(
root: HTMLElement = document.body,
): HTMLElement[] {
···
function noSearchDefaultPageRender() {
const searchCount = localStorage.getItem("search-count") || "0";
+
const historyEnabled = localStorage.getItem("history-enabled") === "true";
+
const searchHistory = getSearchHistory();
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("App element not found");
app.innerHTML = `
···
<img src="/clipboard.svg" alt="Copy" />
</button>
</div>
+
${
+
historyEnabled
+
? `
+
<h2 style="margin-top: 24px;">Recent Searches</h2>
+
<div style="max-height: 200px; overflow-y: auto; text-align: left;">
+
${
+
searchHistory.length === 0
+
? `<div style="padding: 8px; text-align: center;">No search history</div>`
+
: searchHistory
+
.map(
+
(search) => `
+
<div style="padding: 8px; border-bottom: 1px solid var(--border-color);">
+
<a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a>
+
<span style="float: right; color: var(--text-color-secondary);">
+
${new Date(search.timestamp).toLocaleString()}
+
</span>
+
</div>
+
`,
+
)
+
.join("")
+
}
+
</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
···
<div class="modal-content">
<button class="close-modal">&times;</button>
<h2>Settings</h2>
-
<div>
+
<div class="settings-section">
<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 class="settings-section">
+
<h3>Search History (${searchHistory.length}/500)</h3>
+
<div style="display: flex; justify-content: space-between; align-items: center;">
+
<label class="switch">
+
<label for="history-toggle">Enable Search History</label>
+
<input type="checkbox" id="history-toggle" ${historyEnabled ? "checked" : ""}>
+
<span class="slider round"></span>
+
</label>
+
<button class="clear-history">Clear History</button>
+
</div>
+
</div>
</div>
</div>
</div>
···
const description =
app.querySelector<HTMLParagraphElement>("#bang-description");
if (!description) throw new Error("Bang description not found");
+
const historyToggle = app.querySelector<HTMLInputElement>("#history-toggle");
+
if (!historyToggle) throw new Error("History toggle not found");
+
const clearHistory = app.querySelector<HTMLButtonElement>(".clear-history");
+
if (!clearHistory) throw new Error("Clear history button not found");
urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
···
"(prefers-reduced-motion: reduce)",
).matches;
if (!prefersReducedMotion) {
-
const audio = new Audio("/heavier-tick-sprite.mp3");
+
const spinAudio = new Audio("/heavier-tick-sprite.mp3");
+
const toggleOffAudio = new Audio("/toggle-button-off.mp3");
+
const toggleOnAudio = new Audio("/toggle-button-on.mp3");
+
const clickAudio = new Audio("/click-button.mp3");
+
const warningAudio = new Audio("/double-button.mp3");
settingsButton.addEventListener("mouseenter", () => {
-
audio.play();
+
spinAudio.play();
});
settingsButton.addEventListener("mouseleave", () => {
-
audio.pause();
-
audio.currentTime = 0;
+
spinAudio.pause();
+
spinAudio.currentTime = 0;
+
});
+
+
historyToggle.addEventListener("change", () => {
+
if (historyToggle.checked) {
+
toggleOffAudio.pause();
+
toggleOffAudio.currentTime = 0;
+
toggleOnAudio.currentTime = 0;
+
toggleOnAudio.play();
+
} else {
+
toggleOnAudio.pause();
+
toggleOnAudio.currentTime = 0;
+
toggleOffAudio.currentTime = 0;
+
toggleOffAudio.play();
+
}
+
});
+
+
clearHistory.addEventListener("click", () => {
+
warningAudio.play();
+
});
+
+
defaultBangSelect.addEventListener("bangError", () => {
+
warningAudio.currentTime = 0;
+
warningAudio.play();
+
});
+
+
defaultBangSelect.addEventListener("bangSuccess", () => {
+
clickAudio.currentTime = 0;
+
clickAudio.play();
});
}
···
defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value
defaultBangSelect.classList.add("shake", "flash-red");
+
// Dispatch error event
+
defaultBangSelect.dispatchEvent(new CustomEvent("bangError"));
+
// Remove animation classes after animation completes
setTimeout(() => {
defaultBangSelect.classList.remove("shake", "flash-red");
···
return;
}
+
defaultBangSelect.dispatchEvent(new CustomEvent("bangSuccess"));
+
localStorage.setItem("default-bang", newDefaultBang);
description.innerText = bang.s;
});
+
+
// Enable/disable search history
+
historyToggle.addEventListener("change", (event) => {
+
localStorage.setItem(
+
"history-enabled",
+
(event.target as HTMLInputElement).checked.toString(),
+
);
+
});
+
clearHistory.addEventListener("click", () => {
+
clearSearchHistory();
+
if (!prefersReducedMotion)
+
setTimeout(() => {
+
window.location.reload();
+
}, 375);
+
else window.location.reload();
+
});
}
const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg";
···
? bangs.find((b) => b.t === match[1].toLowerCase())
: defaultBang;
const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query;
+
+
// Add search to history
+
if (localStorage.getItem("history-enabled") === "true") {
+
addToSearchHistory(cleanQuery, {
+
bang: selectedBang?.t || "",
+
name: selectedBang?.s || "",
+
url: selectedBang?.u || "",
+
});
+
}
return selectedBang?.u.replace(
"{{{s}}}",