diff --git a/web/assets/site.css b/web/assets/site.css index 5eb53df..67e35bf 100644 --- a/web/assets/site.css +++ b/web/assets/site.css @@ -790,6 +790,54 @@ table.data.data-wide { min-width: 820px; } } /* ---------- search modal ---------- */ +/* ---------- resource table ---------- */ +/* the topic filter is a select rather than a chip row, so it needs the same + height and border as the search field beside it without the icon inset */ +.toolbar .field-select { flex: 0 1 auto; min-width: 0; } +.toolbar .field-select select { + height: 40px; border: 1px solid var(--border); border-radius: var(--radius-md); + background-color: var(--surface); color: var(--text); +} +.field-select select { + appearance: none; -webkit-appearance: none; + border: 0; background: transparent; color: inherit; + font: inherit; padding: 9px 30px 9px 12px; max-width: 15rem; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: right 14px center, right 9px center; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} +.res-table { + width: 100%; border-collapse: collapse; font-size: 0.9rem; + margin-top: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); + background: var(--surface); + /* no overflow property here: any value other than visible makes this a scroll + container, and a sticky thead then offsets inside the table instead of + against the page, leaving a 58px void above the header */ +} +.res-table th, .res-table td { text-align: left; vertical-align: top; padding: 9px 12px; border-bottom: 1px solid var(--border); } +.res-table thead th { position: sticky; top: 58px; z-index: 1; background: var(--surface-alt); border-bottom: 1px solid var(--border-strong); } +.res-table thead th button { + display: inline-flex; align-items: center; gap: 5px; + border: 0; background: none; padding: 0; cursor: pointer; + font: inherit; font-weight: 700; color: var(--text); +} +.res-table thead th button::after { content: "↕"; opacity: 0.35; font-size: 0.85em; } +.res-table thead th[aria-sort="ascending"] button::after { content: "↑"; opacity: 1; color: var(--accent-strong); } +.res-table thead th[aria-sort="descending"] button::after { content: "↓"; opacity: 1; color: var(--accent-strong); } +.res-table tbody tr:hover { background: var(--surface-alt); } +.res-table td[data-col="title"] { width: 52%; } +.res-table td[data-col="publisher"], .res-table td[data-col="topic"] { white-space: nowrap; } +.res-pub { color: var(--text-faint); font-size: 0.85em; } +.res-note { display: block; margin-top: 2px; color: var(--text-faint); font-size: 0.85em; line-height: 1.45; } +@media (max-width: 720px) { + /* the publisher is the first thing to go: it is the least load-bearing + column and the title already carries the link */ + .res-table td[data-col="publisher"], .res-table th:nth-child(2) { display: none; } + .res-table td[data-col="title"] { width: auto; } + .res-table td[data-col="topic"] { white-space: normal; } +} + /* ---------- lightbox ---------- */ .lightbox { position: fixed; inset: 0; z-index: 110; display: none; } .lightbox.open { display: block; } diff --git a/web/assets/site.js b/web/assets/site.js index db6e484..771fbda 100644 --- a/web/assets/site.js +++ b/web/assets/site.js @@ -279,6 +279,7 @@ modal.classList.add("open"); document.body.style.overflow = "hidden"; input.value = ""; render(""); input.focus(); + loadIndex().then(() => { if (modal.classList.contains("open")) render(input.value); }); }; const close = () => { modal.classList.remove("open"); sel = -1; @@ -317,6 +318,27 @@ return sc; }; + /* The index holds every page, prompt, worksheet, section and resource -- + around 22 KB gzipped once the 653 resources are in it. Every page used to + block on that whether or not anyone searched. It is fetched on the first + time the dialog opens instead, and the page carries a prefetch hint so the + browser can pull it while idle. Falls back silently: if the fetch fails + there is simply nothing to search rather than a broken dialog. */ + let indexPromise = null; + const loadIndex = () => { + if (window.SEARCH_INDEX) return Promise.resolve(); + if (indexPromise) return indexPromise; + const src = window.SEARCH_INDEX_URL; + if (!src) return Promise.resolve(); + indexPromise = new Promise((resolve) => { + const s = document.createElement("script"); + s.src = src; + s.onload = s.onerror = () => resolve(); + document.head.appendChild(s); + }); + return indexPromise; + }; + const mark = (text, q) => { if (!q) return esc(text); const i = text.toLowerCase().indexOf(q); @@ -384,7 +406,13 @@ if (!cards.length) return; const input = $(opts.input); - const chips = $$(opts.chips, grid.closest(opts.scope || "body") || document); + const scope = grid.closest(opts.scope || "body") || document; + const all = $$(opts.chips, scope); + /* a filter group is driven either by a row of chips or by one select -- + the select earns its place when the group has fifty values and a chip row + would be longer than the table it filters */ + const chips = all.filter((el) => el.tagName !== "SELECT"); + const selects = all.filter((el) => el.tagName === "SELECT"); const countEl = $(opts.count); const emptyEl = $(opts.empty); const one = opts.one || "item"; @@ -392,6 +420,7 @@ const groups = {}; chips.forEach((c) => { groups[c.dataset.filterGroup] = ""; }); + selects.forEach((c) => { groups[c.dataset.filterGroup] = ""; }); let q = ""; // data-filter-group="time-cost" reads data-time-cost off the card @@ -417,6 +446,7 @@ const on = (c.dataset.filterValue || "") === groups[c.dataset.filterGroup]; c.setAttribute("aria-pressed", String(on)); }); + selects.forEach((c) => { c.value = groups[c.dataset.filterGroup] || ""; }); if (countEl) countEl.innerHTML = `Showing ${shown} of ${cards.length} ${cards.length === 1 ? one : many}`; if (emptyEl) emptyEl.hidden = shown > 0; }; @@ -425,6 +455,7 @@ Object.keys(groups).forEach((g) => { groups[g] = ""; }); q = ""; if (input) input.value = ""; + selects.forEach((c) => { c.value = ""; }); apply(); }; @@ -435,13 +466,20 @@ groups[g] = groups[g] === v ? "" : v; // clicking the live chip clears its group apply(); })); + selects.forEach((c) => c.addEventListener("change", () => { + groups[c.dataset.filterGroup] = c.value; + apply(); + })); $$("[data-filter-reset]").forEach((b) => b.addEventListener("click", reset)); // deep link, e.g. library.html?q=recap&stage=build const params = new URLSearchParams(location.search); Object.keys(groups).forEach((g) => { const v = params.get(g); - if (v && chips.some((c) => c.dataset.filterGroup === g && c.dataset.filterValue === v)) groups[g] = v; + if (!v) return; + const okChip = chips.some((c) => c.dataset.filterGroup === g && c.dataset.filterValue === v); + const okOpt = selects.some((c) => c.dataset.filterGroup === g && [...c.options].some((o) => o.value === v)); + if (okChip || okOpt) groups[g] = v; }); if (params.get("q")) { q = params.get("q").trim().toLowerCase(); if (input) input.value = params.get("q"); } @@ -531,6 +569,67 @@ }); })(); + /* ---------- sortable tables ---------- + Any table[data-sortable] whose header cells contain a button[data-sort]. + Sorts on the matching td[data-col], comparing the cell's text. Rows hidden + by a filter keep their place in the sort, so clearing the filter does not + reveal an order that never made sense. */ + $$("table[data-sortable]").forEach((table) => { + const body = $("tbody", table); + if (!body) return; + const buttons = $$("th button[data-sort]", table); + /* Adopt the order the markup already declares. Without this the first click + on the pre-sorted column re-applies the same direction and looks broken. */ + const preset = $("th[aria-sort] button[data-sort]", table); + let col = preset ? preset.dataset.sort : null; + let dir = preset && preset.closest("th").getAttribute("aria-sort") === "descending" ? -1 : 1; + + const key = (row, name) => { + const cell = $(`td[data-col="${name}"]`, row); + return (cell ? cell.textContent : "").trim().toLowerCase(); + }; + + const sort = (name) => { + dir = col === name ? -dir : 1; + col = name; + const rows = [...body.rows]; + /* Intl collation so accented titles land where a reader expects, and a + numeric pass so "10 Ways" does not sort before "2 Ways". */ + const cmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + rows.sort((a, b) => { + const ka = key(a, name), kb = key(b, name); + /* An empty cell is absent data, not a value that sorts first. Reversing + the direction should not float a block of blanks to the top. */ + if (!ka !== !kb) return ka ? -1 : 1; + return dir * cmp.compare(ka, kb); + }); + const frag = document.createDocumentFragment(); + rows.forEach((r) => frag.appendChild(r)); + body.appendChild(frag); + buttons.forEach((b) => { + const th = b.closest("th"); + if (!th) return; + if (b.dataset.sort === name) th.setAttribute("aria-sort", dir === 1 ? "ascending" : "descending"); + else th.removeAttribute("aria-sort"); + }); + }; + + buttons.forEach((b) => b.addEventListener("click", () => sort(b.dataset.sort))); + }); + + /* the resource index: same filter engine as the prompt library, over rows */ + mountFilterGrid({ + grid: "#res-body", + cards: "#res-body > tr", + input: "#res-q", + chips: "[data-filter-group]", + scope: "main", + count: "#res-count", + empty: "#res-empty", + one: "resource", + many: "resources", + }); + /* the prompt library is the one grid using it today */ mountFilterGrid({ grid: "#lib-grid", diff --git a/web/build.mjs b/web/build.mjs index e55d284..27e035b 100644 --- a/web/build.mjs +++ b/web/build.mjs @@ -33,6 +33,15 @@ const OUT = join(ROOT, "_site"); The cost of that is a file that can quietly go stale, so the numbers printed on it are asserted against the repository further down and the build fails if they have drifted. Regenerate with: node tools/make-og.mjs */ +/* A link on one of these is hosted, not published — the author cannot be read + off the domain, so nothing is attributed and the domain is not worth showing. */ +const PLATFORM_DOMAINS = new Set([ + "youtube.com", "youtu.be", "m.youtube.com", "medium.com", "linkedin.com", + "docs.google.com", "drive.google.com", "s3.amazonaws.com", "cdn2.hubspot.net", + "vimeo.com", "open.spotify.com", "podcasts.apple.com", "soundcloud.com", + "x.com", "twitter.com", "airtable.com", "ecademycourse.teachable.com", +]); + const OG_IMAGE = "og.png"; const OG_ALT = "startup-stack — the documents every startup should have, as a knowledge base an AI can actually read."; @@ -644,7 +653,8 @@ ${tocHtml ? `