@grove-dev/astro 0.6.1 → 0.8.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.
Files changed (111) hide show
  1. package/README.md +77 -5
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -33
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/display.d.ts +1 -1
  7. package/dist/lib/display.js +1 -1
  8. package/dist/lib/format.d.ts +1 -1
  9. package/dist/lib/format.js +1 -1
  10. package/dist/lib/icon-kinds.d.ts +1 -1
  11. package/dist/lib/icon-kinds.js +43 -43
  12. package/dist/lib/icon-registry.d.ts +2 -2
  13. package/dist/lib/icon-registry.js +23 -23
  14. package/dist/lib/index.d.ts +9 -9
  15. package/dist/lib/index.d.ts.map +1 -1
  16. package/dist/lib/index.js +9 -9
  17. package/dist/lib/index.js.map +1 -1
  18. package/dist/lib/lenses.d.ts +1 -1
  19. package/dist/lib/lenses.js +1 -1
  20. package/dist/lib/packaged-icons.d.ts +1 -1
  21. package/dist/lib/packaged-icons.js +4 -4
  22. package/dist/lib/repo.d.ts +1 -1
  23. package/dist/lib/repo.js +1 -1
  24. package/dist/lib/search.d.ts +1 -1
  25. package/dist/lib/search.js +1 -1
  26. package/dist/lib/taxonomy-counts.d.ts +1 -1
  27. package/dist/lib/taxonomy-counts.js +1 -1
  28. package/package.json +7 -17
  29. package/src/index.ts +23 -38
  30. package/src/lib/display.ts +1 -1
  31. package/src/lib/format.ts +1 -1
  32. package/src/lib/icon-kinds.ts +44 -44
  33. package/src/lib/icon-registry.ts +25 -25
  34. package/src/lib/index.ts +9 -9
  35. package/src/lib/lenses.ts +1 -1
  36. package/src/lib/packaged-icons.ts +8 -8
  37. package/src/lib/repo.ts +1 -1
  38. package/src/lib/search.ts +1 -1
  39. package/src/lib/taxonomy-counts.ts +1 -1
  40. package/src/server/collections.ts +53 -42
  41. package/src/server/contrast.ts +14 -9
  42. package/src/server/directory.ts +22 -17
  43. package/src/server/index.ts +3 -2
  44. package/src/server/models.ts +404 -213
  45. package/src/server/seo.ts +34 -31
  46. package/src/styles.css +235 -91
  47. package/dist/layouts/index.d.ts +0 -25
  48. package/dist/layouts/index.d.ts.map +0 -1
  49. package/dist/layouts/index.js +0 -25
  50. package/dist/layouts/index.js.map +0 -1
  51. package/dist/ui/button.d.ts +0 -53
  52. package/dist/ui/button.d.ts.map +0 -1
  53. package/dist/ui/button.js +0 -90
  54. package/dist/ui/button.js.map +0 -1
  55. package/src/components/CardGrid.astro +0 -41
  56. package/src/components/CardIcon.astro +0 -67
  57. package/src/components/CategoryGrid.astro +0 -114
  58. package/src/components/CollectionCard.astro +0 -56
  59. package/src/components/CollectionIndex.astro +0 -102
  60. package/src/components/CollectionPage.astro +0 -128
  61. package/src/components/CollectionRow.astro +0 -81
  62. package/src/components/CollectionTeaser.astro +0 -47
  63. package/src/components/ContributorsGrid.astro +0 -143
  64. package/src/components/DirectoryIndexClient.astro +0 -510
  65. package/src/components/EditorialSummary.astro +0 -58
  66. package/src/components/FilterGroupMenu.astro +0 -69
  67. package/src/components/FilterOptions.astro +0 -49
  68. package/src/components/FinalCta.astro +0 -43
  69. package/src/components/Hero.astro +0 -243
  70. package/src/components/Icon.astro +0 -191
  71. package/src/components/IndexRow.astro +0 -37
  72. package/src/components/LanguageBreakdown.astro +0 -67
  73. package/src/components/MarkdownBody.astro +0 -37
  74. package/src/components/OriginalCollection.astro +0 -117
  75. package/src/components/Pagination.astro +0 -50
  76. package/src/components/PoweredBy.astro +0 -60
  77. package/src/components/ProjectCard.astro +0 -321
  78. package/src/components/RecordHeader.astro +0 -221
  79. package/src/components/RecordSection.astro +0 -128
  80. package/src/components/RecordSidebar.astro +0 -387
  81. package/src/components/RefinePanel.astro +0 -168
  82. package/src/components/SmartLensTabs.astro +0 -81
  83. package/src/components/StackGrid.astro +0 -151
  84. package/src/components/StackPlatformChips.astro +0 -73
  85. package/src/components/SubmissionClient.astro +0 -254
  86. package/src/components/TableOfContents.astro +0 -140
  87. package/src/components/WhyThisExists.astro +0 -116
  88. package/src/layouts/BaseLayout.astro +0 -415
  89. package/src/layouts/Container.astro +0 -18
  90. package/src/layouts/Footer.astro +0 -231
  91. package/src/layouts/Header.astro +0 -214
  92. package/src/layouts/SectionHeader.astro +0 -43
  93. package/src/layouts/Seo.astro +0 -171
  94. package/src/layouts/ThemeToggle.astro +0 -118
  95. package/src/layouts/astro-shim.d.ts +0 -29
  96. package/src/layouts/index.ts +0 -24
  97. package/src/server/collections.test.ts +0 -212
  98. package/src/server/contrast.test.ts +0 -82
  99. package/src/server/directory.test.ts +0 -177
  100. package/src/server/github-repo.test.ts +0 -88
  101. package/src/server/models-home.test.ts +0 -101
  102. package/src/server/models.test.ts +0 -135
  103. package/src/server/seo.test.ts +0 -123
  104. package/src/ui/Badge.astro +0 -38
  105. package/src/ui/Button.astro +0 -42
  106. package/src/ui/EmptyState.astro +0 -38
  107. package/src/ui/FilterDrawer.astro +0 -124
  108. package/src/ui/PageHeader.astro +0 -39
  109. package/src/ui/SearchField.astro +0 -106
  110. package/src/ui/button.test.ts +0 -54
  111. package/src/ui/button.ts +0 -107
