diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-18 01:33:30 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-18 01:33:30 +0300 |
| commit | eac54b84b8335f0ec9b941a2dcb8da25d860fc08 (patch) | |
| tree | 1875ddab2a8da1fa1f8a5d525c2b58bec89d8695 /website/src | |
| parent | b3166d794e758c074d706fcab5864a9f4e7e2c2c (diff) | |
feat(website): plugin marketplace discovery shelves — trending and new arrivals
Diffstat (limited to 'website/src')
| -rw-r--r-- | website/src/pages/plugins.astro | 503 |
1 files changed, 450 insertions, 53 deletions
diff --git a/website/src/pages/plugins.astro b/website/src/pages/plugins.astro index 9bc7e4a8..57b189ea 100644 --- a/website/src/pages/plugins.astro +++ b/website/src/pages/plugins.astro @@ -1,7 +1,6 @@ --- import SiteLayout from '../components/SiteLayout.astro'; import fixture from '../data/plugins-fixture.json'; - const snapshotUrl = 'https://assets.herdr.dev/plugins/index.json'; // Seed the page server-side from the public R2 snapshot so `bun run dev` and @@ -59,35 +58,62 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; </aside> </header> - <section class="section plugins-controls"> - <div class="plugins-search-wrap"> - <span class="plugins-search-icon" aria-hidden="true">/</span> - <input - id="plugins-search" - type="search" - class="plugins-search" - placeholder="search plugins, repositories, owners, languages…" - autocomplete="off" - spellcheck="false" - aria-label="Search plugins" - /> - </div> - <label class="plugins-sort-wrap"> - <span class="plugins-sort-label">sort</span> - <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins"> - <option value="popular">Popular</option> - <option value="recent">Recently updated</option> - <option value="newest">Newest</option> - <option value="name">Name</option> - </select> - </label> - </section> - - <section class="section plugins-metabar"> - <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p> - </section> + <div id="plugins-shelves" hidden> + <section class="section plugins-shelf" id="shelf-trending" hidden> + <div class="shelf-head"> + <div> + <h2 class="shelf-title">Trending this week</h2> + <p class="shelf-sub">fastest-growing repositories, relative to their own size</p> + </div> + <button class="shelf-all" type="button" data-sort="trending">see all →</button> + </div> + <div id="trend-grid" class="trend-grid" role="list"></div> + </section> + + <section class="section plugins-shelf" id="shelf-new" hidden> + <div class="shelf-head"> + <div> + <h2 class="shelf-title">New to the herd</h2> + <p class="shelf-sub">fresh projects that joined recently</p> + </div> + <button class="shelf-all" type="button" data-sort="newest">see all →</button> + </div> + <div class="new-rows" role="list"></div> + </section> + </div> <section class="section plugins-results"> + <div class="shelf-head plugins-browse-head" id="plugins-browse-head"> + <div> + <h2 class="shelf-title">Browse everything</h2> + <p class="shelf-sub">the full index — search or pick a sort</p> + </div> + </div> + <div class="plugins-controls"> + <div class="plugins-search-wrap"> + <span class="plugins-search-icon" aria-hidden="true">/</span> + <input + id="plugins-search" + type="search" + class="plugins-search" + placeholder="search plugins, repositories, owners, languages…" + autocomplete="off" + spellcheck="false" + aria-label="Search plugins" + /> + </div> + <label class="plugins-sort-wrap"> + <span class="plugins-sort-label">sort</span> + <select id="plugins-sort" class="plugins-sort" aria-label="Sort plugins"> + <option value="popular">Popular</option> + <option value="trending">Trending</option> + <option value="recent">Recently updated</option> + <option value="newest">Newest</option> + <option value="name">Name</option> + </select> + </label> + </div> + <p id="plugins-meta" class="plugins-meta" aria-live="polite"></p> <div id="plugins-grid" class="plugins-grid" role="list"></div> <div id="plugins-status" class="plugins-status">Loading plugins…</div> <button id="plugins-more" class="button button-secondary plugins-more" hidden></button> @@ -119,11 +145,31 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; const moreButton = document.getElementById("plugins-more"); const heroCountEl = document.getElementById("plugins-hero-count"); const heroSummaryEl = document.getElementById("plugins-hero-summary"); + const shelvesWrap = document.getElementById("plugins-shelves"); + const trendingShelf = document.getElementById("shelf-trending"); + const trendGrid = document.getElementById("trend-grid"); + const newShelf = document.getElementById("shelf-new"); // 24 fills complete rows in both the 3-column and 2-column grid layouts. const PAGE_SIZE = 24; let visibleLimit = PAGE_SIZE; + // A shelf earns its place: percent growth only counts once a repository + // gained real stars this week, so the long tail (median: 1 star) can't + // flood trending with 1-to-3-star noise, while big repositories must grow + // at their own scale to appear. + const TRENDING_MIN_GAIN = 10; + // "New" means new to everyone: recently indexed AND a recently created + // repository. Established projects that just adopted the topic surface + // through trending instead of burying actual newcomers. + const NEW_SEEN_MAX_DAYS = 14; + const NEW_CREATED_MAX_DAYS = 60; + // Six cards fill complete shelf rows in the 3, 2, and 1 column layouts. + const SHELF_SIZE = 6; + // A shelf with one or two cards reads as a struggling marketplace, which + // is worse than no shelf at all. + const SHELF_MIN_CARDS = 3; + const LANG_COLORS = { TypeScript: "#3178c6", JavaScript: "#f1e05a", @@ -164,6 +210,109 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; return typeof url === "string" && /^https:\/\/github\.com\/[^/]+\/[^/]+/.test(url); } + function daysSince(iso) { + if (!iso) return null; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return null; + return (Date.now() - then) / 86400000; + } + + function trendingScore(p) { + const delta = p.starsDelta7d; + if (typeof delta !== "number" || delta < TRENDING_MIN_GAIN) return null; + return delta / Math.max((p.stars ?? 0) - delta, 1); + } + + function isNewToHerd(p) { + const seen = daysSince(p.firstSeenAt); + const created = daysSince(p.createdAt); + return ( + seen !== null && seen <= NEW_SEEN_MAX_DAYS && + created !== null && created <= NEW_CREATED_MAX_DAYS + ); + } + + function trendingBadge(p) { + const delta = p.starsDelta7d ?? 0; + const base = Math.max((p.stars ?? 0) - delta, 0); + return "▲ " + base + " → " + (p.stars ?? 0) + " ★ this week"; + } + + function joinedBadge(p) { + const ago = timeAgo(p.firstSeenAt); + return ago === "just now" ? "✦ just joined" : ago ? "✦ joined " + ago : null; + } + + function avatar(p, size) { + const img = el("img", "plugin-avatar"); + img.src = "https://github.com/" + encodeURIComponent(p.owner || "") + ".png?size=" + size; + img.alt = ""; + img.loading = "lazy"; + img.width = size / 2; + img.height = size / 2; + return img; + } + + function cardShell(p, className) { + const url = isGithubUrl(p.url) ? p.url : null; + const card = el(url ? "a" : "div", className); + card.setAttribute("role", "listitem"); + if (url) { + card.href = url; + card.target = "_blank"; + card.rel = "noopener noreferrer"; + } + return card; + } + + function titleOf(p) { + const title = el("div", "plugin-title"); + title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/")); + title.appendChild(el("span", "plugin-name", p.name || "unknown")); + return title; + } + + function buildTrendCard(p) { + const card = cardShell(p, "plugin-card plugin-trend-card"); + const head = el("div", "plugin-card-head"); + const lead = el("div", "trend-lead"); + lead.appendChild(avatar(p, 64)); + lead.appendChild(titleOf(p)); + head.appendChild(lead); + head.appendChild(el("span", "plugin-go", "↗")); + card.appendChild(head); + card.appendChild(el("div", "plugin-badge", trendingBadge(p))); + const desc = el( + "p", + "plugin-desc" + (p.description ? "" : " is-empty"), + p.description || "No description provided.", + ); + card.appendChild(desc); + return card; + } + + function buildRow(p, note) { + const row = cardShell(p, "plugin-row"); + row.appendChild(avatar(p, 64)); + const body = el("div", "plugin-row-body"); + body.appendChild(titleOf(p)); + const desc = el( + "span", + "plugin-row-desc" + (p.description ? "" : " is-empty"), + p.description || "No description provided.", + ); + body.appendChild(desc); + row.appendChild(body); + const side = el("div", "plugin-row-side"); + if (note) side.appendChild(el("span", "plugin-row-note", note)); + const stars = el("span", "plugin-stat"); + stars.appendChild(el("span", "plugin-star", "★")); + stars.appendChild(el("span", null, String(p.stars ?? 0))); + side.appendChild(stars); + row.appendChild(side); + return row; + } + function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; @@ -171,7 +320,7 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; return node; } - function buildCard(p) { + function buildCard(p, badge) { const url = isGithubUrl(p.url) ? p.url : null; const card = el(url ? "a" : "div", "plugin-card"); card.setAttribute("role", "listitem"); @@ -182,12 +331,13 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; } const head = el("div", "plugin-card-head"); - const title = el("div", "plugin-title"); - title.appendChild(el("span", "plugin-owner", (p.owner || "?") + "/")); - title.appendChild(el("span", "plugin-name", p.name || "unknown")); - head.appendChild(title); + const lead = el("div", "trend-lead"); + lead.appendChild(avatar(p, 64)); + lead.appendChild(titleOf(p)); + head.appendChild(lead); head.appendChild(el("span", "plugin-go", "↗")); card.appendChild(head); + if (badge) card.appendChild(el("div", "plugin-badge", badge)); const desc = el( "p", @@ -267,8 +417,22 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; const by = [...list]; const t = (v) => new Date(v || 0).getTime() || 0; if (mode === "recent") by.sort((a, b) => t(b.pushedAt) - t(a.pushedAt)); - else if (mode === "newest") by.sort((a, b) => t(b.createdAt) - t(a.createdAt)); + // "Newest" means newest to the marketplace, matching the shelf's order; + // GitHub's createdAt is the fallback for snapshots without firstSeenAt. + else if (mode === "newest") + by.sort( + (a, b) => t(b.firstSeenAt ?? b.createdAt) - t(a.firstSeenAt ?? a.createdAt), + ); else if (mode === "name") by.sort((a, b) => (a.fullName || "").localeCompare(b.fullName || "")); + else if (mode === "trending") + // Qualified movers first by growth rate, then everything else by raw + // weekly gain so the list stays fully ordered past the shelf criteria. + by.sort( + (a, b) => + (trendingScore(b) ?? -1) - (trendingScore(a) ?? -1) || + (b.starsDelta7d ?? 0) - (a.starsDelta7d ?? 0) || + (b.stars ?? 0) - (a.stars ?? 0), + ); else by.sort( (a, b) => (b.stars ?? 0) - (a.stars ?? 0) || t(b.pushedAt) - t(a.pushedAt), @@ -276,6 +440,37 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; return by; } + function renderShelves() { + const trending = repos + .map((p) => [p, trendingScore(p)]) + .filter(([, score]) => score !== null) + .sort((a, b) => b[1] - a[1]) + .slice(0, SHELF_SIZE) + .map(([p]) => p); + trendGrid.replaceChildren(); + if (trending.length >= SHELF_MIN_CARDS) { + for (const p of trending) trendGrid.appendChild(buildTrendCard(p)); + } + trendingShelf.hidden = trending.length < SHELF_MIN_CARDS; + + const fresh = repos + .filter(isNewToHerd) + .sort((a, b) => new Date(b.firstSeenAt).getTime() - new Date(a.firstSeenAt).getTime()) + .slice(0, SHELF_SIZE); + const newRows = newShelf.querySelector(".new-rows"); + newRows.replaceChildren(); + for (const p of fresh) newRows.appendChild(buildRow(p, joinedBadge(p))); + newShelf.hidden = fresh.length < SHELF_MIN_CARDS; + + const anyShelf = !trendingShelf.hidden || !newShelf.hidden; + shelvesWrap.dataset.available = anyShelf ? "1" : ""; + updateShelfVisibility(); + } + + function updateShelfVisibility() { + shelvesWrap.hidden = shelvesWrap.dataset.available !== "1"; + } + function render() { const q = searchInput.value.trim().toLowerCase(); const visible = sortRepos(repos.filter((p) => matches(p, q)), sortSelect.value); @@ -316,6 +511,16 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; " across " + repoCount.toLocaleString("en-US") + (repoCount === 1 ? " repository" : " repositories"); + const starsGained = repos.reduce( + (n, repo) => n + Math.max(repo.starsDelta7d ?? 0, 0), + 0, + ); + if (starsGained > 0) parts.push("+" + starsGained.toLocaleString("en-US") + " ★ this week"); + const joined = repos.filter((repo) => { + const seen = daysSince(repo.firstSeenAt); + return seen !== null && seen <= 7; + }).length; + if (joined > 0) parts.push(joined + " joined this week"); const ago = timeAgo(data && data.generatedAt); if (ago) parts.push("updated " + ago); if (usingFallback) parts.push("preview data"); @@ -325,6 +530,7 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; function apply(data) { repos = Array.isArray(data && data.plugins) ? data.plugins : []; setMeta(data); + renderShelves(); render(); } @@ -372,6 +578,13 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; visibleLimit += PAGE_SIZE; render(); }); + for (const button of document.querySelectorAll(".shelf-all")) { + button.addEventListener("click", () => { + sortSelect.value = button.dataset.sort; + resetAndRender(); + grid.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } load(); </script> @@ -387,6 +600,15 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; border-top: none; } + /* Everything below the hero sits inside the same horizontal gutter the + hero uses, so text never touches the page's column lines. Selectors + outrank site.css's `body.site .plugins-page .section` padding reset. */ + .plugins-page #plugins-shelves, + body.site .plugins-page .section.plugins-results, + body.site .plugins-page .section.plugins-publish { + padding-inline: var(--gut); + } + .plugins-page .plugins-hero { padding-top: 3rem; } @@ -442,25 +664,12 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; } /* Controls */ - .plugins-page .plugins-controls { - position: relative; + .plugins-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; - margin-top: 2.5rem; - padding-bottom: 1.75rem; - } - - /* Divider bound to the content box (padding box), so it aligns with the - search row and the cards instead of bleeding past them. */ - .plugins-controls::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - border-top: 1px solid var(--line); + margin-top: 1.5rem; } .plugins-search-wrap { @@ -543,17 +752,205 @@ const apiUrl = import.meta.env.DEV ? null : snapshotUrl; } .plugins-meta { - margin: 0; - margin-left: auto; + margin: 1rem 0 0; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; letter-spacing: 0.04em; } + /* Shelves */ + #plugins-shelves { + margin-top: 3.5rem; + } + + body.site .plugins-page .section.plugins-shelf { + margin-top: 4.5rem; + } + + body.site .plugins-page #plugins-shelves > .section.plugins-shelf:first-child { + margin-top: 0; + } + + .shelf-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + } + + /* Outranks style.css's `.section h2` mono heading rule. */ + body.site .plugins-page h2.shelf-title { + margin: 0; + color: var(--ink); + font-family: var(--disp); + font-weight: 800; + font-size: 21px; + letter-spacing: -0.03em; + line-height: 1.2; + } + + .shelf-sub { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.82rem; + } + + .shelf-all { + flex: 0 0 auto; + padding: 0; + background: none; + border: none; + color: var(--accent); + font-family: var(--mono); + font-size: 0.82rem; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + } + + .shelf-all:hover { + color: var(--ink); + } + + .plugin-badge { + align-self: flex-start; + padding: 0.15rem 0.5rem; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.02em; + white-space: nowrap; + } + + .plugin-avatar { + width: 1.5rem; + height: 1.5rem; + flex: 0 0 auto; + border-radius: var(--radius-sm); + background: var(--bg); + border: 1px solid var(--line); + } + + .trend-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.85rem; + } + + .plugin-trend-card { + min-height: 0; + gap: 0.65rem; + padding: 1.1rem 1.2rem; + } + + .trend-lead { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; + } + + .new-rows { + display: flex; + flex-direction: column; + } + + .plugin-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 0.5rem; + border-top: 1px solid var(--line); + border-radius: var(--radius-sm); + transition: background 0.12s ease; + min-width: 0; + } + + .plugin-row:last-child { + border-bottom: 1px solid var(--line); + } + + a.plugin-row:hover { + background: var(--bg-elevated); + } + + .plugin-row-body { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; + } + + .plugin-row-body .plugin-title { + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .plugin-row-desc { + color: var(--muted); + font-size: 0.78rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .plugin-row-desc.is-empty { + color: var(--muted-2); + font-style: italic; + } + + .plugin-row-side { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; + flex: 0 0 auto; + color: var(--muted); + font-family: var(--mono); + font-size: 0.75rem; + } + + .plugin-row-note { + color: var(--accent); + white-space: nowrap; + } + + .plugins-browse-head { + margin-bottom: 1rem; + padding-top: 2.25rem; + border-top: 1px solid var(--line); + } + + .plugins-browse-head[hidden] { + display: none; + } + + @media (max-width: 56rem) { + .trend-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 38rem) { + .trend-grid { + grid-template-columns: minmax(0, 1fr); + } + } + /* Grid */ - .plugins-results { - margin-top: 2rem; + body.site .plugins-page .section.plugins-results { + margin-top: 4.5rem; + } + + .plugins-results .plugins-grid { + margin-top: 1.5rem; } .plugins-grid { |
