Files
Vinta Chen 9de86ea785 feat(website): append #library-index to tag links on non-index pages
Tag clicks on category/other pages now land with the results section
scrolled into view.

Co-Authored-By: Claude <[email protected]>
2026-05-03 18:45:09 +08:00

492 lines
14 KiB
JavaScript

const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function getScrollBehavior() {
return reducedMotion.matches ? "auto" : "smooth";
}
let activeFilter = null;
let activeSort = { col: "stars", order: "desc" };
const searchInput = document.querySelector(".search");
const filterBar = document.querySelector(".filter-bar");
const filterValue = document.querySelector(".filter-value");
const filterClear = document.querySelector(".filter-clear");
const noResults = document.querySelector(".no-results");
const rows = document.querySelectorAll(".table tbody tr.row");
const tags = document.querySelectorAll(".tag");
const tbody = document.querySelector(".table tbody");
function initRevealSections() {
const sections = document.querySelectorAll("[data-reveal]");
if (!sections.length) return;
if (!("IntersectionObserver" in window)) {
sections.forEach(function (section) {
section.classList.add("is-visible");
});
return;
}
const observer = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{
threshold: 0.12,
rootMargin: "0px 0px -8% 0px",
},
);
sections.forEach(function (section, index) {
section.classList.add("will-reveal");
section.style.transitionDelay = Math.min(index * 70, 180) + "ms";
observer.observe(section);
});
}
initRevealSections();
// Smooth scroll without hash in URL
document.querySelectorAll("[data-scroll-to]").forEach(function (link) {
link.addEventListener("click", function (e) {
const el = document.getElementById(link.dataset.scrollTo);
if (!el) return;
e.preventDefault();
el.scrollIntoView({ behavior: getScrollBehavior() });
});
});
// Land at #library-index without leaving the hash in the URL
if (window.location.hash === "#library-index") {
const target = document.getElementById("library-index");
if (target) {
target.scrollIntoView();
}
history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
// Pause hero animations when scrolled out of view
(function () {
const hero = document.querySelector(".hero");
if (!hero || !("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(function (entries) {
hero.classList.toggle("offscreen", !entries[0].isIntersecting);
});
observer.observe(hero);
})();
function relativeTime(isoStr) {
const date = new Date(isoStr);
const now = new Date();
const diffMs = now - date;
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffHours < 1) return "just now";
if (diffHours < 24)
return diffHours === 1 ? "1 hour ago" : diffHours + " hours ago";
if (diffDays === 1) return "yesterday";
if (diffDays < 30) return diffDays + " days ago";
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12)
return diffMonths === 1 ? "1 month ago" : diffMonths + " months ago";
const diffYears = Math.floor(diffDays / 365);
return diffYears === 1 ? "1 year ago" : diffYears + " years ago";
}
document.querySelectorAll(".col-commit[data-commit]").forEach(function (td) {
const time = td.querySelector("time");
if (time) time.textContent = relativeTime(td.dataset.commit);
});
document
.querySelectorAll(".expand-commit time[datetime]")
.forEach(function (time) {
time.textContent = relativeTime(time.getAttribute("datetime"));
});
rows.forEach(function (row, i) {
row._origIndex = i;
let next = row.nextElementSibling;
if (next && next.classList.contains("desc-row")) {
row._descRow = next;
next = next.nextElementSibling;
}
row._expandRow = next;
});
function collapseAll() {
if (!tbody) return;
const openRows = tbody.querySelectorAll("tr.row.open");
openRows.forEach(function (row) {
row.classList.remove("open");
row.setAttribute("aria-expanded", "false");
});
}
function applyFilters() {
const query = searchInput ? searchInput.value.toLowerCase().trim() : "";
const descRowsVisible = !isIndexDocument || activeFilter !== null;
let visibleCount = 0;
collapseAll();
rows.forEach(function (row) {
let show = true;
if (activeFilter) {
const rowTags = row.dataset.tags;
show = rowTags ? rowTags.split("||").includes(activeFilter) : false;
}
if (show && query) {
if (!row._searchText) {
let text = row.textContent.toLowerCase();
if (row._descRow) {
text += " " + row._descRow.textContent.toLowerCase();
}
if (row._expandRow) {
text += " " + row._expandRow.textContent.toLowerCase();
}
row._searchText = text;
}
show = row._searchText.includes(query);
}
if (row.hidden !== !show) row.hidden = !show;
if (row._descRow) {
const descHidden = !show || !descRowsVisible;
if (row._descRow.hidden !== descHidden) {
row._descRow.hidden = descHidden;
}
}
if (show) {
visibleCount++;
const numCell = row.cells[0];
if (numCell.textContent !== String(visibleCount)) {
numCell.textContent = String(visibleCount);
}
}
});
if (noResults) noResults.hidden = visibleCount > 0;
tags.forEach(function (tag) {
tag.classList.toggle("active", activeFilter === tag.dataset.value);
});
if (filterBar) {
if (activeFilter) {
filterBar.classList.add("visible");
if (filterValue) filterValue.textContent = activeFilter;
} else {
filterBar.classList.remove("visible");
}
}
updateURL();
}
const filterUrlsScript = document.getElementById("filter-urls");
const filterToUrl = filterUrlsScript
? JSON.parse(filterUrlsScript.textContent)
: {};
const isIndexDocument =
location.pathname === "/" || location.pathname === "/index.html";
const urlToFilter = {};
Object.keys(filterToUrl).forEach(function (k) {
urlToFilter[filterToUrl[k]] = k;
});
function buildQueryString() {
const params = new URLSearchParams();
const query = searchInput ? searchInput.value.trim() : "";
if (query) params.set("q", query);
if (activeSort.col !== "stars" || activeSort.order !== "desc") {
params.set("sort", activeSort.col);
params.set("order", activeSort.order);
}
const qs = params.toString();
return qs ? "?" + qs : "";
}
function updateURL() {
if (!isIndexDocument) return;
const path =
activeFilter && filterToUrl[activeFilter] ? filterToUrl[activeFilter] : "/";
history.replaceState(null, "", path + buildQueryString());
}
function getSortValue(row, col) {
if (col === "name") {
return row.querySelector(".col-name a").textContent.trim().toLowerCase();
}
if (col === "stars") {
const text = row
.querySelector(".col-stars")
.textContent.trim()
.replace(/,/g, "");
const num = parseInt(text, 10);
return isNaN(num) ? -1 : num;
}
if (col === "commit-time") {
const attr = row.querySelector(".col-commit").getAttribute("data-commit");
return attr ? new Date(attr).getTime() : 0;
}
return 0;
}
function sortRows() {
if (!tbody) return;
const arr = Array.prototype.slice.call(rows);
const col = activeSort.col;
const order = activeSort.order;
// Cache sort values once to avoid DOM queries per comparison
arr.forEach(function (row) {
row._sortVal = getSortValue(row, col);
});
arr.sort(function (a, b) {
const aVal = a._sortVal;
const bVal = b._sortVal;
if (col === "name") {
const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
if (cmp === 0) return a._origIndex - b._origIndex;
return order === "desc" ? -cmp : cmp;
}
if (aVal <= 0 && bVal <= 0) return a._origIndex - b._origIndex;
if (aVal <= 0) return 1;
if (bVal <= 0) return -1;
const cmp = aVal - bVal;
if (cmp === 0) return a._origIndex - b._origIndex;
return order === "desc" ? -cmp : cmp;
});
const frag = document.createDocumentFragment();
arr.forEach(function (row) {
frag.appendChild(row);
if (row._descRow) frag.appendChild(row._descRow);
if (row._expandRow) frag.appendChild(row._expandRow);
});
tbody.appendChild(frag);
applyFilters();
}
const sortHeaders = document.querySelectorAll("th[data-sort]");
function updateSortIndicators() {
sortHeaders.forEach(function (th) {
th.classList.remove("sort-asc", "sort-desc");
if (th.dataset.sort === activeSort.col) {
th.classList.add("sort-" + activeSort.order);
th.setAttribute(
"aria-sort",
activeSort.order === "asc" ? "ascending" : "descending",
);
} else {
th.removeAttribute("aria-sort");
}
});
}
// Expand/collapse: event delegation on tbody
if (tbody) {
tbody.addEventListener("click", function (e) {
// Don't toggle if clicking a link or tag button
if (e.target.closest("a") || e.target.closest(".tag")) return;
let row = e.target.closest("tr.row");
if (!row) {
const descRow = e.target.closest("tr.desc-row");
if (descRow) row = descRow.previousElementSibling;
}
if (!row) return;
const isOpen = row.classList.contains("open");
if (isOpen) {
row.classList.remove("open");
row.setAttribute("aria-expanded", "false");
} else {
row.classList.add("open");
row.setAttribute("aria-expanded", "true");
}
});
// Keyboard: Enter or Space on focused .row toggles expand
tbody.addEventListener("keydown", function (e) {
if (e.key !== "Enter" && e.key !== " ") return;
const row = e.target.closest("tr.row");
if (!row) return;
e.preventDefault();
row.click();
});
}
tags.forEach(function (tag) {
tag.addEventListener("click", function (e) {
e.preventDefault();
const value = tag.dataset.value;
const url = tag.dataset.url;
if (isIndexDocument) {
activeFilter = activeFilter === value ? null : value;
if (activeFilter && url) {
history.pushState(null, "", url + buildQueryString());
} else {
history.pushState(null, "", "/" + buildQueryString());
}
applyFilters();
} else if (url) {
window.location.href = url + "#library-index";
}
});
});
if (filterClear) {
filterClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/#library-index";
return;
}
activeFilter = null;
applyFilters();
});
}
const noResultsClear = document.querySelector(".no-results-clear");
if (noResultsClear) {
noResultsClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/";
return;
}
if (searchInput) searchInput.value = "";
activeFilter = null;
applyFilters();
});
}
sortHeaders.forEach(function (th) {
th.addEventListener("click", function () {
const col = th.dataset.sort;
const defaultOrder = col === "name" ? "asc" : "desc";
const altOrder = defaultOrder === "asc" ? "desc" : "asc";
if (activeSort.col === col) {
if (activeSort.order === defaultOrder)
activeSort = { col: col, order: altOrder };
else activeSort = { col: "stars", order: "desc" };
} else {
activeSort = { col: col, order: defaultOrder };
}
sortRows();
updateSortIndicators();
});
});
if (searchInput) {
let searchTimer;
searchInput.addEventListener("input", function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilters, 150);
});
document.addEventListener("keydown", function (e) {
if (
e.key === "/" &&
!["INPUT", "TEXTAREA", "SELECT"].includes(
document.activeElement.tagName,
) &&
!e.ctrlKey &&
!e.metaKey
) {
e.preventDefault();
searchInput.focus();
}
if (e.key === "Escape" && document.activeElement === searchInput) {
searchInput.value = "";
activeFilter = null;
applyFilters();
searchInput.blur();
}
});
}
const backToTop = document.querySelector(".back-to-top");
const resultsSection = document.querySelector("#library-index");
const tableWrap = document.querySelector(".table-wrap");
const stickyHeaderCell = backToTop ? backToTop.closest("th") : null;
function updateBackToTopVisibility() {
if (!backToTop || !tableWrap || !stickyHeaderCell) return;
const tableRect = tableWrap.getBoundingClientRect();
const headRect = stickyHeaderCell.getBoundingClientRect();
const hasPassedHeader = tableRect.top <= 0 && headRect.bottom > 0;
backToTop.classList.toggle("visible", hasPassedHeader);
}
if (backToTop) {
let scrollTicking = false;
window.addEventListener("scroll", function () {
if (!scrollTicking) {
requestAnimationFrame(function () {
updateBackToTopVisibility();
scrollTicking = false;
});
scrollTicking = true;
}
});
window.addEventListener("resize", updateBackToTopVisibility);
backToTop.addEventListener("click", function () {
const target = searchInput || resultsSection;
if (!target) return;
target.scrollIntoView({ behavior: getScrollBehavior(), block: "center" });
if (searchInput) searchInput.focus();
});
updateBackToTopVisibility();
}
(function () {
const params = new URLSearchParams(location.search);
const q = params.get("q");
const sort = params.get("sort");
const order = params.get("order");
if (q && searchInput) searchInput.value = q;
if (
(sort === "name" || sort === "stars" || sort === "commit-time") &&
(order === "desc" || order === "asc")
) {
activeSort = { col: sort, order: order };
}
const matched = urlToFilter[location.pathname];
if (matched) activeFilter = matched;
if (q || activeFilter || sort) {
sortRows();
}
if (activeFilter) {
applyFilters();
}
updateSortIndicators();
})();
window.addEventListener("popstate", function () {
if (!isIndexDocument) return;
const matched = urlToFilter[location.pathname];
activeFilter = matched || null;
applyFilters();
});