feat(ui): add client-side search for tools (THE-66)

Adds a debounced text search input above the D3 tree that filters all
~1,000+ tool nodes by name and description. Results appear as a flat
list with breadcrumb paths; clearing the input hides the panel.

- No build step required: vanilla JS, no new dependencies
- Sanitizes HTML output (escapeHtml/escapeAttr) and validates URLs (http/https only)
- Dark and light mode compatible via existing CSS variables
- Results capped at 50 to keep rendering fast

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
s0lray
2026-03-24 17:56:24 -04:00
co-authored by Paperclip
parent c734dc52e6
commit ab65f65a5f
3 changed files with 149 additions and 1 deletions
+72
View File
@@ -105,3 +105,75 @@ h3 {
a {
color: var(--color-node-stroke);
}
/* Search */
#search-container {
text-align: center;
padding: 8px 0 4px;
}
#search-input {
width: 400px;
max-width: 90vw;
padding: 8px 12px;
font-size: 14px;
font-family: "Helvetica Neue", Helvetica;
background: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-link);
border-radius: 4px;
outline: none;
}
#search-input:focus {
border-color: var(--color-node-stroke);
}
#search-results {
margin: 8px auto 0;
width: 600px;
max-width: 90vw;
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-link);
border-radius: 4px;
max-height: 400px;
overflow-y: auto;
display: none;
}
#search-results.visible {
display: block;
}
.search-result-item {
padding: 8px 12px;
border-bottom: 1px solid var(--color-link);
font-size: 13px;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item a {
font-weight: bold;
color: var(--color-node-stroke);
text-decoration: none;
}
.search-result-item a:hover {
text-decoration: underline;
}
.search-result-path {
color: var(--color-text-secondary);
font-size: 11px;
margin-top: 2px;
}
.search-no-results {
padding: 12px;
color: var(--color-text-secondary);
text-align: center;
}
+4
View File
@@ -28,6 +28,10 @@
OSINT Framework
<hr/>
</div>
<div id="search-container">
<input type="text" id="search-input" placeholder="Search tools..." autocomplete="off" aria-label="Search OSINT tools">
<div id="search-results" role="listbox" aria-label="Search results"></div>
</div>
</div>
<script src="js/arf.js"></script>
+73 -1
View File
@@ -3,7 +3,8 @@ var margin = [20, 120, 20, 140],
height = 800 - margin[0] - margin[2],
i = 0,
duration = 1250,
root;
root,
allSearchNodes = [];
var tree = d3.tree()
.size([height, width]);
@@ -29,6 +30,11 @@ d3.json("arf.json").then(function(json) {
root.x0 = height / 2;
root.y0 = 0;
// Collect all tool nodes (those with URLs) for search
allSearchNodes = root.descendants().filter(function(d) {
return d.data.url;
});
function collapse(d) {
if (d.children) {
d._children = d.children;
@@ -39,6 +45,7 @@ d3.json("arf.json").then(function(json) {
root.children.forEach(collapse);
update(root);
initSearch();
});
function update(source) {
@@ -160,6 +167,71 @@ function toggle(d) {
}
}
// Client-side search over all tool nodes.
var searchDebounceTimer = null;
function initSearch() {
var input = document.getElementById("search-input");
if (!input) return;
input.addEventListener("input", function() {
clearTimeout(searchDebounceTimer);
var query = input.value.trim();
searchDebounceTimer = setTimeout(function() { doSearch(query); }, 200);
});
}
function doSearch(query) {
var results = document.getElementById("search-results");
if (!results) return;
if (!query) {
results.innerHTML = "";
results.classList.remove("visible");
return;
}
var lower = query.toLowerCase();
var matches = allSearchNodes.filter(function(d) {
var name = (d.data.name || "").toLowerCase();
var desc = (d.data.description || "").toLowerCase();
return name.indexOf(lower) !== -1 || desc.indexOf(lower) !== -1;
}).slice(0, 50);
results.classList.add("visible");
if (matches.length === 0) {
results.innerHTML = '<div class="search-no-results">No results found for \u201c' + escapeHtml(query) + '\u201d</div>';
return;
}
var html = matches.map(function(d) {
var path = d.ancestors().reverse().slice(1, -1).map(function(a) {
return escapeHtml(a.data.name);
}).join(" \u203a ");
var name = escapeHtml(d.data.name);
var url = safeUrl(d.data.url);
return '<div class="search-result-item">' +
'<a href="' + escapeAttr(url) + '" target="_blank" rel="noopener noreferrer">' + name + '</a>' +
(path ? '<div class="search-result-path">' + path + '</div>' : '') +
'</div>';
}).join("");
results.innerHTML = html;
}
function escapeHtml(str) {
return (str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttr(str) {
return (str || "").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function safeUrl(url) {
if (!url) return "#";
return /^https?:\/\//i.test(url) ? url : "#";
}
// Toggle light/dark mode and persist preference.
function goDark() {
var body = document.body;