813 lines
22 KiB
HTML
813 lines
22 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>network tools</title>
|
|
<style>
|
|
:root{
|
|
--bg:#000;
|
|
--panel:#000;
|
|
--fg:#f5f5f5;
|
|
--muted:#8a8a8a;
|
|
--grid:#1a1a1a;
|
|
--green:#7CFFB2;
|
|
--red:#FF7A7A;
|
|
--amber:#FFD166;
|
|
--cyan:#8BE9FD;
|
|
--shadow:none;
|
|
}
|
|
|
|
*{box-sizing:border-box}
|
|
|
|
html{
|
|
background:var(--bg);
|
|
-webkit-text-size-adjust:100%;
|
|
text-size-adjust:100%;
|
|
}
|
|
|
|
body{
|
|
margin:0;
|
|
background:var(--bg);
|
|
color:var(--fg);
|
|
font:14px/1.5 "SF Mono","Cascadia Code","Roboto Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
-webkit-font-smoothing:antialiased;
|
|
text-rendering:optimizeLegibility;
|
|
}
|
|
|
|
.mono,.num,.pill,.bar,.controls input,.controls select,.controls button,.weight{
|
|
font-family:"SF Mono","Cascadia Code","Roboto Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
}
|
|
|
|
.a2{border:0 solid transparent!important;border:0}
|
|
.wrap{max-width:2500px;margin:auto;padding:14px}
|
|
|
|
.head{
|
|
position:sticky;
|
|
top:0;
|
|
z-index:20;
|
|
padding:10px 0;
|
|
background:rgba(0,0,0,.98);
|
|
backdrop-filter:blur(10px);
|
|
border-bottom:1px solid var(--grid);
|
|
}
|
|
|
|
.controls{
|
|
display:flex;
|
|
gap:8px;
|
|
align-items:center;
|
|
flex-wrap:wrap;
|
|
}
|
|
|
|
.controls input,
|
|
.controls select,
|
|
.controls button{
|
|
border:1px solid var(--grid);
|
|
background:#000;
|
|
color:var(--fg);
|
|
border-radius:0;
|
|
padding:8px 10px;
|
|
font-size:12px;
|
|
outline:none;
|
|
}
|
|
|
|
.controls input{width:min(345px,100%)}
|
|
.controls select{width:185px}
|
|
|
|
.controls button{
|
|
width:auto;
|
|
cursor:pointer;
|
|
transition:.15s;
|
|
}
|
|
|
|
.controls button:hover,
|
|
.controls button.active{
|
|
background:#03110a;
|
|
border-color:var(--green);
|
|
color:var(--green);
|
|
}
|
|
|
|
.controls button.reset{color:var(--amber)}
|
|
.controls button.weights-btn{color:var(--cyan)}
|
|
|
|
.legend{
|
|
display:flex;
|
|
gap:8px;
|
|
flex-wrap:wrap;
|
|
margin-top:8px;
|
|
color:var(--muted);
|
|
font-size:11px;
|
|
}
|
|
|
|
.pill{
|
|
display:inline-block;
|
|
border:1px solid var(--grid);
|
|
border-radius:0;
|
|
padding:1px 6px;
|
|
color:var(--fg);
|
|
font-size:10px;
|
|
white-space:nowrap;
|
|
background:#000;
|
|
}
|
|
|
|
.weights-drawer{
|
|
max-height:0;
|
|
overflow:hidden;
|
|
opacity:0;
|
|
transition:max-height .28s ease,opacity .2s ease,margin-top .2s ease;
|
|
margin-top:0;
|
|
}
|
|
|
|
.weights-drawer.open{
|
|
max-height:170px;
|
|
opacity:1;
|
|
margin-top:9px;
|
|
}
|
|
|
|
.weights-inner{
|
|
display:flex;
|
|
gap:7px;
|
|
align-items:center;
|
|
flex-wrap:wrap;
|
|
padding:8px;
|
|
background:#000;
|
|
border:1px solid var(--grid);
|
|
border-radius:0;
|
|
}
|
|
|
|
.weight-title{
|
|
color:var(--cyan);
|
|
font:11px "SF Mono","Cascadia Code",ui-monospace,monospace;
|
|
}
|
|
|
|
.weight{
|
|
display:grid;
|
|
grid-template-columns:auto 90px 34px;
|
|
align-items:center;
|
|
gap:6px;
|
|
font-size:11px;
|
|
color:var(--muted);
|
|
}
|
|
|
|
.weight input{
|
|
width:90px;
|
|
accent-color:var(--green);
|
|
}
|
|
|
|
.weight output{
|
|
color:var(--green);
|
|
text-align:right;
|
|
}
|
|
|
|
.meta{
|
|
display:flex;
|
|
gap:16px;
|
|
flex-wrap:wrap;
|
|
margin:12px 0;
|
|
color:var(--muted);
|
|
font-size:12px;
|
|
}
|
|
|
|
.meta b{color:var(--fg)}
|
|
|
|
.section-title{
|
|
margin:16px 0 7px;
|
|
font-size:12px;
|
|
letter-spacing:.055em;
|
|
text-transform:uppercase;
|
|
color:var(--green);
|
|
}
|
|
|
|
.table-box{
|
|
overflow:auto;
|
|
border-radius:0;
|
|
background:var(--panel);
|
|
box-shadow:none;
|
|
border:1px solid var(--grid);
|
|
}
|
|
|
|
table{
|
|
width:100%;
|
|
border-collapse:collapse;
|
|
table-layout:fixed;
|
|
min-width:1980px;
|
|
}
|
|
|
|
.table-box thead th{
|
|
position:sticky;
|
|
top:0;
|
|
z-index:5;
|
|
background:#050505;
|
|
color:#e8e8e8;
|
|
font-size:11px;
|
|
letter-spacing:.035em;
|
|
text-transform:uppercase;
|
|
}
|
|
|
|
th,td{
|
|
padding:9px 10px;
|
|
text-align:left;
|
|
vertical-align:top;
|
|
word-break:break-word;
|
|
border-bottom:1px solid #0f0f0f;
|
|
}
|
|
|
|
tbody tr:nth-child(even){background:rgba(255,255,255,.01)}
|
|
tbody tr:hover{background:rgba(255,255,255,.03)}
|
|
|
|
th.sortable{cursor:pointer;user-select:none}
|
|
th.sortable:hover{color:var(--green)}
|
|
|
|
.sort-mark{
|
|
font-size:9px;
|
|
color:var(--green);
|
|
margin-left:3px;
|
|
}
|
|
|
|
.tool{
|
|
font-weight:550;
|
|
color:var(--fg);
|
|
letter-spacing:.005em;
|
|
}
|
|
|
|
.stack{color:var(--cyan)}
|
|
|
|
.star{
|
|
color:var(--amber);
|
|
font-size:15px;
|
|
margin-right:4px;
|
|
text-shadow:0 0 6px rgba(255,209,102,.18);
|
|
}
|
|
|
|
.tool-link{
|
|
color:inherit;
|
|
text-decoration:none;
|
|
}
|
|
|
|
.tool-link:hover{
|
|
color:var(--cyan);
|
|
text-decoration:underline;
|
|
}
|
|
|
|
.num{
|
|
font-size:11px;
|
|
white-space:nowrap;
|
|
}
|
|
|
|
.bar{
|
|
height:8px;
|
|
min-width:84px;
|
|
margin-top:4px;
|
|
overflow:hidden;
|
|
border:1px solid #262626;
|
|
border-radius:999px;
|
|
background:#0a0a0a;
|
|
}
|
|
|
|
.fill{display:block;height:100%;background:#666}
|
|
.fill.red{background:var(--red)}
|
|
.fill.amber{background:var(--amber)}
|
|
.fill.cyan{background:var(--cyan)}
|
|
|
|
.top-row{}
|
|
.low-row{background:rgba(255,122,255,.055)!important}
|
|
|
|
.rank{
|
|
font-weight:500;
|
|
color:var(--amber);
|
|
}
|
|
|
|
.footer{
|
|
margin:12px 0 3px;
|
|
color:var(--muted);
|
|
font-size:11px;
|
|
}
|
|
|
|
@media(max-width:850px){
|
|
.head{position:relative}
|
|
.controls input,.controls select{width:100%}
|
|
.weights-inner{display:grid;grid-template-columns:1fr}
|
|
.weight{grid-template-columns:auto 1fr 34px}
|
|
.weight input{width:100%}
|
|
.wrap{padding:9px}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="wrap">
|
|
<header class="head">
|
|
<div class="controls">
|
|
<input id="search" type="search" placeholder="search tools, category, features...">
|
|
<select id="category"><option value="">all groups</option></select>
|
|
<button id="toolsOnly">only tools</button>
|
|
|
|
<button data-sort="overall" class="active">overall</button>
|
|
<button data-sort="functionality">functionality</button>
|
|
<button data-sort="difficulty">setup difficulty</button>
|
|
<button data-sort="privacy">privacy</button>
|
|
<button data-sort="stealth">stealth</button>
|
|
<button data-sort="security">security</button>
|
|
<button data-sort="maturity">maturity</button>
|
|
|
|
<button id="weightsToggle" class="weights-btn">weights</button>
|
|
<button id="resetFilters" class="reset">reset</button>
|
|
</div>
|
|
|
|
<div id="weightsDrawer" class="weights-drawer">
|
|
<div class="weights-inner">
|
|
<span class="weight-title">score weights</span>
|
|
<label class="weight">functionality <input id="wf" type="range" min="0" max="100" value="25"><output id="of">25%</output></label>
|
|
<label class="weight">privacy <input id="wp" type="range" min="0" max="100" value="20"><output id="op">20%</output></label>
|
|
<label class="weight">security <input id="ws" type="range" min="0" max="100" value="20"><output id="os">20%</output></label>
|
|
<label class="weight">stealth <input id="wt" type="range" min="0" max="100" value="20"><output id="ot">20%</output></label>
|
|
<label class="weight">maturity <input id="wm" type="range" min="0" max="100" value="15"><output id="om">15%</output></label>
|
|
<button id="resetWeights" class="reset">reset weights</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<span class="pill a2">★ high overall score</span>
|
|
<span class="pill a2">green = functionality / top score</span>
|
|
<span class="pill a2">red = security / lowest score</span>
|
|
<span class="pill a2">cyan = privacy</span>
|
|
<span class="pill a2">amber = stealth</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="meta">
|
|
<span>shown: <b id="shown">0</b></span>
|
|
<span>total: <b id="total">0</b></span>
|
|
<span>sort: <b id="sortLabel">overall descending</b></span>
|
|
<span>weights: <b id="weightLabel">f25 p20 s20 t20 m15</b></span>
|
|
</div>
|
|
|
|
<h2 class="section-title"></h2>
|
|
<div class="table-box">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style="width:4%">rank</th>
|
|
<th style="width:18%">tool / stack</th>
|
|
<th style="width:13%">group</th>
|
|
<th style="width:29%">usage / notes</th>
|
|
<th style="width:11%">overall</th>
|
|
<th style="width:8%">privacy</th>
|
|
<th style="width:8%">stealth</th>
|
|
<th style="width:9%">security</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="topRows"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2 class="section-title"></h2>
|
|
<div class="table-box">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-sort="name" style="width:12%">tool / stack <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="category" style="width:12%">group <span class="sort-mark"></span></th>
|
|
<th style="width:21%">usage / notes</th>
|
|
<th class="sortable" data-sort="overall" style="width:11%">overall <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="functionality" style="width:11%">functionality <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="difficulty" style="width:11%">setup difficulty <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="privacy" style="width:11%">privacy <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="stealth" style="width:11%">stealth <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="security" style="width:11%">security <span class="sort-mark"></span></th>
|
|
<th class="sortable" data-sort="maturity" style="width:11%">maturity <span class="sort-mark"></span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p class="footer">
|
|
</p>
|
|
</main>
|
|
|
|
<script>
|
|
const defaults = {
|
|
functionality: 25,
|
|
privacy: 20,
|
|
security: 20,
|
|
stealth: 20,
|
|
maturity: 15
|
|
};
|
|
|
|
const weights = { ...defaults };
|
|
|
|
const state = {
|
|
toolsOnly: false,
|
|
sort: "overall",
|
|
direction: "desc"
|
|
};
|
|
|
|
let data = [];
|
|
let timer = null;
|
|
|
|
const rows = document.getElementById("rows");
|
|
const topRows = document.getElementById("topRows");
|
|
const search = document.getElementById("search");
|
|
const category = document.getElementById("category");
|
|
const shown = document.getElementById("shown");
|
|
const total = document.getElementById("total");
|
|
const sortLabel = document.getElementById("sortLabel");
|
|
const weightLabel = document.getElementById("weightLabel");
|
|
const drawer = document.getElementById("weightsDrawer");
|
|
const weightsToggle = document.getElementById("weightsToggle");
|
|
const toolsOnly = document.getElementById("toolsOnly");
|
|
const resetWeightsBtn = document.getElementById("resetWeights");
|
|
const resetFiltersBtn = document.getElementById("resetFilters");
|
|
|
|
function normalize(raw) {
|
|
const mapped = raw.map(item => {
|
|
if (Array.isArray(item)) {
|
|
const [
|
|
name,
|
|
category,
|
|
usage,
|
|
official,
|
|
repo,
|
|
functionality,
|
|
difficulty,
|
|
privacy,
|
|
stealth,
|
|
security,
|
|
maturity
|
|
] = item;
|
|
|
|
return {
|
|
name,
|
|
category,
|
|
usage,
|
|
official,
|
|
repo,
|
|
functionality: Number(functionality) || 0,
|
|
difficulty: Number(difficulty) || 0,
|
|
privacy: Number(privacy) || 0,
|
|
stealth: Number(stealth) || 0,
|
|
security: Number(security) || 0,
|
|
maturity: Number(maturity) || 0,
|
|
overall: 0,
|
|
stack: category === "composed stacks"
|
|
};
|
|
}
|
|
|
|
return {
|
|
name: item.name || "",
|
|
category: item.category || "",
|
|
usage: item.usage || "",
|
|
official: item.official || "",
|
|
repo: item.repo || "",
|
|
functionality: Number(item.functionality) || 0,
|
|
difficulty: Number(item.difficulty) || 0,
|
|
privacy: Number(item.privacy) || 0,
|
|
stealth: Number(item.stealth) || 0,
|
|
security: Number(item.security) || 0,
|
|
maturity: Number(item.maturity) || 0,
|
|
overall: 0,
|
|
stack: item.category === "composed stacks"
|
|
};
|
|
});
|
|
|
|
const seen = new Set();
|
|
|
|
return mapped.filter(tool => {
|
|
const key = [
|
|
tool.name.trim().toLowerCase(),
|
|
tool.category.trim().toLowerCase(),
|
|
(tool.repo || tool.official || "").trim().toLowerCase()
|
|
].join("__");
|
|
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function calc(tool) {
|
|
let sum =
|
|
weights.functionality +
|
|
weights.privacy +
|
|
weights.security +
|
|
weights.stealth +
|
|
weights.maturity;
|
|
|
|
if (!sum) {
|
|
Object.assign(weights, defaults);
|
|
sum = 100;
|
|
}
|
|
|
|
return +(
|
|
(
|
|
tool.functionality * weights.functionality +
|
|
tool.privacy * weights.privacy +
|
|
tool.security * weights.security +
|
|
tool.stealth * weights.stealth +
|
|
tool.maturity * weights.maturity
|
|
) / sum
|
|
).toFixed(1);
|
|
}
|
|
|
|
function bar(value, type) {
|
|
const colorClass =
|
|
type === "difficulty" || type === "security"
|
|
? "red"
|
|
: type === "privacy"
|
|
? "cyan"
|
|
: type === "stealth"
|
|
? "amber"
|
|
: "";
|
|
|
|
const safe = Math.max(0, Math.min(100, Number(value) || 0));
|
|
|
|
return `
|
|
<div class="num">${safe}/100</div>
|
|
<div class="bar" title="${safe}/100">
|
|
<span class="fill ${colorClass}" style="width:${safe}%"></span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function getFiltered() {
|
|
const q = search.value.trim().toLowerCase();
|
|
const selectedCategory = category.value;
|
|
|
|
return data.filter(tool => {
|
|
const haystack = `${tool.name} ${tool.category} ${tool.usage}`.toLowerCase();
|
|
return (
|
|
(!q || haystack.includes(q)) &&
|
|
(!selectedCategory || tool.category === selectedCategory) &&
|
|
(!state.toolsOnly || !tool.stack)
|
|
);
|
|
});
|
|
}
|
|
|
|
function compare(a, b, key = state.sort, dir = state.direction) {
|
|
let x = a[key];
|
|
let y = b[key];
|
|
|
|
if (typeof x === "string") {
|
|
x = x.toLowerCase();
|
|
y = y.toLowerCase();
|
|
}
|
|
|
|
if (x < y) return dir === "asc" ? -1 : 1;
|
|
if (x > y) return dir === "asc" ? 1 : -1;
|
|
|
|
return b.overall - a.overall || a.name.localeCompare(b.name);
|
|
}
|
|
|
|
function nameCell(tool) {
|
|
const star = tool.overall >= 88
|
|
? `<span class="star" title="high composite score">★</span>`
|
|
: "";
|
|
|
|
const href = tool.repo || tool.official || "#";
|
|
const cls = tool.stack ? "tool stack" : "tool";
|
|
const title = `open ${tool.repo ? "source repository" : "official site"}`;
|
|
|
|
return `
|
|
<a class="tool-link ${cls}" href="${href}" target="_blank" rel="noopener noreferrer" title="${title}">
|
|
${star}${tool.name} ↗
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
function render() {
|
|
data.forEach(tool => {
|
|
tool.overall = calc(tool);
|
|
});
|
|
|
|
const filtered = getFiltered().sort(compare);
|
|
const ranked = [...filtered].sort((a, b) =>
|
|
b.overall - a.overall ||
|
|
b.security - a.security ||
|
|
a.name.localeCompare(b.name)
|
|
);
|
|
|
|
const hi = ranked.length ? ranked[0].overall : null;
|
|
const lo = ranked.length ? ranked[ranked.length - 1].overall : null;
|
|
|
|
topRows.innerHTML = ranked.length
|
|
? ranked.slice(0, 10).map((tool, i) => `
|
|
<tr class="top-row">
|
|
<td class="rank">${i + 1}</td>
|
|
<td>${nameCell(tool)}</td>
|
|
<td><span class="pill">${tool.category}</span></td>
|
|
<td>${tool.usage}</td>
|
|
<td>${bar(tool.overall, "overall")}</td>
|
|
<td>${bar(tool.privacy, "privacy")}</td>
|
|
<td>${bar(tool.stealth, "stealth")}</td>
|
|
<td>${bar(tool.security, "security")}</td>
|
|
</tr>
|
|
`).join("")
|
|
: `<tr><td colspan="8">no matching entries</td></tr>`;
|
|
|
|
rows.innerHTML = filtered.length
|
|
? filtered.map(tool => {
|
|
const cls =
|
|
tool.overall === hi && hi !== lo ? "top-row" :
|
|
tool.overall === lo && hi !== lo ? "low-row" :
|
|
"";
|
|
|
|
return `
|
|
<tr class="${cls}">
|
|
<td>${nameCell(tool)}</td>
|
|
<td><span class="pill">${tool.category}</span></td>
|
|
<td>${tool.usage}</td>
|
|
<td>${bar(tool.overall, "overall")}</td>
|
|
<td>${bar(tool.functionality, "functionality")}</td>
|
|
<td>${bar(tool.difficulty, "difficulty")}</td>
|
|
<td>${bar(tool.privacy, "privacy")}</td>
|
|
<td>${bar(tool.stealth, "stealth")}</td>
|
|
<td>${bar(tool.security, "security")}</td>
|
|
<td>${bar(tool.maturity, "maturity")}</td>
|
|
</tr>
|
|
`;
|
|
}).join("")
|
|
: `<tr><td colspan="10">no matching entries</td></tr>`;
|
|
|
|
shown.textContent = filtered.length;
|
|
total.textContent = data.length;
|
|
|
|
const labels = {
|
|
overall: "overall",
|
|
functionality: "functionality",
|
|
difficulty: "setup difficulty",
|
|
privacy: "privacy",
|
|
stealth: "stealth",
|
|
security: "security",
|
|
maturity: "maturity",
|
|
name: "name",
|
|
category: "group"
|
|
};
|
|
|
|
sortLabel.textContent = `${labels[state.sort]} ${state.direction === "asc" ? "ascending" : "descending"}`;
|
|
weightLabel.textContent = `f${weights.functionality} p${weights.privacy} s${weights.security} t${weights.stealth} m${weights.maturity}`;
|
|
|
|
document.querySelectorAll(".controls [data-sort]").forEach(btn => {
|
|
btn.classList.toggle("active", btn.dataset.sort === state.sort);
|
|
});
|
|
|
|
document.querySelectorAll("th[data-sort] .sort-mark").forEach(mark => {
|
|
const col = mark.parentElement.dataset.sort;
|
|
mark.textContent = col === state.sort
|
|
? (state.direction === "asc" ? "↑" : "↓")
|
|
: "↕";
|
|
});
|
|
|
|
toolsOnly.classList.toggle("active", state.toolsOnly);
|
|
}
|
|
|
|
function setSort(col) {
|
|
if (state.sort === col) {
|
|
state.direction = state.direction === "asc" ? "desc" : "asc";
|
|
} else {
|
|
state.sort = col;
|
|
state.direction = ["difficulty", "name", "category"].includes(col) ? "asc" : "desc";
|
|
}
|
|
render();
|
|
}
|
|
|
|
function syncWeights() {
|
|
const map = {
|
|
functionality: "f",
|
|
privacy: "p",
|
|
security: "s",
|
|
stealth: "t",
|
|
maturity: "m"
|
|
};
|
|
|
|
Object.entries(map).forEach(([key, id]) => {
|
|
weights[key] = Number(document.getElementById("w" + id).value);
|
|
document.getElementById("o" + id).value = `${weights[key]}%`;
|
|
});
|
|
|
|
clearTimeout(timer);
|
|
timer = setTimeout(render, 120);
|
|
}
|
|
|
|
function resetWeights() {
|
|
Object.assign(weights, defaults);
|
|
|
|
const map = {
|
|
functionality: "f",
|
|
privacy: "p",
|
|
security: "s",
|
|
stealth: "t",
|
|
maturity: "m"
|
|
};
|
|
|
|
Object.entries(map).forEach(([key, id]) => {
|
|
document.getElementById("w" + id).value = weights[key];
|
|
document.getElementById("o" + id).value = `${weights[key]}%`;
|
|
});
|
|
|
|
clearTimeout(timer);
|
|
render();
|
|
}
|
|
|
|
function populateCategories() {
|
|
category.innerHTML = `<option value="">all groups</option>`;
|
|
|
|
[...new Set(data.map(tool => tool.category))]
|
|
.sort()
|
|
.forEach(group => {
|
|
const option = document.createElement("option");
|
|
option.value = group;
|
|
option.textContent = group;
|
|
category.appendChild(option);
|
|
});
|
|
}
|
|
|
|
async function loadData() {
|
|
const candidateInputs = [
|
|
"/tools.json",
|
|
"./tools.json",
|
|
"/static/tools.json"
|
|
];
|
|
|
|
const uniqueCandidates = [];
|
|
const seenUrls = new Set();
|
|
|
|
for (const input of candidateInputs) {
|
|
const absolute = new URL(input, window.location.href).href;
|
|
if (!seenUrls.has(absolute)) {
|
|
seenUrls.add(absolute);
|
|
uniqueCandidates.push({ input, absolute });
|
|
}
|
|
}
|
|
|
|
topRows.innerHTML = `<tr><td colspan="8">loading…</td></tr>`;
|
|
rows.innerHTML = `<tr><td colspan="10">loading…</td></tr>`;
|
|
|
|
let lastError = null;
|
|
|
|
for (const candidate of uniqueCandidates) {
|
|
try {
|
|
console.log("Trying:", candidate.absolute);
|
|
|
|
const response = await fetch(candidate.absolute, {
|
|
cache: "no-store"
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status} for ${response.url}`);
|
|
}
|
|
|
|
const raw = await response.json();
|
|
data = normalize(raw);
|
|
populateCategories();
|
|
render();
|
|
|
|
console.log("Loaded tools.json from:", response.url);
|
|
return;
|
|
} catch (error) {
|
|
console.warn("Failed:", candidate.absolute, error);
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
console.error("All JSON load attempts failed.", lastError);
|
|
topRows.innerHTML = `<tr><td colspan="8">failed to load tools.json</td></tr>`;
|
|
rows.innerHTML = `<tr><td colspan="10">failed to load tools.json</td></tr>`;
|
|
}
|
|
|
|
search.addEventListener("input", render);
|
|
category.addEventListener("change", render);
|
|
|
|
document.querySelectorAll("[data-sort]").forEach(el => {
|
|
el.addEventListener("click", () => setSort(el.dataset.sort));
|
|
});
|
|
|
|
toolsOnly.addEventListener("click", () => {
|
|
state.toolsOnly = !state.toolsOnly;
|
|
render();
|
|
});
|
|
|
|
weightsToggle.addEventListener("click", () => {
|
|
drawer.classList.toggle("open");
|
|
weightsToggle.classList.toggle("active", drawer.classList.contains("open"));
|
|
});
|
|
|
|
["f", "p", "s", "t", "m"].forEach(id => {
|
|
document.getElementById("w" + id).addEventListener("input", syncWeights);
|
|
});
|
|
|
|
resetWeightsBtn.addEventListener("click", resetWeights);
|
|
|
|
resetFiltersBtn.addEventListener("click", () => {
|
|
search.value = "";
|
|
category.value = "";
|
|
state.toolsOnly = false;
|
|
state.sort = "overall";
|
|
state.direction = "desc";
|
|
render();
|
|
});
|
|
|
|
loadData();
|
|
</script>
|
|
</body>
|
|
</html>
|