···
import { bangs } from "./bang";
6
+
ANIMATION_DURATION: 375,
7
+
LOCAL_STORAGE_KEYS: {
8
+
SEARCH_HISTORY: "search-history",
9
+
SEARCH_COUNT: "search-count",
10
+
HISTORY_ENABLED: "history-enabled",
11
+
DEFAULT_BANG: "default-bang",
16
+
get: (key: string) => localStorage.getItem(key),
17
+
set: (key: string, value: string) => localStorage.setItem(key, value),
18
+
remove: (key: string) => localStorage.removeItem(key),
21
+
const memoizedGetSearchHistory = (() => {
31
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]",
function addToSearchHistory(
bang: { bang: string; name: string; url: string },
8
-
const history = getSearchHistory();
42
+
const history = memoizedGetSearchHistory();
43
+
if (!history) return;
15
-
// Keep only last 500 searches
16
-
history.splice(500);
17
-
localStorage.setItem("search-history", JSON.stringify(history));
51
+
history.splice(CONSTANTS.MAX_HISTORY);
53
+
CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY,
54
+
JSON.stringify(history),
function getSearchHistory(): Array<{
···
27
-
return JSON.parse(localStorage.getItem("search-history") || "[]");
66
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY) || "[]",
function clearSearchHistory() {
34
-
localStorage.setItem("search-history", "[]");
74
+
storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_HISTORY, "[]");
function getFocusableElements(
···
98
+
const createTemplate = (data: {
99
+
searchCount: string;
100
+
historyEnabled: boolean;
101
+
searchHistory: Array<{
107
+
LS_DEFAULT_BANG: string;
109
+
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh;">
110
+
<header style="position: absolute; top: 1rem; width: 100%;">
111
+
<div style="display: flex; justify-content: space-between; padding: 0 1rem;">
112
+
<span>${data.searchCount} ${data.searchCount === "1" ? "search" : "searches"}</span>
113
+
<button class="settings-button">
114
+
<img src="/gear.svg" alt="Settings" class="settings" />
118
+
<div class="content-container">
120
+
<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>
121
+
<div class="url-container">
125
+
value="https://unduck.link?q=%s"
128
+
<button class="copy-button">
129
+
<img src="/clipboard.svg" alt="Copy" />
133
+
data.historyEnabled
135
+
<h2 style="margin-top: 24px;">Recent Searches</h2>
136
+
<div style="max-height: 200px; overflow-y: auto; text-align: left;">
138
+
data.searchHistory.length === 0
139
+
? `<div style="padding: 8px; text-align: center;">No search history</div>`
140
+
: data.searchHistory
143
+
<div style="padding: 8px; border-bottom: 1px solid var(--border-color);">
144
+
<a href="?q=!${search.bang} ${search.query}">${search.name}: ${search.query}</a>
145
+
<span style="float: right; color: var(--text-color-secondary);">
146
+
${new Date(search.timestamp).toLocaleString()}
158
+
<footer class="footer">
159
+
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
161
+
<div class="modal" id="settings-modal">
162
+
<div class="modal-content">
163
+
<button class="close-modal">×</button>
165
+
<div class="settings-section">
166
+
<label for="default-bang" id="bang-description">${bangs.find((b) => b.t === data.LS_DEFAULT_BANG)?.s || "Unknown bang"}</label>
167
+
<div class="bang-select-container">
168
+
<input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}">
171
+
<div class="settings-section">
172
+
<h3>Search History (${data.searchHistory.length}/500)</h3>
173
+
<div style="display: flex; justify-content: space-between; align-items: center;">
174
+
<label class="switch">
175
+
<label for="history-toggle">Enable Search History</label>
176
+
<input type="checkbox" id="history-toggle" ${data.historyEnabled ? "checked" : ""}>
177
+
<span class="slider round"></span>
179
+
<button class="clear-history">Clear History</button>
188
+
const createAudio = (src: string) => {
189
+
const audio = new Audio();
function noSearchDefaultPageRender() {
59
-
const searchCount = localStorage.getItem("search-count") || "0";
60
-
const historyEnabled = localStorage.getItem("history-enabled") === "true";
195
+
const searchCount =
196
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0";
197
+
const historyEnabled =
198
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true";
const searchHistory = getSearchHistory();
const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("App element not found");
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" />
74
-
<div class="content-container">
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">
81
-
value="https://unduck.link?q=%s"
84
-
<button class="copy-button">
85
-
<img src="/clipboard.svg" alt="Copy" />
91
-
<h2 style="margin-top: 24px;">Recent Searches</h2>
92
-
<div style="max-height: 200px; overflow-y: auto; text-align: left;">
94
-
searchHistory.length === 0
95
-
? `<div style="padding: 8px; text-align: center;">No search history</div>`
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()}
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
117
-
<div class="modal" id="settings-modal">
118
-
<div class="modal-content">
119
-
<button class="close-modal">×</button>
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}">
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>
135
-
<button class="clear-history">Clear History</button>
203
+
app.innerHTML = createTemplate({
212
+
copyInput: app.querySelector<HTMLInputElement>(".url-input"),
213
+
copyButton: app.querySelector<HTMLButtonElement>(".copy-button"),
214
+
copyIcon: app.querySelector<HTMLImageElement>(".copy-button img"),
215
+
urlInput: app.querySelector<HTMLInputElement>(".url-input"),
216
+
settingsButton: app.querySelector<HTMLButtonElement>(".settings-button"),
217
+
modal: app.querySelector<HTMLDivElement>("#settings-modal"),
218
+
closeModal: app.querySelector<HTMLSpanElement>(".close-modal"),
219
+
defaultBangSelect: app.querySelector<HTMLSelectElement>("#default-bang"),
220
+
description: app.querySelector<HTMLParagraphElement>("#bang-description"),
221
+
historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"),
222
+
clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"),
225
+
// Validate all elements exist
226
+
for (const [key, element] of Object.entries(elements)) {
227
+
if (!element) throw new Error(`${key} not found`);
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");
230
+
// After validation, we can assert elements are non-null
231
+
const validatedElements = elements as {
232
+
[K in keyof typeof elements]: NonNullable<(typeof elements)[K]>;
170
-
urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
235
+
validatedElements.urlInput.value = `${window.location.protocol}//${window.location.host}?q=%s`;
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
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");
243
+
spin: createAudio("/heavier-tick-sprite.mp3"),
244
+
toggleOff: createAudio("/toggle-button-off.mp3"),
245
+
toggleOn: createAudio("/toggle-button-on.mp3"),
246
+
click: createAudio("/click-button.mp3"),
247
+
warning: createAudio("/double-button.mp3"),
248
+
copy: createAudio("/foot-switch.mp3"),
183
-
copyButton.addEventListener("click", () => {
184
-
copyAudio.currentTime = 0;
251
+
validatedElements.copyButton.addEventListener("click", () => {
252
+
audio.copy.currentTime = 0;
188
-
settingsButton.addEventListener("mouseenter", () => {
256
+
validatedElements.settingsButton.addEventListener("mouseenter", () => {
192
-
settingsButton.addEventListener("mouseleave", () => {
194
-
spinAudio.currentTime = 0;
260
+
validatedElements.settingsButton.addEventListener("mouseleave", () => {
261
+
audio.spin.pause();
262
+
audio.spin.currentTime = 0;
197
-
historyToggle.addEventListener("change", () => {
198
-
if (historyToggle.checked) {
199
-
toggleOffAudio.pause();
200
-
toggleOffAudio.currentTime = 0;
201
-
toggleOnAudio.currentTime = 0;
202
-
toggleOnAudio.play();
265
+
validatedElements.historyToggle.addEventListener("change", () => {
266
+
if (validatedElements.historyToggle.checked) {
267
+
audio.toggleOff.pause();
268
+
audio.toggleOff.currentTime = 0;
269
+
audio.toggleOn.currentTime = 0;
270
+
audio.toggleOn.play();
204
-
toggleOnAudio.pause();
205
-
toggleOnAudio.currentTime = 0;
206
-
toggleOffAudio.currentTime = 0;
207
-
toggleOffAudio.play();
272
+
audio.toggleOn.pause();
273
+
audio.toggleOn.currentTime = 0;
274
+
audio.toggleOff.currentTime = 0;
275
+
audio.toggleOff.play();
211
-
clearHistory.addEventListener("click", () => {
212
-
warningAudio.play();
279
+
validatedElements.clearHistory.addEventListener("click", () => {
280
+
audio.warning.play();
215
-
defaultBangSelect.addEventListener("bangError", () => {
216
-
warningAudio.currentTime = 0;
217
-
warningAudio.play();
283
+
validatedElements.defaultBangSelect.addEventListener("bangError", () => {
284
+
audio.warning.currentTime = 0;
285
+
audio.warning.play();
220
-
defaultBangSelect.addEventListener("bangSuccess", () => {
221
-
clickAudio.currentTime = 0;
288
+
validatedElements.defaultBangSelect.addEventListener("bangSuccess", () => {
289
+
audio.click.currentTime = 0;
290
+
audio.click.play();
225
-
closeModal.addEventListener("closed", () => {
226
-
settingsButton.classList.remove("rotate");
227
-
spinAudio.playbackRate = 0.7;
228
-
spinAudio.currentTime = 0;
230
-
spinAudio.onended = () => {
231
-
spinAudio.playbackRate = 1;
293
+
validatedElements.closeModal.addEventListener("closed", () => {
294
+
validatedElements.settingsButton.classList.remove("rotate");
295
+
audio.spin.playbackRate = 0.7;
296
+
audio.spin.currentTime = 0;
298
+
audio.spin.onended = () => {
299
+
audio.spin.playbackRate = 1;
236
-
copyButton.addEventListener("click", async () => {
237
-
await navigator.clipboard.writeText(urlInput.value);
238
-
copyIcon.src = "/clipboard-check.svg";
304
+
validatedElements.copyButton.addEventListener("click", async () => {
305
+
await navigator.clipboard.writeText(validatedElements.urlInput.value);
306
+
validatedElements.copyIcon.src = "/clipboard-check.svg";
240
-
if (!prefersReducedMotion) copyInput.classList.add("flash-white");
308
+
if (!prefersReducedMotion)
309
+
validatedElements.copyInput.classList.add("flash-white");
243
-
copyInput.classList.remove("flash-white");
244
-
copyIcon.src = "/clipboard.svg";
312
+
validatedElements.copyInput.classList.remove("flash-white");
313
+
validatedElements.copyIcon.src = "/clipboard.svg";
248
-
settingsButton.addEventListener("click", () => {
249
-
settingsButton.classList.add("rotate");
250
-
modal.style.display = "block";
251
-
setOutsideElementsTabindex(modal, -1);
317
+
validatedElements.settingsButton.addEventListener("click", () => {
318
+
validatedElements.settingsButton.classList.add("rotate");
319
+
validatedElements.modal.style.display = "block";
320
+
setOutsideElementsTabindex(validatedElements.modal, -1);
254
-
closeModal.addEventListener("click", () => {
255
-
closeModal.dispatchEvent(new Event("closed"));
323
+
validatedElements.closeModal.addEventListener("click", () => {
324
+
validatedElements.closeModal.dispatchEvent(new Event("closed"));
window.addEventListener("click", (event) => {
259
-
if (event.target === modal) {
260
-
closeModal.dispatchEvent(new Event("closed"));
328
+
if (event.target === validatedElements.modal) {
329
+
validatedElements.closeModal.dispatchEvent(new Event("closed"));
264
-
closeModal.addEventListener("closed", () => {
265
-
modal.style.display = "none";
266
-
setOutsideElementsTabindex(modal, 0);
333
+
validatedElements.closeModal.addEventListener("closed", () => {
334
+
validatedElements.modal.style.display = "none";
335
+
setOutsideElementsTabindex(validatedElements.modal, 0);
268
-
if (historyToggle.checked !== historyEnabled)
337
+
if (validatedElements.historyToggle.checked !== historyEnabled)
if (!prefersReducedMotion)
window.location.reload();
···
else window.location.reload();
276
-
// Save default bang
277
-
defaultBangSelect.addEventListener("change", (event) => {
345
+
validatedElements.defaultBangSelect.addEventListener("change", (event) => {
const newDefaultBang = (event.target as HTMLSelectElement).value;
const bang = bangs.find((b) => b.t === newDefaultBang);
282
-
// Invalid bang entered
283
-
defaultBangSelect.value = LS_DEFAULT_BANG; // Reset to previous value
284
-
defaultBangSelect.classList.add("shake", "flash-red");
286
-
// Dispatch error event
287
-
defaultBangSelect.dispatchEvent(new CustomEvent("bangError"));
289
-
// Remove animation classes after animation completes
350
+
validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG;
351
+
validatedElements.defaultBangSelect.classList.add("shake", "flash-red");
352
+
validatedElements.defaultBangSelect.dispatchEvent(
353
+
new CustomEvent("bangError"),
291
-
defaultBangSelect.classList.remove("shake", "flash-red");
356
+
validatedElements.defaultBangSelect.classList.remove(
297
-
defaultBangSelect.dispatchEvent(new CustomEvent("bangSuccess"));
299
-
localStorage.setItem("default-bang", newDefaultBang);
300
-
description.innerText = bang.s;
364
+
validatedElements.defaultBangSelect.dispatchEvent(
365
+
new CustomEvent("bangSuccess"),
367
+
storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang);
368
+
validatedElements.description.innerText = bang.s;
303
-
// Enable/disable search history
304
-
historyToggle.addEventListener("change", (event) => {
305
-
localStorage.setItem(
371
+
validatedElements.historyToggle.addEventListener("change", (event) => {
373
+
CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED,
(event.target as HTMLInputElement).checked.toString(),
310
-
clearHistory.addEventListener("click", () => {
378
+
validatedElements.clearHistory.addEventListener("click", () => {
if (!prefersReducedMotion)
···
320
-
const LS_DEFAULT_BANG = localStorage.getItem("default-bang") ?? "ddg";
388
+
const LS_DEFAULT_BANG =
389
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG) ?? "ddg";
const defaultBang = bangs.find((b) => b.t === LS_DEFAULT_BANG);
function getBangredirectUrl() {
···
331
-
// increment search count
333
-
Number.parseInt(localStorage.getItem("search-count") || "0") + 1
402
+
storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT) || "0",
335
-
localStorage.setItem("search-count", count);
405
+
storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count);
const match = query.match(/!(\S+)/i);
const selectedBang = match
···
const cleanQuery = match ? query.replace(/!\S+\s*/i, "").trim() : query;
343
-
// Add search to history
344
-
if (localStorage.getItem("history-enabled") === "true") {
413
+
if (storage.get(CONSTANTS.LOCAL_STORAGE_KEYS.HISTORY_ENABLED) === "true") {
addToSearchHistory(cleanQuery, {
bang: selectedBang?.t || "",
name: selectedBang?.s || "",