Files
awesome-python/website/static/main.js
T
Vinta ChenandClaude 7e6baa968b feat(website): send a GA4 event on sponsorship email clicks
Mailto clicks on the sponsorship page leave no page view, so the sponsor funnel ended at the sponsorship page and never showed who went on to email. Each click now sends a sponsor_contact event with a short tier value (headline, featured, indie, or general) rather than the link URL, since GA4 caps event parameter values at 100 characters and the Indie Dev mailto href is 101.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 09:09:21 +08:00

500 lines
14 KiB
JavaScript

const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function getScrollBehavior() {
return reducedMotion.matches ? "auto" : "smooth";
}
const table = document.querySelector(".table");
// Category pages list rows in editorial (README) order until a column is sorted
const defaultSort =
table && table.dataset.defaultSort === "editorial"
? { col: "editorial", order: "asc" }
: { col: "downloads", order: "desc" };
let activeSort = defaultSort;
const searchInput = document.querySelector(".search");
const noResults = document.querySelector(".no-results");
const rows = document.querySelectorAll(".table tbody tr.row");
const tbody = document.querySelector(".table tbody");
const groupRows = document.querySelectorAll(".table tbody tr.group-row");
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"));
});
let currentGroupRow = null;
Array.prototype.forEach.call(tbody ? tbody.rows : [], function (tr) {
if (tr.classList.contains("group-row")) currentGroupRow = tr;
else if (tr.classList.contains("row")) tr._groupRow = currentGroupRow;
});
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;
let visibleCount = 0;
collapseAll();
// Number rows in their sorted DOM order, not the load order `rows` keeps
const orderedRows = tbody ? tbody.querySelectorAll("tr.row") : rows;
orderedRows.forEach(function (row) {
let show = true;
if (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);
}
}
});
groupRows.forEach(function (groupRow) {
groupRow.hidden = true;
});
if (activeSort.col === "editorial") {
rows.forEach(function (row) {
if (!row.hidden && row._groupRow) row._groupRow.hidden = false;
});
}
if (noResults) noResults.hidden = visibleCount > 0;
updateURL();
}
const isIndexDocument =
location.pathname === "/" || location.pathname === "/index.html";
function buildQueryString() {
const params = new URLSearchParams();
const query = searchInput ? searchInput.value.trim() : "";
if (query) params.set("q", query);
if (activeSort.col !== defaultSort.col || activeSort.order !== defaultSort.order) {
params.set("sort", activeSort.col);
params.set("order", activeSort.order);
}
const qs = params.toString();
return qs ? "?" + qs : "";
}
function updateURL() {
if (!isIndexDocument) return;
history.replaceState(null, "", "/" + buildQueryString());
}
function getSortValue(row, col) {
// +1 keeps the first row above the "no value" cutoff in sortRows
if (col === "editorial") return row._origIndex + 1;
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 === "downloads") {
const text = row
.querySelector(".col-downloads")
.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();
let lastGroupRow = null;
arr.forEach(function (row) {
if (col === "editorial" && row._groupRow && row._groupRow !== lastGroupRow) {
frag.appendChild(row._groupRow);
lastGroupRow = row._groupRow;
}
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() {
if (table) table.classList.toggle("sorted", activeSort.col !== "editorial");
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
if (e.target.closest("a")) 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;
if (e.target.closest("a")) return;
const row = e.target.closest("tr.row");
if (!row) return;
e.preventDefault();
row.click();
});
}
const noResultsClear = document.querySelector(".no-results-clear");
if (noResultsClear) {
noResultsClear.addEventListener("click", function () {
if (!isIndexDocument) {
window.location.href = "/";
return;
}
if (searchInput) searchInput.value = "";
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 = defaultSort;
} else {
activeSort = { col: col, order: defaultOrder };
}
sortRows();
updateSortIndicators();
});
});
function openEntryRow(row) {
if (row.hidden && searchInput) {
searchInput.value = "";
applyFilters();
}
row.classList.add("open");
row.setAttribute("aria-expanded", "true");
}
// Group headings are hidden while sorted flat or filtered out by search, so a link to one must restore them first
document.addEventListener("click", function (e) {
const link = e.target.closest('a[href*="#"]');
if (!link || link.pathname !== location.pathname) return;
const target = document.getElementById(decodeURIComponent(link.hash.slice(1)));
if (target && target.matches("tr.row")) {
openEntryRow(target);
return;
}
const groupRow = target ? target.closest(".group-row") : null;
if (!groupRow) return;
if (activeSort.col !== "editorial") {
activeSort = defaultSort;
sortRows();
updateSortIndicators();
}
if (groupRow.hidden && searchInput) {
searchInput.value = "";
applyFilters();
}
});
// Mailto clicks leave no page view, so the sponsor funnel needs its own event
document.addEventListener("click", function (e) {
const link = e.target.closest('a[href^="mailto:sponsorship@"]');
if (!link || typeof gtag !== "function") return;
gtag("event", "sponsor_contact", { tier: link.dataset.tier });
});
if (location.hash) {
const linkedRow = document.getElementById(decodeURIComponent(location.hash.slice(1)));
if (linkedRow && linkedRow.matches("tr.row")) openEntryRow(linkedRow);
}
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 = "";
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 === "downloads" ||
sort === "commit-time") &&
(order === "desc" || order === "asc")
) {
activeSort = { col: sort, order: order };
}
if (q || sort) {
sortRows();
}
updateSortIndicators();
})();