@@ -1,387 +0,0 @@
1
- ---
2
- /**
3
- * RecordSidebar.astro
4
- *
5
- * Sticky right-hand column on the record detail page. Renders
6
- * up to four cards, each conditional on the model's `sidebar`
7
- * visibility flags:
8
- *
9
- * - Activity — repo facts (stars, forks, language, license, …)
10
- * - Freshness — last commit / last fetch / maintenance status
11
- * - Ecosystem — stack, platforms, category, tags, hygiene files
12
- * - Source — curation metadata + reading-time summary
13
- *
14
- * Empty rows are suppressed entirely — the sidebar never shows "—"
15
- * or "0" placeholders that confuse "no data" with "zero".
16
- *
17
- * Sticky on lg+ (`lg:sticky lg:top-20 lg:max-h-calc(100vh-5rem)
18
- * lg:overflow-y-auto`) so the cards stay in view while the user
19
- * scrolls the body. Cards are individually rendered so the
20
- * sticky container's height is bounded by the actual content.
21
- */
22
- import { licenseDisplay } from "@grove-dev/core";
23
- import type { RecordDetailModel } from "../server/models.js";
24
- import { taxonomyLabel } from "../server/directory.js";
25
- import Icon from "./Icon.astro";
26
- import LanguageBreakdown from "./LanguageBreakdown.astro";
27
-
28
- interface Props {
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";
42
- }
43
-
44
- const { detail, sticky = true, layout = "column" } = Astro.props;
45
- const {
46
- sidebar,
47
- github,
48
- stars,
49
- forks,
50
- language,
51
- licenseSpdx,
52
- pushedLabel,
53
- repoUrl,
54
- ownerRepo,
55
- healthLabel,
56
- activityBadge,
57
- stacks,
58
- platforms,
59
- tags,
60
- category,
61
- contributionSignals,
62
- languages,
63
- totalLanguageBytes,
64
- readingMetrics,
65
- record,
66
- } = detail;
67
-
68
- // Visibility helpers for individual rows inside the Activity card.
69
- // `hasRepo` is the discriminator between "GitHub data synced" and
70
- // "no data fetched yet" — without it the card would show "Stars 0"
71
- // even when the field was never populated.
72
- const repo = github;
73
- const hasRepo = !!repo;
74
- const starsNumber = typeof stars === "number" && stars > 0 ? stars : null;
75
- const forksNumber = typeof forks === "number" && forks > 0 ? forks : null;
76
- const recentReleases = (repo?.releases ?? []).slice(0, 1);
77
- const isArchived = !!repo?.archived;
78
-
79
- // Render a kv row only when both the label and value have content.
80
- function row(label: string, value: unknown) {
81
- if (value === null || value === undefined || value === "") return null;
82
- return { label, value };
83
- }
84
- ---
85
-
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
- >
97
-
98
- {/* ── Activity card ──────────────────────────────────────── */}
99
- {sidebar.showActivity && (
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
- >
108
- Activity
109
- </h2>
110
- <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
111
- {row("Stars", starsNumber !== null ? starsNumber : null) && (
112
- <div class="flex items-baseline justify-between gap-3">
113
- <dt class="text-ink-500 dark:text-ink-400">Stars</dt>
114
- <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{starsNumber!.toLocaleString()}</dd>
115
- </div>
116
- )}
117
- {row("Forks", forksNumber !== null ? forksNumber.toLocaleString() : null) && (
118
- <div class="flex items-baseline justify-between gap-3">
119
- <dt class="text-ink-500 dark:text-ink-400">Forks</dt>
120
- <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{forksNumber!.toLocaleString()}</dd>
121
- </div>
122
- )}
123
- {row("Contributors", repo?.contributors_count) && (
124
- <div class="flex items-baseline justify-between gap-3">
125
- <dt class="text-ink-500 dark:text-ink-400">Contributors</dt>
126
- <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.contributors_count).toLocaleString()}</dd>
127
- </div>
128
- )}
129
- {row("Open issues", repo?.open_issues_count) && (
130
- <div class="flex items-baseline justify-between gap-3">
131
- <dt class="text-ink-500 dark:text-ink-400">Open issues</dt>
132
- <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.open_issues_count).toLocaleString()}</dd>
133
- </div>
134
- )}
135
- {row("Watchers", repo?.subscribers_count) && (
136
- <div class="flex items-baseline justify-between gap-3">
137
- <dt class="text-ink-500 dark:text-ink-400">Watchers</dt>
138
- <dd class="font-mono tabular-nums text-ink-900 dark:text-ink-100">{Number(repo!.subscribers_count).toLocaleString()}</dd>
139
- </div>
140
- )}
141
- {row("Language", language) && (
142
- <div class="flex items-baseline justify-between gap-3">
143
- <dt class="text-ink-500 dark:text-ink-400">Language</dt>
144
- <dd class="text-ink-900 dark:text-ink-100">{language}</dd>
145
- </div>
146
- )}
147
- {row("License", licenseSpdx) && (
148
- <div class="flex items-baseline justify-between gap-3">
149
- <dt class="text-ink-500 dark:text-ink-400">License</dt>
150
- <dd class="font-mono text-ink-900 dark:text-ink-100">{licenseDisplay(licenseSpdx)}</dd>
151
- </div>
152
- )}
153
- {row("Default branch", repo?.default_branch) && (
154
- <div class="flex items-baseline justify-between gap-3">
155
- <dt class="text-ink-500 dark:text-ink-400">Default branch</dt>
156
- <dd class="font-mono text-ink-900 dark:text-ink-100">{repo!.default_branch}</dd>
157
- </div>
158
- )}
159
- {row("First commit", repo?.created_at ? new Date(String(repo!.created_at)).getUTCFullYear() : null) && (
160
- <div class="flex items-baseline justify-between gap-3">
161
- <dt class="text-ink-500 dark:text-ink-400">First commit</dt>
162
- <dd class="text-ink-900 dark:text-ink-100">
163
- <time datetime={String(repo!.created_at)}>{new Date(String(repo!.created_at)).getUTCFullYear()}</time>
164
- </dd>
165
- </div>
166
- )}
167
- {recentReleases[0] && (
168
- <div class="flex items-baseline justify-between gap-3">
169
- <dt class="text-ink-500 dark:text-ink-400">Latest release</dt>
170
- <dd class="text-ink-900 dark:text-ink-100">
171
- <a
172
- href={recentReleases[0].html_url ?? repoUrl}
173
- target="_blank"
174
- rel="noopener noreferrer"
175
- class="no-underline hover:underline"
176
- >
177
- {recentReleases[0].tag_name ?? recentReleases[0].name ?? "—"}
178
- </a>
179
- </dd>
180
- </div>
181
- )}
182
- </dl>
183
-
184
- <LanguageBreakdown languages={languages} totalBytes={totalLanguageBytes} />
185
- </section>
186
- )}
187
-
188
- {/* ── Freshness card ─────────────────────────────────────── */}
189
- {sidebar.showFreshness && (
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
- >
198
- Freshness
199
- </h2>
200
- <dl class="grove-kv m-0 flex flex-col gap-2.5 text-sm">
201
- {pushedLabel && pushedLabel !== "—" && (
202
- <div class="flex items-baseline justify-between gap-3">
203
- <dt class="text-ink-500 dark:text-ink-400">Last commit</dt>
204
- <dd class="text-ink-900 dark:text-ink-100">{pushedLabel}</dd>
205
- </div>
206
- )}
207
- {repo?.updated_at && (
208
- <div class="flex items-baseline justify-between gap-3">
209
- <dt class="text-ink-500 dark:text-ink-400">Last fetched</dt>
210
- <dd class="text-ink-900 dark:text-ink-100">
211
- <time datetime={String(repo.updated_at)}>{new Date(String(repo.updated_at)).toLocaleDateString()}</time>
212
- </dd>
213
- </div>
214
- )}
215
- {activityBadge && (
216
- <div class="flex items-baseline justify-between gap-3">
217
- <dt class="text-ink-500 dark:text-ink-400">Maintenance</dt>
218
- <dd>
219
- <span data-activity-tone={activityBadge.tone} class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium">
220
- {activityBadge.label}
221
- </span>
222
- </dd>
223
- </div>
224
- )}
225
- {healthLabel && (
226
- <div class="flex items-baseline justify-between gap-3">
227
- <dt class="text-ink-500 dark:text-ink-400">Health</dt>
228
- <dd class="text-ink-900 dark:text-ink-100">{healthLabel}</dd>
229
- </div>
230
- )}
231
- {isArchived && (
232
- <div class="flex items-baseline justify-between gap-3">
233
- <dt class="text-ink-500 dark:text-ink-400">Status</dt>
234
- <dd class="text-amber-700 dark:text-amber-400">Archived</dd>
235
- </div>
236
- )}
237
- {repo?.homepage && (
238
- <div class="flex items-baseline justify-between gap-3">
239
- <dt class="text-ink-500 dark:text-ink-400">Docs</dt>
240
- <dd class="truncate text-ink-900 dark:text-ink-100">
241
- <a href={String(repo.homepage)} target="_blank" rel="noopener noreferrer" class="no-underline hover:underline">
242
- {new URL(String(repo.homepage)).hostname}
243
- </a>
244
- </dd>
245
- </div>
246
- )}
247
- {repoUrl && (
248
- <div class="flex items-baseline justify-between gap-3">
249
- <dt class="text-ink-500 dark:text-ink-400">Source</dt>
250
- <dd class="text-ink-900 dark:text-ink-100">
251
- <a href={repoUrl} target="_blank" rel="noopener noreferrer" class="no-underline hover:underline">
252
- {ownerRepo ? `${ownerRepo.owner}/${ownerRepo.repo}` : repoUrl}
253
- </a>
254
- </dd>
255
- </div>
256
- )}
257
- </dl>
258
- </section>
259
- )}
260
-
261
- {/* ── Ecosystem card ─────────────────────────────────────── */}
262
- {sidebar.showEcosystem && (
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
- >
271
- Ecosystem
272
- </h2>
273
-
274
- {stacks.length > 0 && (
275
- <div class="mb-3">
276
- <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stack</h3>
277
- <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
278
- {stacks.map((s) => (
279
- <li>
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)}
283
- </a>
284
- </li>
285
- ))}
286
- </ul>
287
- </div>
288
- )}
289
-
290
- {platforms.length > 0 && (
291
- <div class="mb-3">
292
- <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Platforms</h3>
293
- <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
294
- {platforms.map((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)}
298
- </li>
299
- ))}
300
- </ul>
301
- </div>
302
- )}
303
-
304
- {category && (
305
- <div class="mb-3">
306
- <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Category</h3>
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">
308
- {taxonomyLabel("categories", category)}
309
- </a>
310
- </div>
311
- )}
312
-
313
- {tags.length > 0 && (
314
- <div class="mb-3">
315
- <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Tags</h3>
316
- <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0">
317
- {tags.map((t) => (
318
- <li>
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">
320
- {taxonomyLabel("topics", t)}
321
- </a>
322
- </li>
323
- ))}
324
- </ul>
325
- </div>
326
- )}
327
-
328
- {contributionSignals.length > 0 && (
329
- <div>
330
- <h3 class="m-0 mb-1.5 text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Repository hygiene</h3>
331
- <ul class="m-0 flex list-none flex-col gap-1 p-0 text-sm">
332
- {contributionSignals.map((s) => (
333
- <li class="inline-flex items-center gap-1.5 text-ink-700 dark:text-ink-200">
334
- {s.ok ? (
335
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-emerald-600 dark:text-emerald-400">
336
- <path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
337
- </svg>
338
- ) : (
339
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor" class="text-ink-400">
340
- <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
341
- </svg>
342
- )}
343
- {s.label}
344
- </li>
345
- ))}
346
- </ul>
347
- </div>
348
- )}
349
- </section>
350
- )}
351
-
352
- {/* ── Source card (small) ────────────────────────────────── */}
353
- {sidebar.showSource && (
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
- >
362
- Source
363
- </h2>
364
- <p class="m-0 leading-relaxed">
365
- Reviewed by {record.curation?.reviewedBy ?? "grove"}
366
- on <time datetime={String(record.curation?.reviewedAt)}>
367
- {record.curation?.reviewedAt ? new Date(String(record.curation.reviewedAt)).toLocaleDateString() : "—"}
368
- </time>.
369
- </p>
370
- {detail.collectionMembership && detail.collectionMembership.length > 0 && (
371
- <p class="m-0 mt-1.5 leading-relaxed">
372
- <span class="font-semibold">Also in: </span>
373
- {detail.collectionMembership.map((c, i) => (
374
- <>
375
- <a href={c.url} class="text-inherit no-underline hover:underline">{c.title}</a>{i < detail.collectionMembership.length - 1 ? ", " : ""}
376
- </>
377
- ))}
378
- </p>
379
- )}
380
- {readingMetrics.wordCount > 0 && (
381
- <p class="m-0 mt-1 leading-relaxed">
382
- Notes: {readingMetrics.wordCount.toLocaleString()} words · {readingMetrics.minutes} min read.
383
- </p>
384
- )}
385
- </section>
386
- )}
387
- </aside>
@@ -1,168 +0,0 @@
1
- ---
2
- /**
3
- * RefinePanel — multi-select facet dropdowns.
4
- *
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.
11
- *
12
- * The form is a GET to the list page base, with each facet as a
13
- * repeated key. Empty values are dropped, so the URL stays clean.
14
- */
15
-
16
- import type { IndexFilters } from "../lib/search";
17
- import { SORT_OPTIONS } from "../lib/search";
18
- import FilterGroupMenu from "./FilterGroupMenu.astro";
19
-
20
- interface FacetValue {
21
- value: string;
22
- label?: string;
23
- count?: number;
24
- }
25
-
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[];
33
- }
34
-
35
- interface Props {
36
- /** Ordered filter groups (see `getDirectoryIndexModel`). */
37
- groups: FacetGroupModel[];
38
- /** Current URL filter state — used to preselect the sort. */
39
- initial: IndexFilters;
40
- pathPrefix?: string;
41
- class?: string;
42
- /**
43
- * Emit the default flex layout on the root. Pass `false` when the
44
- * caller owns the layout — e.g. `layout={false} class="contents"` to
45
- * promote the filter triggers into the parent's flex row. Without
46
- * this the root's own `flex` would win over any display utility in
47
- * `class`, since Tailwind emits `.contents` before `.flex`.
48
- */
49
- layout?: boolean;
50
- /** Hide the sort select (e.g. when the page renders its own). */
51
- hide?: Array<"sort">;
52
- }
53
-
54
- const {
55
- groups: groupModels,
56
- initial,
57
- pathPrefix = "/",
58
- class: className = "",
59
- layout = true,
60
- hide = [],
61
- } = Astro.props;
62
-
63
- function triggerLabel(group: FacetGroupModel): string {
64
- if (group.initial.length === 0) return `${group.label}: Any`;
65
- if (group.initial.length === 1) {
66
- const selected = group.initial[0];
67
- const option = group.options.find((candidate) => candidate.value === selected);
68
- return `${group.label}: ${option?.label ?? selected}`;
69
- }
70
- return `${group.label}: ${group.initial.length} selected`;
71
- }
72
-
73
- const groups = groupModels.map((group) => ({
74
- ...group,
75
- active: group.initial.length > 0,
76
- popoverId: `filter-popover-${group.filterKey}`,
77
- trigger: triggerLabel(group),
78
- }));
79
- ---
80
-
81
- <div class:list={[layout && "flex flex-wrap items-center gap-2", className]} role="toolbar" aria-label="Filter items">
82
- {groups.map((group) => <FilterGroupMenu {...group} />)}
83
-
84
- {!hide.includes("sort") && (
85
- <label class="relative">
86
- <span class="sr-only">Sort items</span>
87
- <select name="sort" class="h-9 cursor-pointer appearance-none border py-1.5 pl-3 pr-8 text-[0.8125rem] font-medium">
88
- {SORT_OPTIONS.map((option) => <option value={option.value} selected={(initial.sort ?? "recently-updated") === option.value}>{option.label}</option>)}
89
- </select>
90
- <svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-ink-400" fill="currentColor">
91
- <path d="M4.22 6.22a.75.75 0 0 1 1.06 0L8 8.94l2.72-2.72a.75.75 0 1 1 1.06 1.06l-3.25 3.25a.75.75 0 0 1-1.06 0L4.22 7.28a.75.75 0 0 1 0-1.06Z"/>
92
- </svg>
93
- </label>
94
- )}
95
- </div>
96
-
97
- <script is:inline define:vars={{ pathPrefix }}>
98
- (() => {
99
- const groups = document.querySelectorAll(".grove-filter");
100
- const keys = { stacks: "stack", platforms: "platform", categories: "category", tags: "tag", licenses: "license" };
101
- function closeAll(except) {
102
- groups.forEach((group) => {
103
- if (group === except) return;
104
- const popover = group.querySelector(".grove-filter-popover");
105
- const trigger = group.querySelector(".grove-filter-trigger");
106
- if (popover && trigger) {
107
- popover.hidden = true;
108
- popover.classList.add("hidden");
109
- trigger.setAttribute("aria-expanded", "false");
110
- }
111
- });
112
- }
113
- // A directory controller (DirectoryIndexClient) can adopt the URL
114
- // in place and re-render without a reload; it signals that by
115
- // cancelling the event. Anywhere else — a taxonomy page, no
116
- // controller on the page at all — this falls back to navigating,
117
- // so the panel keeps working on its own.
118
- function go(url) {
119
- const event = new CustomEvent("grove:navigate", { detail: { url }, cancelable: true });
120
- if (document.dispatchEvent(event)) location.assign(url);
121
- }
122
- function navigate(group, values) {
123
- const params = new URLSearchParams(location.search);
124
- const key = keys[group.dataset.filterGroupKey];
125
- params.delete(key);
126
- params.delete("page");
127
- values.forEach((value) => params.append(key, value));
128
- go(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
129
- }
130
- groups.forEach((group) => {
131
- const trigger = group.querySelector(".grove-filter-trigger");
132
- const popover = group.querySelector(".grove-filter-popover");
133
- trigger?.addEventListener("click", (event) => {
134
- event.stopPropagation();
135
- const opening = trigger.getAttribute("aria-expanded") !== "true";
136
- closeAll(opening ? group : undefined);
137
- popover.hidden = !opening;
138
- popover.classList.toggle("hidden", !opening);
139
- trigger.setAttribute("aria-expanded", String(opening));
140
- });
141
- group.querySelector(".grove-filter-apply")?.addEventListener("click", () => {
142
- navigate(group, [...group.querySelectorAll(".grove-filter-input:checked")].map((input) => input.dataset.filterValue));
143
- });
144
- group.querySelector(".grove-filter-clear")?.addEventListener("click", () => navigate(group, []));
145
- if (group.dataset.filterSingle === "true") {
146
- group.querySelectorAll(".grove-filter-input").forEach((input) => input.addEventListener("change", () => navigate(group, [input.dataset.filterValue])));
147
- }
148
- popover?.addEventListener("click", (event) => event.stopPropagation());
149
- });
150
- document.addEventListener("click", () => closeAll());
151
- document.addEventListener("keydown", (event) => {
152
- if (event.key !== "Escape") return;
153
- // Restore focus to the trigger whose popover was open so
154
- // keyboard users don't lose their place.
155
- const openGroup = [...groups].find((group) =>
156
- group.querySelector('.grove-filter-trigger[aria-expanded="true"]'),
157
- );
158
- closeAll();
159
- openGroup?.querySelector(".grove-filter-trigger")?.focus();
160
- });
161
- document.querySelector('select[name="sort"]')?.addEventListener("change", (event) => {
162
- const params = new URLSearchParams(location.search);
163
- params.set("sort", event.target.value);
164
- params.delete("page");
165
- go(params.toString() ? `${pathPrefix}?${params}` : pathPrefix);
166
- });
167
- })();
168
- </script>
@@ -1,81 +0,0 @@
1
- ---
2
- /**
3
- * SmartLensTabs — curated single-select lens tabs.
4
- *
5
- * Renders configured curated lenses as a horizontal tab bar. Sort choices,
6
- * including Recently added, belong in the separate sort control.
7
- * Clicking a tab navigates to the same list page with the `lens=`
8
- * query param updated. Other filter params (q, stack, platform,
9
- * category, sort) are preserved.
10
- *
11
- * The current selection is computed from `currentParams`, so the
12
- * component is server-render-only — no client JS required.
13
- *
14
- * Generic: uses the `LENSES` registry from `lib/lenses.ts` and
15
- * takes the list-page base via `pathPrefix`.
16
- */
17
-
18
- import { LENSES, hrefForLens, isLensActive } from "../lib/lenses";
19
- import { lensDisplay } from "../lib/display";
20
- import { lensTabClass } from "../ui/button.js";
21
-
22
- interface Props {
23
- currentParams: URLSearchParams;
24
- pathPrefix?: string;
25
- class?: string;
26
- /**
27
- * Emit the default flex layout on the root. Pass `false` when the
28
- * caller owns the layout — e.g. `layout={false} class="contents"` to
29
- * promote the tabs into the parent's flex row. Without this the
30
- * root's own `flex` would win over any display utility in `class`,
31
- * since Tailwind emits `.contents` before `.flex`.
32
- */
33
- layout?: boolean;
34
- /** Override the visible lens list (defaults to LENSES). */
35
- lensIds?: readonly string[];
36
- /** Consumer noun for the unfiltered lens, e.g. "All apps". */
37
- allLabel?: string;
38
- }
39
-
40
- const {
41
- currentParams,
42
- pathPrefix = "/",
43
- class: className = "",
44
- layout = true,
45
- lensIds,
46
- allLabel,
47
- } = Astro.props;
48
-
49
- // If `lensIds` is provided, filter LENSES to those ids in that order.
50
- const lenses =
51
- lensIds && lensIds.length > 0
52
- ? lensIds
53
- .map((id) => LENSES.find((l) => l.id === id))
54
- .filter((l): l is NonNullable<typeof l> => Boolean(l))
55
- : LENSES;
56
- ---
57
-
58
- <nav
59
- class:list={[
60
- "grove-lens-tabs",
61
- layout && "flex flex-wrap items-center gap-1.5",
62
- className,
63
- ]}
64
- aria-label="Curated views"
65
- >
66
- {lenses.map((lens) => {
67
- const active = isLensActive(lens.id, currentParams);
68
- return (
69
- <a
70
- href={hrefForLens(lens.id, currentParams, pathPrefix)}
71
- class={lensTabClass(active)}
72
- aria-current={active ? "page" : undefined}
73
- data-lens-id={lens.id}
74
- data-event="lens_tab_click"
75
- data-event-source="smart_lens_tabs"
76
- >
77
- {lens.id === "all" && allLabel ? allLabel : lensDisplay(lens.id)}
78
- </a>
79
- );
80
- })}
81
- </nav>