mirror of
https://github.com/vinta/awesome-python.git
synced 2026-05-08 23:01:17 -05:00
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.
470 lines
13 KiB
JavaScript
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();
|
|
});
|