From eac54b84b8335f0ec9b941a2dcb8da25d860fc08 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 18 Aug 2026 01:33:30 +0300 Subject: feat(website): plugin marketplace discovery shelves — trending and new arrivals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/src/pages/plugins.astro | 503 +++++++++++++++++++++++++++++++++++----- 1 file changed, 450 insertions(+), 53 deletions(-) (limited to 'website/src') 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; -
-
- - -
- -
- -
-

-
+
+
+
+

Browse everything

+

the full index — search or pick a sort

+
+
+
+
+ + +
+ +
+

Loading plugins…
@@ -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(); @@ -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 { -- cgit v1.2.3