Files
awesome-python/website/static/main.js
Vinta Chen 8e72e8af8f feat(website): strip #library-index from URL after All projects click
The "All projects" link in the category-page topbar pointed to
/#library-index so the browser would scroll to the library section on
arrival. The hash stayed in the URL, which looked like an internal anchor
state rather than a clean homepage URL.

On homepage load, if the hash is #library-index, scroll to the section
explicitly and use history.replaceState to drop the hash from the URL.
The scrollIntoView call covers the case where the script runs before the
browser's native anchor scroll, since replaceState removes the hash the
browser would have used.
2026-05-03 07:56:54 +08:00

470 lines
13 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;
row._expandRow = row.nextElementSibling;
});
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() : "";
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();
const next = row.nextElementSibling;
if (next && next.classList.contains("expand-row")) {
text += " " + next.textContent.toLowerCase();
}
row._searchText = text;
}
show = row._searchText.includes(query);
}
if (row.hidden !== !show) row.hidden = !show;
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 urlToFilter = {};
Object.keys(filterToUrl).forEach(function (k) {
urlToFilter[filterToUrl[k]] = k;
});
const isIndexDocument =
location.pathname === "/" || location.pathname === "/index.html";
function isIndexPage() {
return isIndexDocument;
}
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 (!isIndexPage()) 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);
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;
const row = e.target.closest("tr.row");
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 (isIndexPage()) {
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;
} else {
activeFilter = activeFilter === value ? null : value;
applyFilters();
}
});
});
if (filterClear) {
filterClear.addEventListener("click", function () {
activeFilter = null;
applyFilters();
});
}
const noResultsClear = document.querySelector(".no-results-clear");
if (noResultsClear) {
noResultsClear.addEventListener("click", function () {
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 };
}
if (isIndexPage()) {
const matched = urlToFilter[location.pathname];
if (matched) activeFilter = matched;
}
if (q || activeFilter || sort) {
sortRows();
}
updateSortIndicators();
})();
window.addEventListener("popstate", function () {
if (!isIndexPage()) return;
const matched = urlToFilter[location.pathname];
activeFilter = matched || null;
applyFilters();
});