@grove-dev/astro 0.5.0-next.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +0 -2
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +0 -2
- package/dist/lib/index.js.map +1 -1
- package/dist/ui/button.d.ts +45 -0
- package/dist/ui/button.d.ts.map +1 -0
- package/dist/ui/button.js +82 -0
- package/dist/ui/button.js.map +1 -0
- package/package.json +8 -5
- package/src/components/CardGrid.astro +41 -0
- package/src/components/CardIcon.astro +67 -0
- package/src/components/CategoryGrid.astro +20 -5
- package/src/components/CollectionCard.astro +56 -0
- package/src/components/CollectionIndex.astro +21 -26
- package/src/components/CollectionPage.astro +36 -13
- package/src/components/CollectionRow.astro +62 -192
- package/src/components/CollectionTeaser.astro +4 -0
- package/src/components/ContributorsGrid.astro +4 -1
- package/src/components/DirectoryIndexClient.astro +127 -30
- package/src/components/EditorialSummary.astro +6 -5
- package/src/components/FilterGroupMenu.astro +24 -10
- package/src/components/FilterOptions.astro +6 -1
- package/src/components/FinalCta.astro +5 -3
- package/src/components/Hero.astro +85 -139
- package/src/components/IndexRow.astro +36 -69
- package/src/components/LanguageBreakdown.astro +5 -1
- package/src/components/OriginalCollection.astro +3 -2
- package/src/components/Pagination.astro +7 -20
- package/src/components/ProjectCard.astro +345 -0
- package/src/components/RecordHeader.astro +111 -82
- package/src/components/RecordSection.astro +14 -10
- package/src/components/RecordSidebar.astro +68 -20
- package/src/components/RefinePanel.astro +41 -62
- package/src/components/SmartLensTabs.astro +3 -7
- package/src/components/StackGrid.astro +26 -6
- package/src/components/SubmissionClient.astro +140 -63
- package/src/components/TableOfContents.astro +31 -5
- package/src/components/WhyThisExists.astro +1 -1
- package/src/index.ts +2 -2
- package/src/layouts/BaseLayout.astro +79 -9
- package/src/layouts/Header.astro +5 -4
- package/src/layouts/SectionHeader.astro +3 -2
- package/src/layouts/Seo.astro +28 -17
- package/src/layouts/ThemeToggle.astro +23 -6
- package/src/lib/index.ts +0 -2
- package/src/server/collections.ts +11 -0
- package/src/server/contrast.test.ts +82 -0
- package/src/server/contrast.ts +117 -0
- package/src/server/directory.test.ts +177 -0
- package/src/server/directory.ts +328 -206
- package/src/server/github-repo.test.ts +88 -0
- package/src/server/github-repo.ts +104 -0
- package/src/server/index.ts +4 -3
- package/src/server/models-home.test.ts +101 -0
- package/src/server/models.test.ts +135 -0
- package/src/server/models.ts +170 -42
- package/src/styles.css +128 -16
- package/dist/lib/scores.d.ts +0 -2
- package/dist/lib/scores.d.ts.map +0 -1
- package/dist/lib/scores.js +0 -2
- package/dist/lib/scores.js.map +0 -1
- package/src/components/CurationGrid.astro +0 -41
- package/src/components/DecisionRow.astro +0 -89
- package/src/components/ExploreByCategory.astro +0 -67
- package/src/components/ExploreByStack.astro +0 -78
- package/src/components/GroveDocumentHead.astro +0 -28
- package/src/components/ItemCard.astro +0 -324
- package/src/components/MinimalAbout.astro +0 -82
- package/src/components/ScoreBars.astro +0 -102
- package/src/lib/scores.ts +0 -1
|
@@ -19,15 +19,29 @@
|
|
|
19
19
|
* scrolls the body. Cards are individually rendered so the
|
|
20
20
|
* sticky container's height is bounded by the actual content.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { licenseDisplay } from "@grove-dev/core";
|
|
23
23
|
import type { RecordDetailModel } from "../server/models.js";
|
|
24
|
+
import { taxonomyLabel } from "../server/directory.js";
|
|
25
|
+
import Icon from "./Icon.astro";
|
|
24
26
|
import LanguageBreakdown from "./LanguageBreakdown.astro";
|
|
25
27
|
|
|
26
28
|
interface Props {
|
|
27
29
|
detail: RecordDetailModel;
|
|
30
|
+
/**
|
|
31
|
+
* Pass false when a parent wrapper owns the sticky behavior (e.g.
|
|
32
|
+
* the detail page stacks the TOC above this sidebar inside one
|
|
33
|
+
* sticky column).
|
|
34
|
+
*/
|
|
35
|
+
sticky?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* "column" (default) stacks the cards vertically for a rail;
|
|
38
|
+
* "grid" lays them out in responsive columns for full-width
|
|
39
|
+
* placement below the article.
|
|
40
|
+
*/
|
|
41
|
+
layout?: "column" | "grid";
|
|
28
42
|
}
|
|
29
43
|
|
|
30
|
-
const { detail } = Astro.props;
|
|
44
|
+
const { detail, sticky = true, layout = "column" } = Astro.props;
|
|
31
45
|
const {
|
|
32
46
|
sidebar,
|
|
33
47
|
github,
|
|
@@ -35,10 +49,8 @@ const {
|
|
|
35
49
|
forks,
|
|
36
50
|
language,
|
|
37
51
|
licenseSpdx,
|
|
38
|
-
pushedAt,
|
|
39
52
|
pushedLabel,
|
|
40
53
|
repoUrl,
|
|
41
|
-
homepageUrl,
|
|
42
54
|
ownerRepo,
|
|
43
55
|
healthLabel,
|
|
44
56
|
activityBadge,
|
|
@@ -71,16 +83,32 @@ function row(label: string, value: unknown) {
|
|
|
71
83
|
}
|
|
72
84
|
---
|
|
73
85
|
|
|
74
|
-
<aside
|
|
86
|
+
<aside
|
|
87
|
+
class:list={[
|
|
88
|
+
layout === "grid"
|
|
89
|
+
? "grove-sidebar grid grid-cols-1 items-start gap-4 sm:grid-cols-2 xl:grid-cols-4"
|
|
90
|
+
: "grove-sidebar flex flex-col gap-4",
|
|
91
|
+
layout === "column" &&
|
|
92
|
+
sticky &&
|
|
93
|
+
"lg:sticky lg:top-20 lg:max-h-[calc(100vh-5rem)] lg:self-start lg:overflow-y-auto",
|
|
94
|
+
]}
|
|
95
|
+
aria-label="Record metadata"
|
|
96
|
+
>
|
|
75
97
|
|
|
76
98
|
{/* ── Activity card ──────────────────────────────────────── */}
|
|
77
99
|
{sidebar.showActivity && (
|
|
78
|
-
<section
|
|
79
|
-
|
|
100
|
+
<section
|
|
101
|
+
class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
|
|
102
|
+
aria-labelledby="sidebar-activity-title"
|
|
103
|
+
>
|
|
104
|
+
<h2
|
|
105
|
+
id="sidebar-activity-title"
|
|
106
|
+
class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
|
|
107
|
+
>
|
|
80
108
|
Activity
|
|
81
109
|
</h2>
|
|
82
110
|
<dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
|
|
83
|
-
{row("Stars", starsNumber !== null ?
|
|
111
|
+
{row("Stars", starsNumber !== null ? starsNumber : null) && (
|
|
84
112
|
<div class="flex items-baseline justify-between gap-3">
|
|
85
113
|
<dt class="text-ink-500 dark:text-ink-400">Stars</dt>
|
|
86
114
|
<dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{starsNumber!.toLocaleString()}</dd>
|
|
@@ -159,8 +187,14 @@ function row(label: string, value: unknown) {
|
|
|
159
187
|
|
|
160
188
|
{/* ── Freshness card ─────────────────────────────────────── */}
|
|
161
189
|
{sidebar.showFreshness && (
|
|
162
|
-
<section
|
|
163
|
-
|
|
190
|
+
<section
|
|
191
|
+
class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
|
|
192
|
+
aria-labelledby="sidebar-freshness-title"
|
|
193
|
+
>
|
|
194
|
+
<h2
|
|
195
|
+
id="sidebar-freshness-title"
|
|
196
|
+
class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
|
|
197
|
+
>
|
|
164
198
|
Freshness
|
|
165
199
|
</h2>
|
|
166
200
|
<dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
|
|
@@ -226,8 +260,14 @@ function row(label: string, value: unknown) {
|
|
|
226
260
|
|
|
227
261
|
{/* ── Ecosystem card ─────────────────────────────────────── */}
|
|
228
262
|
{sidebar.showEcosystem && (
|
|
229
|
-
<section
|
|
230
|
-
|
|
263
|
+
<section
|
|
264
|
+
class="grove-card rounded-[var(--radius-lg)] border border-ink-200 bg-card p-4 dark:border-ink-800"
|
|
265
|
+
aria-labelledby="sidebar-ecosystem-title"
|
|
266
|
+
>
|
|
267
|
+
<h2
|
|
268
|
+
id="sidebar-ecosystem-title"
|
|
269
|
+
class="grove-card-title m-0 mb-3 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
|
|
270
|
+
>
|
|
231
271
|
Ecosystem
|
|
232
272
|
</h2>
|
|
233
273
|
|
|
@@ -237,8 +277,9 @@ function row(label: string, value: unknown) {
|
|
|
237
277
|
<ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
|
238
278
|
{stacks.map((s) => (
|
|
239
279
|
<li>
|
|
240
|
-
<a href={`/${detail.slug}?stack=${encodeURIComponent(s)}`} class="rounded-full bg-secondary
|
|
241
|
-
{s}
|
|
280
|
+
<a href={`/${detail.slug}?stack=${encodeURIComponent(s)}`} class="inline-flex items-center gap-1.5 rounded-full bg-secondary py-0.5 pl-1.5 pr-2 text-2xs font-medium text-secondary-foreground no-underline">
|
|
281
|
+
<Icon name={s} category="stack" size={12} />
|
|
282
|
+
{taxonomyLabel("stacks", s)}
|
|
242
283
|
</a>
|
|
243
284
|
</li>
|
|
244
285
|
))}
|
|
@@ -251,8 +292,9 @@ function row(label: string, value: unknown) {
|
|
|
251
292
|
<h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Platforms</h3>
|
|
252
293
|
<ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
|
253
294
|
{platforms.map((p) => (
|
|
254
|
-
<li class="rounded-full border border-ink-200
|
|
255
|
-
{p}
|
|
295
|
+
<li class="inline-flex items-center gap-1.5 rounded-full border border-ink-200 py-0.5 pl-1.5 pr-2 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
|
|
296
|
+
<Icon name={p} category="platform" size={12} />
|
|
297
|
+
{taxonomyLabel("platforms", p)}
|
|
256
298
|
</li>
|
|
257
299
|
))}
|
|
258
300
|
</ul>
|
|
@@ -263,7 +305,7 @@ function row(label: string, value: unknown) {
|
|
|
263
305
|
<div class="mb-3">
|
|
264
306
|
<h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Category</h3>
|
|
265
307
|
<a href={`/${detail.slug}?category=${encodeURIComponent(category)}`} class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground no-underline">
|
|
266
|
-
{category}
|
|
308
|
+
{taxonomyLabel("categories", category)}
|
|
267
309
|
</a>
|
|
268
310
|
</div>
|
|
269
311
|
)}
|
|
@@ -275,7 +317,7 @@ function row(label: string, value: unknown) {
|
|
|
275
317
|
{tags.map((t) => (
|
|
276
318
|
<li>
|
|
277
319
|
<a href={`/${detail.slug}?tag=${encodeURIComponent(t)}`} class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 no-underline dark:border-ink-800 dark:text-ink-300">
|
|
278
|
-
{t}
|
|
320
|
+
{taxonomyLabel("topics", t)}
|
|
279
321
|
</a>
|
|
280
322
|
</li>
|
|
281
323
|
))}
|
|
@@ -309,8 +351,14 @@ function row(label: string, value: unknown) {
|
|
|
309
351
|
|
|
310
352
|
{/* ── Source card (small) ────────────────────────────────── */}
|
|
311
353
|
{sidebar.showSource && (
|
|
312
|
-
<section
|
|
313
|
-
|
|
354
|
+
<section
|
|
355
|
+
class="grove-card grove-card--muted rounded-[var(--radius-lg)] border border-dashed border-ink-200 bg-card/50 p-4 text-2xs text-ink-500 dark:border-ink-800 dark:text-ink-400"
|
|
356
|
+
aria-labelledby="sidebar-source-title"
|
|
357
|
+
>
|
|
358
|
+
<h2
|
|
359
|
+
id="sidebar-source-title"
|
|
360
|
+
class="grove-card-title m-0 mb-1.5 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400"
|
|
361
|
+
>
|
|
314
362
|
Source
|
|
315
363
|
</h2>
|
|
316
364
|
<p class="m-0 leading-relaxed">
|
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* RefinePanel — multi-select facet dropdowns.
|
|
4
4
|
*
|
|
5
|
-
* Renders
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Renders the ordered `groups` view-model produced by
|
|
6
|
+
* `getDirectoryIndexModel().facetGroups` — visibility, ORDER, display
|
|
7
|
+
* label, and selection mode all come from `browse.facets` + the core
|
|
8
|
+
* facet registry, never from this component. Option labels arrive
|
|
9
|
+
* pre-resolved from the taxonomy YAML, so the server render and the
|
|
10
|
+
* client script share one label source.
|
|
9
11
|
*
|
|
10
12
|
* The form is a GET to the list page base, with each facet as a
|
|
11
13
|
* repeated key. Empty values are dropped, so the URL stays clean.
|
|
12
|
-
*
|
|
13
|
-
* Generic: takes a `facets` map of `{ value, label, count }[]` per
|
|
14
|
-
* dimension and an `initial` `IndexFilters` for preselecting the
|
|
15
|
-
* current URL state.
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
16
|
import type { IndexFilters } from "../lib/search";
|
|
@@ -25,78 +23,50 @@ interface FacetValue {
|
|
|
25
23
|
count?: number;
|
|
26
24
|
}
|
|
27
25
|
|
|
28
|
-
interface
|
|
29
|
-
|
|
30
|
-
platforms
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
interface FacetGroupModel {
|
|
27
|
+
id: string;
|
|
28
|
+
filterKey: "stacks" | "platforms" | "categories" | "tags" | "licenses";
|
|
29
|
+
label: string;
|
|
30
|
+
single?: boolean;
|
|
31
|
+
options: FacetValue[];
|
|
32
|
+
initial: string[];
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
interface Props {
|
|
38
|
-
|
|
36
|
+
/** Ordered filter groups (see `getDirectoryIndexModel`). */
|
|
37
|
+
groups: FacetGroupModel[];
|
|
38
|
+
/** Current URL filter state — used to preselect the sort. */
|
|
39
39
|
initial: IndexFilters;
|
|
40
40
|
pathPrefix?: string;
|
|
41
41
|
class?: string;
|
|
42
|
-
/** Hide
|
|
43
|
-
hide?: Array<"
|
|
42
|
+
/** Hide the sort select (e.g. when the page renders its own). */
|
|
43
|
+
hide?: Array<"sort">;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const {
|
|
47
|
-
|
|
47
|
+
groups: groupModels,
|
|
48
48
|
initial,
|
|
49
49
|
pathPrefix = "/",
|
|
50
50
|
class: className = "",
|
|
51
51
|
hide = [],
|
|
52
52
|
} = Astro.props;
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
label: string;
|
|
56
|
-
filterKey: "stacks" | "platforms" | "categories" | "tags" | "licenses";
|
|
57
|
-
options: FacetValue[];
|
|
58
|
-
initial: string[];
|
|
59
|
-
single?: boolean;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface RenderedFilterGroup extends FilterGroup {
|
|
63
|
-
active: boolean;
|
|
64
|
-
popoverId: string;
|
|
65
|
-
trigger: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const baseGroups: FilterGroup[] = [
|
|
69
|
-
{ label: "Stack", filterKey: "stacks", options: facets.stacks ?? [], initial: initial.stacks ?? [] },
|
|
70
|
-
{ label: "Platform", filterKey: "platforms", options: facets.platforms ?? [], initial: initial.platforms ?? [] },
|
|
71
|
-
{ label: "Category", filterKey: "categories", options: facets.categories ?? [], initial: initial.categories ?? [] },
|
|
72
|
-
{ label: "Tag", filterKey: "tags", options: facets.tags ?? [], initial: initial.tags ?? [] },
|
|
73
|
-
{ label: "License", filterKey: "licenses", options: facets.licenses ?? [], initial: initial.licenses ?? [], single: true },
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
function pretty(value: string): string {
|
|
77
|
-
return value
|
|
78
|
-
.split(/[-_]/)
|
|
79
|
-
.map((part) => part ? part[0].toUpperCase() + part.slice(1) : part)
|
|
80
|
-
.join(" ");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function triggerLabel(group: FilterGroup): string {
|
|
54
|
+
function triggerLabel(group: FacetGroupModel): string {
|
|
84
55
|
if (group.initial.length === 0) return `${group.label}: Any`;
|
|
85
|
-
if (group.initial.length === 1)
|
|
56
|
+
if (group.initial.length === 1) {
|
|
57
|
+
const selected = group.initial[0];
|
|
58
|
+
const option = group.options.find((candidate) => candidate.value === selected);
|
|
59
|
+
return `${group.label}: ${option?.label ?? selected}`;
|
|
60
|
+
}
|
|
86
61
|
return `${group.label}: ${group.initial.length} selected`;
|
|
87
62
|
}
|
|
88
63
|
|
|
89
|
-
const groups
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
...group,
|
|
96
|
-
active: group.initial.length > 0,
|
|
97
|
-
popoverId: `filter-popover-${group.filterKey}`,
|
|
98
|
-
trigger: triggerLabel(group),
|
|
99
|
-
}));
|
|
64
|
+
const groups = groupModels.map((group) => ({
|
|
65
|
+
...group,
|
|
66
|
+
active: group.initial.length > 0,
|
|
67
|
+
popoverId: `filter-popover-${group.filterKey}`,
|
|
68
|
+
trigger: triggerLabel(group),
|
|
69
|
+
}));
|
|
100
70
|
---
|
|
101
71
|
|
|
102
72
|
<div class:list={["flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
|
|
@@ -160,7 +130,16 @@ const groups: RenderedFilterGroup[] = baseGroups
|
|
|
160
130
|
popover?.addEventListener("click", (event) => event.stopPropagation());
|
|
161
131
|
});
|
|
162
132
|
document.addEventListener("click", () => closeAll());
|
|
163
|
-
document.addEventListener("keydown", (event) => {
|
|
133
|
+
document.addEventListener("keydown", (event) => {
|
|
134
|
+
if (event.key !== "Escape") return;
|
|
135
|
+
// Restore focus to the trigger whose popover was open so
|
|
136
|
+
// keyboard users don't lose their place.
|
|
137
|
+
const openGroup = [...groups].find((group) =>
|
|
138
|
+
group.querySelector('.grove-filter-trigger[aria-expanded="true"]'),
|
|
139
|
+
);
|
|
140
|
+
closeAll();
|
|
141
|
+
openGroup?.querySelector(".grove-filter-trigger")?.focus();
|
|
142
|
+
});
|
|
164
143
|
document.querySelector('select[name="sort"]')?.addEventListener("change", (event) => {
|
|
165
144
|
const params = new URLSearchParams(location.search);
|
|
166
145
|
params.set("sort", event.target.value);
|
|
@@ -17,13 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import { LENSES, hrefForLens, isLensActive } from "../lib/lenses";
|
|
19
19
|
import { lensDisplay } from "../lib/display";
|
|
20
|
+
import { lensTabClass } from "../ui/button.js";
|
|
20
21
|
|
|
21
22
|
interface Props {
|
|
22
23
|
currentParams: URLSearchParams;
|
|
23
24
|
pathPrefix?: string;
|
|
24
25
|
class?: string;
|
|
25
26
|
/** Override the visible lens list (defaults to LENSES). */
|
|
26
|
-
lensIds?: string[];
|
|
27
|
+
lensIds?: readonly string[];
|
|
27
28
|
/** Consumer noun for the unfiltered lens, e.g. "All apps". */
|
|
28
29
|
allLabel?: string;
|
|
29
30
|
}
|
|
@@ -57,12 +58,7 @@ const lenses =
|
|
|
57
58
|
return (
|
|
58
59
|
<a
|
|
59
60
|
href={hrefForLens(lens.id, currentParams, pathPrefix)}
|
|
60
|
-
class
|
|
61
|
-
"inline-flex h-9 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-[0.8125rem] font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35",
|
|
62
|
-
active
|
|
63
|
-
? "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
|
64
|
-
: "border-border bg-secondary text-foreground hover:bg-accent",
|
|
65
|
-
]}
|
|
61
|
+
class={lensTabClass(active)}
|
|
66
62
|
aria-current={active ? "page" : undefined}
|
|
67
63
|
data-lens-id={lens.id}
|
|
68
64
|
data-event="lens_tab_click"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { formatCount } from "@grove-dev/core";
|
|
2
3
|
import Icon from "./Icon.astro";
|
|
3
4
|
/**
|
|
4
5
|
* StackGrid — browse-by-stack grid for the home page.
|
|
@@ -32,6 +33,16 @@ interface Props {
|
|
|
32
33
|
/** Hide the section eyebrow / header entirely. */
|
|
33
34
|
bare?: boolean;
|
|
34
35
|
itemLabelPlural?: string;
|
|
36
|
+
/** Noun pair for count chips — singular fixes "1 projects". */
|
|
37
|
+
itemNoun?: { singular: string; plural: string };
|
|
38
|
+
/** Pass 1 when this grid IS the page (`/stacks` index). */
|
|
39
|
+
headingLevel?: 1 | 2;
|
|
40
|
+
/**
|
|
41
|
+
* Card link builder. Defaults to the browse page filtered by stack
|
|
42
|
+
* (`{pathPrefix}?stack=…`); pass e.g. `(slug) => \`/stacks/${slug}\``
|
|
43
|
+
* to link to taxonomy detail routes instead.
|
|
44
|
+
*/
|
|
45
|
+
hrefFor?: (slug: string) => string;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
const {
|
|
@@ -41,9 +52,15 @@ const {
|
|
|
41
52
|
class: className = "",
|
|
42
53
|
bare = false,
|
|
43
54
|
itemLabelPlural = "items",
|
|
55
|
+
itemNoun = { singular: "item", plural: "items" },
|
|
56
|
+
headingLevel = 2,
|
|
57
|
+
hrefFor,
|
|
44
58
|
} = Astro.props;
|
|
45
59
|
|
|
60
|
+
const Heading = `h${headingLevel}` as "h1" | "h2";
|
|
61
|
+
|
|
46
62
|
function buildHref(slug: string): string {
|
|
63
|
+
if (hrefFor) return hrefFor(slug);
|
|
47
64
|
const base = pathPrefix.replace(/\/$/, "");
|
|
48
65
|
return `${base}?stack=${encodeURIComponent(slug)}`;
|
|
49
66
|
}
|
|
@@ -73,12 +90,12 @@ function statusLabel(s: StackEntry["status"]): string | null {
|
|
|
73
90
|
<span class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
74
91
|
Stacks
|
|
75
92
|
</span>
|
|
76
|
-
<
|
|
93
|
+
<Heading
|
|
77
94
|
id="grove-stackgrid-heading"
|
|
78
95
|
class="m-0 mt-1 text-[1.5rem] sm:text-[1.625rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100"
|
|
79
96
|
>
|
|
80
97
|
Browse by stack
|
|
81
|
-
</
|
|
98
|
+
</Heading>
|
|
82
99
|
<p class="m-0 max-w-[60ch] text-[0.9375rem] text-ink-500 dark:text-ink-400">
|
|
83
100
|
Pick a stack to see real {itemLabelPlural} built with it.
|
|
84
101
|
</p>
|
|
@@ -89,7 +106,7 @@ function statusLabel(s: StackEntry["status"]): string | null {
|
|
|
89
106
|
{stacks.map((s) => (
|
|
90
107
|
<a
|
|
91
108
|
href={buildHref(s.slug)}
|
|
92
|
-
class="rounded-[calc(var(--radius)+0.25rem)] border-
|
|
109
|
+
class="rounded-[calc(var(--radius)+0.25rem)] border border-border bg-card shadow-none transition-colors hover:border-ink-300 dark:hover:border-ink-700 group flex h-full items-center gap-3 p-3.5 no-underline"
|
|
93
110
|
data-event="stack_grid_click"
|
|
94
111
|
data-event-source="stack_grid"
|
|
95
112
|
>
|
|
@@ -99,12 +116,15 @@ function statusLabel(s: StackEntry["status"]): string | null {
|
|
|
99
116
|
|
|
100
117
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
101
118
|
<div class="flex items-center gap-2">
|
|
102
|
-
|
|
119
|
+
{/* Not a heading: these tiles are links in a grid, and
|
|
120
|
+
heading-per-card breaks the outline when the grid
|
|
121
|
+
sits directly under the page h1 (/stacks). */}
|
|
122
|
+
<span class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
|
|
103
123
|
{s.name}
|
|
104
|
-
</
|
|
124
|
+
</span>
|
|
105
125
|
{typeof s.count === "number" && (
|
|
106
126
|
<span class="inline-flex items-center rounded-md border border-ink-200 bg-ink-50 px-1.5 py-0.5 font-mono text-[10px] font-medium text-ink-600 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-400">
|
|
107
|
-
{s.count
|
|
127
|
+
{formatCount(s.count, itemNoun)}
|
|
108
128
|
</span>
|
|
109
129
|
)}
|
|
110
130
|
</div>
|