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

feat: add custom bangs

Changed files
+186 -9
src
+76 -5
src/global.css
···
}
input {
+
margin: 2px 0px !important;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
···
position: relative;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
-
margin: 15% auto;
+
margin: 5% auto; /* Reduced from 15% to 5% to give more space */
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 500px;
+
max-height: 80vh; /* 80% of viewport height */
+
overflow-y: auto; /* Makes content scrollable */
}
.close-modal {
···
content: "↵";
position: absolute;
right: 10px;
-
top: 15%;
+
top: 18%;
color: var(--text-color-secondary);
pointer-events: none;
font-size: 1.2em;
···
left: 22px;
}
-
.clear-history {
+
.clear-history,
+
.remove-bang {
margin-top: 8px;
padding: 8px 16px;
background-color: var(--bg-color-danger);
···
transition: all 0.2s;
}
-
.clear-history:hover {
+
.remove-bang {
+
padding: 4px 6px;
+
}
+
+
.clear-history:hover,
+
.remove-bang:hover {
+
background-color: var(--bg-color-hover);
+
}
+
+
.clear-history:active,
+
.remove-bang:active {
+
background-color: var(--bg-color-active);
+
transform: scale(0.98);
+
}
+
+
.add-bang {
+
margin-top: 8px;
+
padding: 8px 16px;
+
background-color: var(--text-color);
+
color: var(--bg-color);
+
border-radius: 4px;
+
transition: all 0.2s;
+
}
+
+
.add-bang:hover {
background-color: var(--bg-color-hover);
+
color: var(--text-color-hover);
}
-
.clear-history:active {
+
.add-bang:active {
background-color: var(--bg-color-active);
transform: scale(0.98);
}
+
+
.custom-bang-item {
+
display: flex;
+
flex-direction: column;
+
gap: 8px;
+
padding: 12px;
+
border: 1px solid var(--border-color);
+
border-radius: 4px;
+
margin-top: 8px;
+
}
+
+
.custom-bang-info {
+
width: 100%;
+
border-collapse: collapse;
+
border: 1px solid var(--border-color);
+
}
+
+
.custom-bang-info td {
+
color: var(--text-color-secondary);
+
padding: 8px 16px;
+
border: 1px solid var(--border-color);
+
}
+
+
.custom-bang-info td:nth-child(2) code {
+
padding: 0.3rem;
+
border-radius: 4px;
+
word-break: break-all;
+
background-color: var(--bg-color-secondary);
+
}
+
+
.custom-bang-info td:last-child {
+
border-right: 1px solid var(--border-color);
+
}
+
+
.custom-bang-name {
+
font-weight: 500;
+
}
+
+
.custom-bang-url {
+
word-break: break-all;
+
color: var(--text-color-secondary);
+
}
+110 -4
src/main.ts
···
SEARCH_COUNT: "search-count",
HISTORY_ENABLED: "history-enabled",
DEFAULT_BANG: "default-bang",
+
CUSTOM_BANGS: "custom-bangs",
},
CUTIES: {
NOTFOUND: [
···
DOWN: ["(↓°□°)↓", "(´◕‿◕)↓", "↓(´・ω・)↓"],
},
};
+
const customBangs = JSON.parse(localStorage.getItem("custom-bangs") || "{}");
function getFocusableElements(
root: HTMLElement = document.body,
···
<button class="close-modal">&times;</button>
<h2>Settings</h2>
<div class="settings-section">
-
<label for="default-bang" id="bang-description">${bangs[data.LS_DEFAULT_BANG].s || "Unknown bang"}</label>
+
<h3>Bangs</h3>
+
<label for="default-bang" id="bang-description">Default Bang: ${bangs[data.LS_DEFAULT_BANG].s || "Unknown bang"}</label>
<div class="bang-select-container">
<input type="text" id="default-bang" class="bang-select" value="${data.LS_DEFAULT_BANG}">
</div>
+
<p class="help-text">The best way to add new bangs is by submitting them on <a href="https://duckduckgo.com/newbang" target="_blank">DuckDuckGo</a> but you can also add them below</p>
+
<div style="margin-top: 16px;">
+
<h4>Add Custom Bang</h4>
+
<div class="custom-bang-inputs">
+
<input type="text" placeholder="Bang name" id="bang-name" class="bang-name">
+
<input type="text" placeholder="Shortcut (e.g. !ddg)" id="bang-shortcut" class="bang-shortcut">
+
<input type="text" placeholder="Search URL with {{{s}}}" id="bang-search-url" class="bang-search-url">
+
<input type="text" placeholder="Base domain" id="bang-base-url" class="bang-base-url">
+
<div style="text-align: right;">
+
<button class="add-bang">Add Bang</button>
+
</div>
+
</div>
+
${
+
Object.keys(customBangs).length > 0
+
? `
+
<h4>Your Custom Bangs</h4>
+
<div class="custom-bangs-list">
+
${Object.entries(customBangs)
+
.map(
+
([shortcut, bang]) => `
+
<div class="custom-bang-item">
+
<table class="custom-bang-info">
+
<tr>
+
<td class="custom-bang-name">${bang.t}</td>
+
<td class="custom-bang-shortcut"><code>!${shortcut}</code></td>
+
<td class="custom-bang-base">${bang.d}</td>
+
</tr>
+
</table>
+
<div class="custom-bang-url">${bang.u}</div>
+
<button class="remove-bang" data-shortcut="${shortcut}">Remove</button>
+
</div>
+
`,
+
)
+
.join("")}
+
</div>
+
`
+
: ""
+
}
+
</div>
</div>
<div class="settings-section">
<h3>Search History (${data.searchHistory.length}/500)</h3>
···
description: app.querySelector<HTMLParagraphElement>("#bang-description"),
historyToggle: app.querySelector<HTMLInputElement>("#history-toggle"),
clearHistory: app.querySelector<HTMLButtonElement>(".clear-history"),
+
bangName: app.querySelector<HTMLInputElement>(".bang-name"),
+
bangShortcut: app.querySelector<HTMLInputElement>(".bang-shortcut"),
+
bangSearchUrl: app.querySelector<HTMLInputElement>(".bang-search-url"),
+
bangBaseUrl: app.querySelector<HTMLInputElement>(".bang-base-url"),
+
addBang: app.querySelector<HTMLButtonElement>(".add-bang"),
+
removeBangs: app.querySelectorAll<HTMLButtonElement>(".remove-bang"),
} as const;
// Validate all elements exist
···
audio.spin.playbackRate = 1;
};
});
+
+
validatedElements.addBang.addEventListener("click", () => {
+
audio.click.currentTime = 0.1;
+
audio.click.playbackRate = 2;
+
audio.click.play();
+
});
+
+
validatedElements.removeBangs.forEach((button) => {
+
button.addEventListener("click", () => {
+
audio.warning.currentTime = 0;
+
audio.warning.play();
+
});
+
});
}
validatedElements.copyButton.addEventListener("click", async () => {
···
validatedElements.defaultBangSelect.addEventListener("change", (event) => {
const newDefaultBang = (event.target as HTMLSelectElement).value;
-
const bang = bangs[newDefaultBang];
+
const bang = customBangs[newDefaultBang] || bangs[newDefaultBang];
if (!bang) {
validatedElements.defaultBangSelect.value = LS_DEFAULT_BANG;
···
new CustomEvent("bangSuccess"),
);
storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.DEFAULT_BANG, newDefaultBang);
-
validatedElements.description.innerText = bang.s;
+
validatedElements.description.innerText = "Default Bang: " + bang.s;
});
validatedElements.historyToggle.addEventListener("change", (event) => {
···
}, 375);
else window.location.reload();
});
+
+
validatedElements.addBang.addEventListener("click", () => {
+
const name = validatedElements.bangName.value.trim();
+
const shortcut = validatedElements.bangShortcut.value.trim();
+
const searchUrl = validatedElements.bangSearchUrl.value.trim();
+
const baseUrl = validatedElements.bangBaseUrl.value.trim();
+
+
if (!name || !searchUrl || !baseUrl) return;
+
+
customBangs[shortcut] = {
+
t: name,
+
s: shortcut,
+
u: searchUrl,
+
d: baseUrl,
+
};
+
storage.set(
+
CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS,
+
JSON.stringify(customBangs),
+
);
+
+
if (!prefersReducedMotion)
+
setTimeout(() => {
+
window.location.reload();
+
}, 375);
+
else window.location.reload();
+
});
+
+
validatedElements.removeBangs.forEach((button) => {
+
button.addEventListener("click", (event) => {
+
const shortcut = (event.target as HTMLButtonElement).dataset.shortcut;
+
delete customBangs[shortcut];
+
storage.set(
+
CONSTANTS.LOCAL_STORAGE_KEYS.CUSTOM_BANGS,
+
JSON.stringify(customBangs),
+
);
+
+
if (!prefersReducedMotion)
+
setTimeout(() => {
+
window.location.reload();
+
}, 375);
+
else window.location.reload();
+
});
+
});
}
const LS_DEFAULT_BANG =
···
storage.set(CONSTANTS.LOCAL_STORAGE_KEYS.SEARCH_COUNT, count);
const match = query.toLowerCase().match(/^!(\S+)|!(\S+)$/i);
-
const selectedBang = match ? bangs[match[1] || match[2]] : defaultBang;
+
const selectedBang = match
+
? customBangs[match[1] || match[2]] || bangs[match[1] || match[2]]
+
: defaultBang;
const cleanQuery = match
? query.replace(/!\S+\s*|^(\S+!|!\S+)$/i, "").trim()
: query;