2026-03-22 21:14:50 +00:00
|
|
|
|
/* awesome-quant – search, filter, sort, expand */
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
|
|
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
|
|
|
|
|
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
|
|
|
|
|
|
|
|
|
|
|
|
const searchInput = $("#search");
|
|
|
|
|
|
const filterBar = $("#filter-bar");
|
|
|
|
|
|
const filterValue = $("#filter-value");
|
|
|
|
|
|
const filterClear = $("#filter-clear");
|
|
|
|
|
|
const noResults = $("#no-results");
|
|
|
|
|
|
const resultsCount = $("#results-count");
|
|
|
|
|
|
const tableBody = $("tbody", $("#project-table"));
|
|
|
|
|
|
const sortHeaders = $$("th[data-sort]");
|
|
|
|
|
|
|
|
|
|
|
|
let activeFilter = { type: "", value: "" };
|
|
|
|
|
|
let currentSort = { key: "", dir: "" };
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Theme =====
|
|
|
|
|
|
const themeToggle = $(".theme-toggle");
|
|
|
|
|
|
|
|
|
|
|
|
function getPreferredTheme() {
|
|
|
|
|
|
const stored = localStorage.getItem("theme");
|
|
|
|
|
|
if (stored) return stored;
|
|
|
|
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
|
|
|
|
? "dark"
|
|
|
|
|
|
: "light";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyTheme(theme) {
|
|
|
|
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
|
|
|
|
localStorage.setItem("theme", theme);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
applyTheme(getPreferredTheme());
|
|
|
|
|
|
|
|
|
|
|
|
themeToggle.addEventListener("click", () => {
|
|
|
|
|
|
const current = document.documentElement.getAttribute("data-theme");
|
|
|
|
|
|
applyTheme(current === "dark" ? "light" : "dark");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Helpers =====
|
|
|
|
|
|
function getRows() {
|
|
|
|
|
|
return $$(".row", tableBody);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getExpandRow(row) {
|
|
|
|
|
|
return row.nextElementSibling;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collapseAll() {
|
|
|
|
|
|
for (const row of getRows()) {
|
|
|
|
|
|
row.classList.remove("expanded");
|
|
|
|
|
|
const expand = getExpandRow(row);
|
|
|
|
|
|
if (expand) expand.hidden = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Search & Filter =====
|
|
|
|
|
|
let searchTimeout;
|
|
|
|
|
|
|
|
|
|
|
|
function applyFilters() {
|
|
|
|
|
|
const query = searchInput.value.trim().toLowerCase();
|
|
|
|
|
|
let visible = 0;
|
|
|
|
|
|
|
|
|
|
|
|
collapseAll();
|
|
|
|
|
|
|
|
|
|
|
|
for (const row of getRows()) {
|
|
|
|
|
|
const expand = getExpandRow(row);
|
|
|
|
|
|
const text = (
|
|
|
|
|
|
row.textContent +
|
|
|
|
|
|
" " +
|
|
|
|
|
|
(expand ? expand.textContent : "")
|
|
|
|
|
|
).toLowerCase();
|
2026-03-28 16:21:50 +00:00
|
|
|
|
const languages = (row.dataset.languages || "").split(" ");
|
2026-03-22 21:14:50 +00:00
|
|
|
|
const category = row.dataset.category || "";
|
|
|
|
|
|
const sources = row.dataset.sources || "";
|
|
|
|
|
|
|
|
|
|
|
|
let show = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Search
|
|
|
|
|
|
if (query && !text.includes(query)) show = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Tag filter
|
|
|
|
|
|
if (show && activeFilter.value) {
|
|
|
|
|
|
const ft = activeFilter.type;
|
|
|
|
|
|
const fv = activeFilter.value;
|
2026-03-28 16:21:50 +00:00
|
|
|
|
if (ft === "language" && !languages.includes(fv)) show = false;
|
2026-03-22 21:14:50 +00:00
|
|
|
|
if (ft === "category" && category !== fv) show = false;
|
|
|
|
|
|
if (ft === "source" && !sources.split(" ").includes(fv)) show = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
row.hidden = !show;
|
|
|
|
|
|
if (expand) expand.hidden = true;
|
|
|
|
|
|
|
|
|
|
|
|
if (show) {
|
|
|
|
|
|
visible++;
|
|
|
|
|
|
const numCell = $(".col-num", row);
|
|
|
|
|
|
if (numCell) numCell.textContent = visible;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
noResults.hidden = visible > 0;
|
|
|
|
|
|
resultsCount.textContent =
|
|
|
|
|
|
query || activeFilter.value
|
|
|
|
|
|
? `Showing ${visible} project${visible !== 1 ? "s" : ""}`
|
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
|
|
// Sync filter bar
|
|
|
|
|
|
if (activeFilter.value) {
|
|
|
|
|
|
filterValue.textContent = activeFilter.value;
|
|
|
|
|
|
filterBar.style.display = "flex";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
filterBar.style.display = "none";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
syncURL();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
searchInput.addEventListener("input", () => {
|
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
|
searchTimeout = setTimeout(applyFilters, 120);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
filterClear.addEventListener("click", () => {
|
|
|
|
|
|
activeFilter = { type: "", value: "" };
|
|
|
|
|
|
applyFilters();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-28 16:21:50 +00:00
|
|
|
|
// ===== Tag Click Handler =====
|
|
|
|
|
|
function handleTagClick(e) {
|
2026-03-22 21:14:50 +00:00
|
|
|
|
const tag = e.target.closest(".tag");
|
|
|
|
|
|
if (tag) {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
const type = tag.dataset.filterType;
|
|
|
|
|
|
const value = tag.dataset.filterValue;
|
|
|
|
|
|
|
|
|
|
|
|
// Toggle off if same filter
|
|
|
|
|
|
if (activeFilter.type === type && activeFilter.value === value) {
|
|
|
|
|
|
activeFilter = { type: "", value: "" };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
activeFilter = { type, value };
|
|
|
|
|
|
}
|
|
|
|
|
|
applyFilters();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-28 16:21:50 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Listen for tag clicks on table rows
|
|
|
|
|
|
tableBody.addEventListener("click", handleTagClick);
|
|
|
|
|
|
|
|
|
|
|
|
// Listen for tag clicks on tag cloud (outside table)
|
|
|
|
|
|
const tagCloud = $(".tag-cloud");
|
|
|
|
|
|
if (tagCloud) {
|
|
|
|
|
|
tagCloud.addEventListener("click", handleTagClick);
|
|
|
|
|
|
}
|
2026-03-22 21:14:50 +00:00
|
|
|
|
|
|
|
|
|
|
// ===== Row Expand =====
|
|
|
|
|
|
tableBody.addEventListener("click", (e) => {
|
|
|
|
|
|
if (e.target.closest(".tag") || e.target.closest("a")) return;
|
|
|
|
|
|
const row = e.target.closest(".row");
|
|
|
|
|
|
if (!row) return;
|
|
|
|
|
|
|
|
|
|
|
|
const expand = getExpandRow(row);
|
|
|
|
|
|
if (!expand) return;
|
|
|
|
|
|
|
|
|
|
|
|
const isExpanded = row.classList.contains("expanded");
|
|
|
|
|
|
|
|
|
|
|
|
for (const r of getRows()) {
|
|
|
|
|
|
if (r !== row) {
|
|
|
|
|
|
r.classList.remove("expanded");
|
|
|
|
|
|
const ex = getExpandRow(r);
|
|
|
|
|
|
if (ex) ex.hidden = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
|
row.classList.remove("expanded");
|
|
|
|
|
|
expand.hidden = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row.classList.add("expanded");
|
|
|
|
|
|
expand.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
tableBody.addEventListener("keydown", (e) => {
|
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
|
const row = e.target.closest(".row");
|
|
|
|
|
|
if (row) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
row.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Sort =====
|
|
|
|
|
|
function getSortValue(row, key) {
|
|
|
|
|
|
if (key === "name") {
|
|
|
|
|
|
return ($(".col-name a", row)?.textContent || "").toLowerCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === "stars") {
|
|
|
|
|
|
return parseInt(row.dataset.stars || "0", 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === "update") {
|
|
|
|
|
|
return ($(".last-update", row)?.textContent || "").trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function doSort(key, dir) {
|
|
|
|
|
|
const rows = getRows();
|
|
|
|
|
|
const pairs = rows.map((r) => [r, getExpandRow(r)]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!dir) {
|
|
|
|
|
|
pairs.sort((a, b) => {
|
|
|
|
|
|
const ai = parseInt(a[0].dataset.originalIndex || "0");
|
|
|
|
|
|
const bi = parseInt(b[0].dataset.originalIndex || "0");
|
|
|
|
|
|
return ai - bi;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pairs.sort((a, b) => {
|
|
|
|
|
|
const va = getSortValue(a[0], key);
|
|
|
|
|
|
const vb = getSortValue(b[0], key);
|
|
|
|
|
|
let cmp;
|
|
|
|
|
|
if (typeof va === "number" && typeof vb === "number") {
|
|
|
|
|
|
cmp = va - vb;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cmp = String(va).localeCompare(String(vb));
|
|
|
|
|
|
}
|
|
|
|
|
|
return dir === "asc" ? cmp : -cmp;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [row, expand] of pairs) {
|
|
|
|
|
|
tableBody.appendChild(row);
|
|
|
|
|
|
if (expand) tableBody.appendChild(expand);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
applyFilters();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const th of sortHeaders) {
|
|
|
|
|
|
th.addEventListener("click", () => {
|
|
|
|
|
|
const key = th.dataset.sort;
|
|
|
|
|
|
|
|
|
|
|
|
let nextDir;
|
|
|
|
|
|
if (currentSort.key !== key) {
|
|
|
|
|
|
nextDir = key === "name" ? "asc" : "desc";
|
|
|
|
|
|
} else if (currentSort.dir === "asc") {
|
|
|
|
|
|
nextDir = "desc";
|
|
|
|
|
|
} else if (currentSort.dir === "desc") {
|
|
|
|
|
|
nextDir = key === "name" ? "" : "asc";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
nextDir = key === "name" ? "asc" : "desc";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const h of sortHeaders) {
|
|
|
|
|
|
h.classList.remove("asc", "desc");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (nextDir) {
|
|
|
|
|
|
th.classList.add(nextDir);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentSort = { key: nextDir ? key : "", dir: nextDir };
|
|
|
|
|
|
doSort(key, nextDir);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store original indices
|
|
|
|
|
|
getRows().forEach((r, i) => (r.dataset.originalIndex = i));
|
|
|
|
|
|
|
|
|
|
|
|
// ===== Keyboard Shortcuts =====
|
|
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
|
|
|
|
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
|
if (
|
|
|
|
|
|
active &&
|
|
|
|
|
|
(active.tagName === "INPUT" ||
|
|
|
|
|
|
active.tagName === "SELECT" ||
|
|
|
|
|
|
active.tagName === "TEXTAREA")
|
|
|
|
|
|
)
|
|
|
|
|
|
return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
searchInput.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
|
if (document.activeElement === searchInput) {
|
|
|
|
|
|
if (searchInput.value) {
|
|
|
|
|
|
searchInput.value = "";
|
|
|
|
|
|
applyFilters();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
searchInput.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ===== URL State =====
|
|
|
|
|
|
function syncURL() {
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (searchInput.value) params.set("q", searchInput.value);
|
|
|
|
|
|
if (activeFilter.value) {
|
|
|
|
|
|
params.set("filter_type", activeFilter.type);
|
|
|
|
|
|
params.set("filter", activeFilter.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
const qs = params.toString();
|
|
|
|
|
|
const url = qs ? `?${qs}` : location.pathname;
|
|
|
|
|
|
history.replaceState(null, "", url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function restoreURL() {
|
|
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
|
|
|
|
if (params.has("q")) searchInput.value = params.get("q");
|
|
|
|
|
|
if (params.has("filter")) {
|
|
|
|
|
|
activeFilter = {
|
|
|
|
|
|
type: params.get("filter_type") || "category",
|
|
|
|
|
|
value: params.get("filter"),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (params.toString()) applyFilters();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
restoreURL();
|
|
|
|
|
|
})();
|