@grove-dev/astro 0.1.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 (109) hide show
  1. package/README.md +81 -0
  2. package/dist/index.d.ts +26 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +26 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +58 -0
  7. package/src/components/ActiveFilterChips.astro +20 -0
  8. package/src/components/CategoryGrid.astro +27 -0
  9. package/src/components/DirectoryFilters.astro +100 -0
  10. package/src/components/DirectoryHero.astro +32 -0
  11. package/src/components/HealthBadge.astro +12 -0
  12. package/src/components/ItemCard.astro +41 -0
  13. package/src/components/LensTabs.astro +26 -0
  14. package/src/components/MethodologyPanel.astro +20 -0
  15. package/src/components/Pagination.astro +24 -0
  16. package/src/components/ProjectDetail.astro +166 -0
  17. package/src/components/ScoreBars.astro +32 -0
  18. package/src/components/SubmitDraft.astro +137 -0
  19. package/src/index.ts +17 -0
  20. package/src/layouts/BaseLayout.astro +32 -0
  21. package/src/styles.css +675 -0
  22. package/templates/default/astro.config.mjs +23 -0
  23. package/templates/default/data/generated/apps.json +1 -0
  24. package/templates/default/data/generated/repo-stats.json +1 -0
  25. package/templates/default/package.json +35 -0
  26. package/templates/default/public/icons/platforms/android.svg +1 -0
  27. package/templates/default/public/icons/platforms/apple-dark.svg +1 -0
  28. package/templates/default/public/icons/platforms/apple-light.svg +1 -0
  29. package/templates/default/public/icons/platforms/chrome.svg +1 -0
  30. package/templates/default/public/icons/platforms/chromeos.svg +5 -0
  31. package/templates/default/public/icons/platforms/desktop.svg +4 -0
  32. package/templates/default/public/icons/platforms/embedded.svg +5 -0
  33. package/templates/default/public/icons/platforms/ios.svg +1 -0
  34. package/templates/default/public/icons/platforms/linux.svg +1 -0
  35. package/templates/default/public/icons/platforms/macos.svg +1 -0
  36. package/templates/default/public/icons/platforms/ubuntu.svg +1 -0
  37. package/templates/default/public/icons/platforms/web.svg +5 -0
  38. package/templates/default/public/icons/platforms/windows.svg +6 -0
  39. package/templates/default/public/icons/stacks/android.svg +1 -0
  40. package/templates/default/public/icons/stacks/apple-dark.svg +1 -0
  41. package/templates/default/public/icons/stacks/apple-light.svg +1 -0
  42. package/templates/default/public/icons/stacks/capacitor.svg +14 -0
  43. package/templates/default/public/icons/stacks/dart.svg +1 -0
  44. package/templates/default/public/icons/stacks/firebase.svg +1 -0
  45. package/templates/default/public/icons/stacks/flutter.svg +1 -0
  46. package/templates/default/public/icons/stacks/graphql.svg +1 -0
  47. package/templates/default/public/icons/stacks/ionic.svg +4 -0
  48. package/templates/default/public/icons/stacks/java.svg +1 -0
  49. package/templates/default/public/icons/stacks/javascript.svg +1 -0
  50. package/templates/default/public/icons/stacks/kotlin.svg +1 -0
  51. package/templates/default/public/icons/stacks/mongodb.svg +1 -0
  52. package/templates/default/public/icons/stacks/nodejs.svg +1 -0
  53. package/templates/default/public/icons/stacks/python.svg +1 -0
  54. package/templates/default/public/icons/stacks/react-native.svg +1 -0
  55. package/templates/default/public/icons/stacks/react.svg +1 -0
  56. package/templates/default/public/icons/stacks/rust.svg +1 -0
  57. package/templates/default/public/icons/stacks/solidity.svg +1 -0
  58. package/templates/default/public/icons/stacks/swift.svg +1 -0
  59. package/templates/default/public/icons/stacks/tensorflow.svg +1 -0
  60. package/templates/default/public/icons/stacks/typescript.svg +1 -0
  61. package/templates/default/public/og-image.svg +48 -0
  62. package/templates/default/public/robots.txt +53 -0
  63. package/templates/default/src/components/AppCard.astro +248 -0
  64. package/templates/default/src/components/AppSection.astro +58 -0
  65. package/templates/default/src/components/AppsIndexRow.astro +230 -0
  66. package/templates/default/src/components/AppsPagination.astro +147 -0
  67. package/templates/default/src/components/CategoryGrid.astro +80 -0
  68. package/templates/default/src/components/ContributorsGrid.astro +105 -0
  69. package/templates/default/src/components/DecisionRow.astro +219 -0
  70. package/templates/default/src/components/ExploreByCategory.astro +74 -0
  71. package/templates/default/src/components/ExploreByStack.astro +102 -0
  72. package/templates/default/src/components/Hero.astro +176 -0
  73. package/templates/default/src/components/MinimalAbout.astro +57 -0
  74. package/templates/default/src/components/OriginalCollection.astro +144 -0
  75. package/templates/default/src/components/RefinePanel.astro +272 -0
  76. package/templates/default/src/components/ScoreBars.astro +72 -0
  77. package/templates/default/src/components/SmartLensTabs.astro +65 -0
  78. package/templates/default/src/components/StackGrid.astro +95 -0
  79. package/templates/default/src/components/WhyThisExists.astro +81 -0
  80. package/templates/default/src/components/icons/Icon.astro +148 -0
  81. package/templates/default/src/components/layout/BaseLayout.astro +94 -0
  82. package/templates/default/src/components/layout/Container.astro +18 -0
  83. package/templates/default/src/components/layout/Footer.astro +145 -0
  84. package/templates/default/src/components/layout/Header.astro +144 -0
  85. package/templates/default/src/components/layout/SectionHeader.astro +42 -0
  86. package/templates/default/src/components/layout/Seo.astro +120 -0
  87. package/templates/default/src/components/layout/ThemeToggle.astro +97 -0
  88. package/templates/default/src/data/apps.ts +149 -0
  89. package/templates/default/src/data/categories.ts +27 -0
  90. package/templates/default/src/data/config.ts +28 -0
  91. package/templates/default/src/data/contributors.ts +96 -0
  92. package/templates/default/src/data/stacks.ts +47 -0
  93. package/templates/default/src/data/stats.ts +65 -0
  94. package/templates/default/src/data/types.ts +248 -0
  95. package/templates/default/src/lib/apps-search.ts +228 -0
  96. package/templates/default/src/lib/format.ts +36 -0
  97. package/templates/default/src/lib/lenses.ts +98 -0
  98. package/templates/default/src/lib/repo.ts +12 -0
  99. package/templates/default/src/lib/scores.ts +51 -0
  100. package/templates/default/src/lib/taxonomy-counts.ts +31 -0
  101. package/templates/default/src/pages/about.astro +433 -0
  102. package/templates/default/src/pages/apps/[slug].astro +709 -0
  103. package/templates/default/src/pages/apps/index.astro +669 -0
  104. package/templates/default/src/pages/contributors.astro +142 -0
  105. package/templates/default/src/pages/index.astro +66 -0
  106. package/templates/default/src/pages/sitemap.xml.ts +77 -0
  107. package/templates/default/src/pages/submit.astro +416 -0
  108. package/templates/default/src/styles/global.css +84 -0
  109. package/templates/default/tailwind.config.mjs +130 -0
@@ -0,0 +1,144 @@
1
+ ---
2
+ import type { SiteStats } from "../data/stats";
3
+ import Container from "./layout/Container.astro";
4
+
5
+ interface Props {
6
+ stats: SiteStats;
7
+ }
8
+
9
+ const { stats } = Astro.props;
10
+ const repoUrl = stats.originalRepo;
11
+
12
+ function formatCount(n: number): string {
13
+ if (n >= 1000) {
14
+ const k = n / 1000;
15
+ return (k >= 10 ? Math.round(k).toString() : k.toFixed(1)) + "k";
16
+ }
17
+ return n.toString();
18
+ }
19
+
20
+ const starsLabel = formatCount(stats.stars);
21
+ const forksLabel = formatCount(stats.forks);
22
+ ---
23
+
24
+ <section
25
+ id="original"
26
+ class="section scroll-mt-20 border-b border-ink-200 dark:border-ink-800"
27
+ >
28
+ <Container>
29
+ <div
30
+ class="card flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:gap-8"
31
+ >
32
+ <div class="max-w-2xl">
33
+ <span
34
+ class="text-2xs font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400"
35
+ >Origin</span
36
+ >
37
+ <h2
38
+ class="mt-1 text-[1.375rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100 m-0"
39
+ >
40
+ Started from Open Source Flutter Apps.
41
+ </h2>
42
+ <p
43
+ class="mt-2 text-[0.9375rem] leading-relaxed text-ink-500 dark:text-ink-400 m-0"
44
+ >
45
+ The project began as a curated Flutter app list and is now expanding
46
+ into a broader open-source app directory, starting with mobile.
47
+ </p>
48
+
49
+ <dl class="mt-4 flex flex-wrap items-center gap-x-6 gap-y-2">
50
+ <div class="flex items-center gap-2">
51
+ <svg
52
+ viewBox="0 0 16 16"
53
+ width="14"
54
+ height="14"
55
+ aria-hidden="true"
56
+ fill="currentColor"
57
+ class="text-amber-500"
58
+ >
59
+ <path
60
+ d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z"
61
+ />
62
+ </svg>
63
+ <dd class="m-0 text-[0.9375rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
64
+ <span class="font-mono">{starsLabel}+</span>
65
+ <span class="ml-1 text-2xs font-normal text-ink-500 dark:text-ink-400">stars</span>
66
+ </dd>
67
+ </div>
68
+ <div class="flex items-center gap-2">
69
+ <svg
70
+ viewBox="0 0 16 16"
71
+ width="14"
72
+ height="14"
73
+ aria-hidden="true"
74
+ fill="none"
75
+ stroke="currentColor"
76
+ stroke-width="1.5"
77
+ class="text-ink-500 dark:text-ink-400"
78
+ >
79
+ <circle cx="4" cy="3" r="1.5"/>
80
+ <circle cx="12" cy="3" r="1.5"/>
81
+ <circle cx="8" cy="8" r="1.5"/>
82
+ <circle cx="4" cy="13" r="1.5"/>
83
+ <circle cx="12" cy="13" r="1.5"/>
84
+ <path d="M5 4.2 7 6.8M11 4.2 9 6.8M5 11.8 7 9.2M11 11.8 9 9.2"/>
85
+ </svg>
86
+ <dd class="m-0 text-[0.9375rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
87
+ <span class="font-mono">{forksLabel}+</span>
88
+ <span class="ml-1 text-2xs font-normal text-ink-500 dark:text-ink-400">forks</span>
89
+ </dd>
90
+ </div>
91
+ <div class="flex items-center gap-2">
92
+ <svg
93
+ viewBox="0 0 16 16"
94
+ width="14"
95
+ height="14"
96
+ aria-hidden="true"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ stroke-width="1.5"
100
+ class="text-ink-500 dark:text-ink-400"
101
+ >
102
+ <path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 0 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Z"/>
103
+ <path d="M5 6.5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 5 6.5Zm.75 2.25a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5Z"/>
104
+ </svg>
105
+ <dd class="m-0 text-[0.9375rem] font-semibold tracking-tight text-ink-900 dark:text-ink-100">
106
+ <span class="font-mono">{stats.contributors}+</span>
107
+ <span class="ml-1 text-2xs font-normal text-ink-500 dark:text-ink-400">contributors</span>
108
+ </dd>
109
+ </div>
110
+ </dl>
111
+ </div>
112
+
113
+ <div class="flex shrink-0 flex-col gap-2 sm:items-end">
114
+ <a
115
+ href={repoUrl}
116
+ target="_blank"
117
+ rel="noopener noreferrer"
118
+ class="btn-primary"
119
+ >
120
+ <svg
121
+ viewBox="0 0 16 16"
122
+ width="14"
123
+ height="14"
124
+ aria-hidden="true"
125
+ fill="currentColor"
126
+ >
127
+ <path
128
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38v-1.33c-2.22.48-2.69-1.07-2.69-1.07-.36-.92-.89-1.17-.89-1.17-.73-.5.05-.49.05-.49.8.06 1.23.83 1.23.83.72 1.22 1.87.87 2.33.67.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.19c0 .21.15.46.55.38A8 8 0 0 0 8 0Z"
129
+ />
130
+ </svg>
131
+ View on GitHub
132
+ </a>
133
+ <a
134
+ href={`${repoUrl}/blob/main/CONTRIBUTING.md`}
135
+ target="_blank"
136
+ rel="noopener noreferrer"
137
+ class="text-2xs text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
138
+ >
139
+ How to contribute →
140
+ </a>
141
+ </div>
142
+ </div>
143
+ </Container>
144
+ </section>
@@ -0,0 +1,272 @@
1
+ ---
2
+ import type { AppsFilters } from "../lib/apps-search";
3
+
4
+ interface FacetOption {
5
+ value: string;
6
+ count: number;
7
+ }
8
+
9
+ interface Props {
10
+ facets: {
11
+ stacks: FacetOption[];
12
+ platforms: FacetOption[];
13
+ categories: FacetOption[];
14
+ labels: FacetOption[];
15
+ licenses: FacetOption[];
16
+ };
17
+ initial: AppsFilters;
18
+ class?: string;
19
+ }
20
+
21
+ const { facets, initial, class: className = "" } = Astro.props;
22
+
23
+ /**
24
+ * A single filter group rendered as a dropdown trigger + popover.
25
+ * Multi-select by default; license is intentionally single-select.
26
+ */
27
+ interface FilterGroup {
28
+ /** Short label, e.g. "Stack". */
29
+ label: string;
30
+ /** Key in AppsFilters (plural). */
31
+ filterKey: "stacks" | "platforms" | "categories" | "labels" | "licenses";
32
+ /** Options with counts. */
33
+ options: FacetOption[];
34
+ initial: string[];
35
+ /** Single-select (radio-like) — for license, which is usually one. */
36
+ single?: boolean;
37
+ }
38
+
39
+ const groups: FilterGroup[] = [
40
+ { label: "Stack", filterKey: "stacks", options: facets.stacks, initial: initial.stacks ?? [] },
41
+ { label: "Platform", filterKey: "platforms", options: facets.platforms, initial: initial.platforms ?? [] },
42
+ { label: "Category", filterKey: "categories", options: facets.categories, initial: initial.categories ?? [] },
43
+ { label: "License", filterKey: "licenses", options: facets.licenses, initial: initial.licenses ?? [], single: true },
44
+ ];
45
+
46
+ /** Build the trigger label: "Stack" when empty, "Stack: 2" when 2 selected. */
47
+ function triggerLabel(g: FilterGroup): string {
48
+ const n = g.initial.length;
49
+ if (n === 0) return `${g.label}: Any`;
50
+ if (n === 1) return `${g.label}: ${g.initial[0]}`;
51
+ return `${g.label}: ${n} selected`;
52
+ }
53
+ ---
54
+
55
+ <section class={`${className}`}>
56
+ <div
57
+ class="flex flex-wrap items-center gap-2"
58
+ role="toolbar"
59
+ aria-label="Filter apps"
60
+ >
61
+ {
62
+ groups.map((g) => {
63
+ const hasActive = g.initial.length > 0;
64
+ const popoverId = `filter-popover-${g.filterKey}`;
65
+ return (
66
+ <div
67
+ class="apps-filter relative"
68
+ data-filter-group-key={g.filterKey}
69
+ data-filter-single={g.single ? "true" : "false"}
70
+ >
71
+ <button
72
+ type="button"
73
+ class={`apps-filter-trigger inline-flex h-9 items-center gap-1.5 rounded-md border px-3 text-[0.8125rem] font-medium transition-colors ${
74
+ hasActive
75
+ ? "border-ink-900 bg-ink-900 text-white dark:border-ink-100 dark:bg-ink-100 dark:text-ink-900"
76
+ : "border-ink-200 bg-white text-ink-700 hover:border-ink-300 hover:bg-ink-50 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300 dark:hover:border-ink-700 dark:hover:bg-ink-900/60"
77
+ }`}
78
+ aria-haspopup="listbox"
79
+ aria-expanded="false"
80
+ aria-controls={popoverId}
81
+ data-filter-trigger
82
+ >
83
+ <span>{triggerLabel(g)}</span>
84
+ <svg
85
+ viewBox="0 0 16 16"
86
+ width="11"
87
+ height="11"
88
+ aria-hidden="true"
89
+ fill="currentColor"
90
+ class="opacity-70"
91
+ >
92
+ <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"/>
93
+ </svg>
94
+ </button>
95
+ <div
96
+ id={popoverId}
97
+ class="apps-filter-popover absolute left-0 top-full z-40 mt-1 hidden w-72 max-w-[calc(100vw-2rem)] rounded-lg border border-ink-200 bg-white p-1.5 shadow-lg dark:border-ink-800 dark:bg-ink-950"
98
+ role="listbox"
99
+ aria-multiselectable={g.single ? "false" : "true"}
100
+ hidden
101
+ >
102
+ {g.options.length === 0 ? (
103
+ <p class="m-0 px-2 py-1.5 text-2xs text-ink-400 dark:text-ink-500">
104
+ No options available.
105
+ </p>
106
+ ) : (
107
+ <ul class="m-0 flex max-h-72 flex-col gap-0.5 overflow-y-auto p-0" data-filter-list>
108
+ {g.options.map((opt) => {
109
+ const checked = g.initial.includes(opt.value);
110
+ return (
111
+ <li>
112
+ <label
113
+ class={`flex cursor-pointer items-center justify-between gap-3 rounded-md px-2 py-1.5 text-[0.8125rem] transition-colors hover:bg-ink-50 dark:hover:bg-ink-900/60 ${
114
+ checked
115
+ ? "text-ink-900 dark:text-ink-100"
116
+ : "text-ink-700 dark:text-ink-300"
117
+ }`}
118
+ >
119
+ <span class="flex min-w-0 items-center gap-2">
120
+ <input
121
+ type={g.single ? "radio" : "checkbox"}
122
+ class="apps-filter-input h-3.5 w-3.5 shrink-0 cursor-pointer accent-ink-900 dark:accent-ink-100"
123
+ data-filter-key={g.filterKey}
124
+ data-filter-value={opt.value}
125
+ data-filter-single={g.single ? "true" : undefined}
126
+ checked={checked}
127
+ />
128
+ <span class="truncate">{opt.value}</span>
129
+ </span>
130
+ <span class="shrink-0 font-mono text-2xs tabular-nums text-ink-500 dark:text-ink-400">
131
+ {opt.count}
132
+ </span>
133
+ </label>
134
+ </li>
135
+ );
136
+ })}
137
+ </ul>
138
+ )}
139
+ <div class="mt-1 flex items-center justify-between border-t border-ink-100 px-2 pt-1.5 dark:border-ink-900">
140
+ <button
141
+ type="button"
142
+ class="apps-filter-clear text-2xs font-medium text-ink-500 hover:text-ink-900 dark:text-ink-400 dark:hover:text-ink-100"
143
+ hidden={!hasActive}
144
+ >
145
+ Clear {g.label.toLowerCase()}
146
+ </button>
147
+ <button
148
+ type="button"
149
+ class="apps-filter-apply ml-auto rounded-md bg-ink-900 px-2.5 py-1 text-2xs font-medium text-white transition-colors hover:bg-ink-700 dark:bg-ink-100 dark:text-ink-900 dark:hover:bg-ink-300"
150
+ >
151
+ Apply
152
+ </button>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ );
157
+ })
158
+ }
159
+ </div>
160
+ </section>
161
+
162
+ <script>
163
+ /**
164
+ * Dropdown behavior for each filter group:
165
+ * - click trigger → toggle popover
166
+ * - click outside / ESC → close
167
+ * - Apply / Clear / Single-select fire `applyFilterPatch()` which
168
+ * is a host-page hook (defined in `src/pages/apps/index.astro`).
169
+ * The hook updates the URL via `history.pushState` (no reload)
170
+ * and re-runs the row filter so the result list updates
171
+ * immediately. Static pages don't filter on the server, so
172
+ * this is the only way the filter actually narrows the list.
173
+ */
174
+ function applyFilterPatch(patch: Record<string, string[] | null>) {
175
+ const fn = (window as unknown as { __applyFilters?: (p: typeof patch) => void }).__applyFilters;
176
+ if (typeof fn === "function") fn(patch);
177
+ else location.assign(location.pathname); // graceful fallback
178
+ }
179
+
180
+ (() => {
181
+ const groups = document.querySelectorAll<HTMLElement>(".apps-filter");
182
+ if (!groups.length) return;
183
+
184
+ function closeAll(except?: HTMLElement) {
185
+ groups.forEach((g) => {
186
+ if (g === except) return;
187
+ const pop = g.querySelector<HTMLElement>(".apps-filter-popover");
188
+ const trig = g.querySelector<HTMLElement>(".apps-filter-trigger");
189
+ if (!pop || !trig) return;
190
+ pop.hidden = true;
191
+ pop.classList.add("hidden");
192
+ trig.setAttribute("aria-expanded", "false");
193
+ });
194
+ }
195
+
196
+ function openGroup(g: HTMLElement) {
197
+ const pop = g.querySelector<HTMLElement>(".apps-filter-popover");
198
+ const trig = g.querySelector<HTMLElement>(".apps-filter-trigger");
199
+ if (!pop || !trig) return;
200
+ pop.hidden = false;
201
+ pop.classList.remove("hidden");
202
+ trig.setAttribute("aria-expanded", "true");
203
+ }
204
+
205
+ function closeGroup(g: HTMLElement) {
206
+ const pop = g.querySelector<HTMLElement>(".apps-filter-popover");
207
+ const trig = g.querySelector<HTMLElement>(".apps-filter-trigger");
208
+ if (!pop || !trig) return;
209
+ pop.hidden = true;
210
+ pop.classList.add("hidden");
211
+ trig.setAttribute("aria-expanded", "false");
212
+ }
213
+
214
+ groups.forEach((g) => {
215
+ const trig = g.querySelector<HTMLElement>(".apps-filter-trigger");
216
+ const pop = g.querySelector<HTMLElement>(".apps-filter-popover");
217
+ const applyBtn = g.querySelector<HTMLElement>(".apps-filter-apply");
218
+ const clearBtn = g.querySelector<HTMLElement>(".apps-filter-clear");
219
+ const isSingle = g.dataset.filterSingle === "true";
220
+ if (!trig || !pop) return;
221
+
222
+ trig.addEventListener("click", (e) => {
223
+ e.stopPropagation();
224
+ const isOpen = trig.getAttribute("aria-expanded") === "true";
225
+ closeAll(isOpen ? undefined : g);
226
+ if (isOpen) closeGroup(g);
227
+ else openGroup(g);
228
+ });
229
+
230
+ // Single-select: click any input immediately applies + closes.
231
+ if (isSingle) {
232
+ pop.querySelectorAll<HTMLInputElement>(".apps-filter-input").forEach((input) => {
233
+ input.addEventListener("change", () => {
234
+ const key = input.dataset.filterKey!;
235
+ const value = input.dataset.filterValue!;
236
+ applyFilterPatch({ [key]: [value] });
237
+ });
238
+ });
239
+ }
240
+
241
+ // Apply: read checked inputs, write URL.
242
+ applyBtn?.addEventListener("click", (e) => {
243
+ e.stopPropagation();
244
+ const key = g.dataset.filterGroupKey!;
245
+ const inputs = pop.querySelectorAll<HTMLInputElement>(".apps-filter-input");
246
+ const chosen: string[] = [];
247
+ inputs.forEach((i) => {
248
+ if (i.checked) chosen.push(i.dataset.filterValue!);
249
+ });
250
+ applyFilterPatch({ [key]: chosen });
251
+ });
252
+
253
+ // Clear: uncheck all in this group, remove from URL.
254
+ clearBtn?.addEventListener("click", (e) => {
255
+ e.stopPropagation();
256
+ const key = g.dataset.filterGroupKey!;
257
+ applyFilterPatch({ [key]: null });
258
+ });
259
+
260
+ // Prevent clicks inside the popover from closing it (e.g. the
261
+ // scrollbar area).
262
+ pop.addEventListener("click", (e) => e.stopPropagation());
263
+ });
264
+
265
+ // Close on outside click.
266
+ document.addEventListener("click", () => closeAll());
267
+ // Close on ESC.
268
+ document.addEventListener("keydown", (e) => {
269
+ if (e.key === "Escape") closeAll();
270
+ });
271
+ })();
272
+ </script>
@@ -0,0 +1,72 @@
1
+ ---
2
+ import { SCORE_DIMENSIONS, SCORE_LABELS, scoreTier, scoreLabel } from "../lib/scores";
3
+ import type { AppScores } from "../data/types";
4
+
5
+ interface Props {
6
+ scores?: AppScores;
7
+ /** Show the "Overall" dimension too (default true). */
8
+ showOverall?: boolean;
9
+ /** Compact mode: hide value text, bar only. */
10
+ compact?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ const { scores, showOverall = true, compact = false, class: className = "" } = Astro.props;
15
+ const dims = showOverall ? SCORE_DIMENSIONS : SCORE_DIMENSIONS.filter((d) => d !== "overall");
16
+ const hasAny = scores && Object.values(scores).some((v) => typeof v === "number");
17
+ ---
18
+
19
+ <dl
20
+ class={`flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs ${className}`}
21
+ aria-label="App scores"
22
+ >
23
+ {!hasAny && (
24
+ <span class="text-ink-400 dark:text-ink-500">Scores not yet curated</span>
25
+ )}
26
+ {hasAny &&
27
+ dims.map((dim) => {
28
+ const v = scores![dim];
29
+ const tier = typeof v === "number" ? scoreTier(v) : 0;
30
+ const isOverall = dim === "overall";
31
+ return (
32
+ <div class="inline-flex items-center gap-1.5">
33
+ <dt
34
+ class={`${
35
+ isOverall
36
+ ? "font-semibold text-ink-700 dark:text-ink-200"
37
+ : "text-ink-500 dark:text-ink-400"
38
+ }`}
39
+ >
40
+ {SCORE_LABELS[dim]}
41
+ </dt>
42
+ {!compact && (
43
+ <dd
44
+ class={`font-mono ${
45
+ isOverall
46
+ ? "text-ink-900 dark:text-ink-100"
47
+ : "text-ink-700 dark:text-ink-300"
48
+ }`}
49
+ >
50
+ {scoreLabel(v)}
51
+ </dd>
52
+ )}
53
+ <dd
54
+ class="flex h-1.5 w-10 gap-px"
55
+ aria-label={`${SCORE_LABELS[dim]} ${scoreLabel(v)} of 100`}
56
+ >
57
+ {[0, 1, 2, 3].map((i) => (
58
+ <div
59
+ class={`flex-1 rounded-sm ${
60
+ i < tier
61
+ ? isOverall
62
+ ? "bg-ink-700 dark:bg-ink-200"
63
+ : "bg-ink-500 dark:bg-ink-400"
64
+ : "bg-ink-200 dark:bg-ink-800"
65
+ }`}
66
+ />
67
+ ))}
68
+ </dd>
69
+ </div>
70
+ );
71
+ })}
72
+ </dl>
@@ -0,0 +1,65 @@
1
+ ---
2
+ import { LENSES, PRIMARY_LENSES, isLensActive, type LensId } from "../lib/lenses";
3
+
4
+ interface Props {
5
+ currentParams: URLSearchParams;
6
+ class?: string;
7
+ }
8
+
9
+ const { currentParams, class: className = "" } = Astro.props;
10
+
11
+ // Render only the 6 primary lenses, in declared order.
12
+ const tabs = PRIMARY_LENSES.map((id) => LENSES.find((l) => l.id === id)!).filter(Boolean);
13
+
14
+ // Build href for a lens: merge lens's params on top of current search.
15
+ function hrefFor(lensId: LensId): string {
16
+ const sp = new URLSearchParams(currentParams);
17
+ // Clear any existing lens
18
+ sp.delete("lens");
19
+ if (lensId !== "all") {
20
+ const def = LENSES.find((l) => l.id === lensId);
21
+ if (def) {
22
+ for (const [k, v] of Object.entries(def.toParams())) {
23
+ if (Array.isArray(v)) {
24
+ sp.delete(k);
25
+ for (const item of v) sp.append(k, item);
26
+ } else {
27
+ sp.set(k, v);
28
+ }
29
+ }
30
+ }
31
+ }
32
+ const qs = sp.toString();
33
+ return qs ? `/apps?${qs}` : "/apps";
34
+ }
35
+ ---
36
+
37
+ <nav
38
+ class={`overflow-x-auto ${className}`}
39
+ aria-label="Curated lenses"
40
+ >
41
+ <ul class="m-0 flex list-none items-center gap-1 p-0 whitespace-nowrap">
42
+ {
43
+ tabs.map((lens) => {
44
+ const isActive = isLensActive(lens.id, currentParams);
45
+ return (
46
+ <li>
47
+ <a
48
+ href={hrefFor(lens.id)}
49
+ data-lens-id={lens.id}
50
+ class={`lens-tab inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-2xs font-medium transition-colors ${
51
+ isActive
52
+ ? "border-ink-900 bg-ink-900 text-white dark:border-ink-100 dark:bg-ink-100 dark:text-ink-900"
53
+ : "border-ink-200 bg-white text-ink-700 hover:border-ink-300 hover:bg-ink-50 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300 dark:hover:border-ink-700 dark:hover:bg-ink-900/60"
54
+ }`}
55
+ aria-current={isActive ? "page" : undefined}
56
+ title={lens.description}
57
+ >
58
+ {lens.label}
59
+ </a>
60
+ </li>
61
+ );
62
+ })
63
+ }
64
+ </ul>
65
+ </nav>
@@ -0,0 +1,95 @@
1
+ ---
2
+ import type { Stack } from "../data/stacks";
3
+ import SectionHeader from "./layout/SectionHeader.astro";
4
+ import Container from "./layout/Container.astro";
5
+ import Icon from "./icons/Icon.astro";
6
+ import { countByStack } from "../lib/taxonomy-counts";
7
+
8
+ interface Props {
9
+ stacks: Stack[];
10
+ }
11
+
12
+ const { stacks } = Astro.props;
13
+ const counts = countByStack();
14
+
15
+ const statusLabel: Record<Stack["status"], string> = {
16
+ live: "Available now",
17
+ expanding: "Expanding",
18
+ planned: "Planned",
19
+ };
20
+
21
+ const statusBadge: Record<Stack["status"], string> = {
22
+ live: "border-emerald-300/60 bg-emerald-50 text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300",
23
+ expanding: "border-ink-200 bg-ink-50 text-ink-700 dark:border-ink-800 dark:bg-ink-900/60 dark:text-ink-300",
24
+ planned: "border-ink-200 bg-white text-ink-500 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-500",
25
+ };
26
+
27
+ // Decorative count for each stack. We render it as a small "n apps" pill
28
+ // for live stacks and as a "0 apps" / "Planned" pill for the rest so the
29
+ // UI is honest about coverage — no fake numbers, no "Available now"
30
+ // stacks that secretly have zero apps.
31
+ const enriched = stacks
32
+ .map((s) => ({ ...s, count: counts.get(s.slug) ?? 0 }))
33
+ .sort((a, b) => b.count - a.count);
34
+ ---
35
+
36
+ <section
37
+ id="stacks"
38
+ class="section scroll-mt-20 border-b border-ink-200 dark:border-ink-800"
39
+ >
40
+ <Container>
41
+ <SectionHeader
42
+ eyebrow="Stacks"
43
+ title="Browse by stack"
44
+ description="Pick a stack to see real apps built with it."
45
+ />
46
+
47
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
48
+ {
49
+ enriched.map((stack) => (
50
+ <a
51
+ href={`/apps?stack=${encodeURIComponent(stack.name)}`}
52
+ class="card card-hover group flex h-full items-center gap-3 p-3.5 no-underline"
53
+ >
54
+ {/* Compact stack icon — supports the card without dominating it */}
55
+ <div
56
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-ink-200 bg-white dark:border-ink-800 dark:bg-ink-950"
57
+ aria-hidden="true"
58
+ >
59
+ <Icon
60
+ name={stack.name}
61
+ category="stack"
62
+ size={18}
63
+ class="h-[18px] w-[18px]"
64
+ />
65
+ </div>
66
+
67
+ <div class="flex min-w-0 flex-1 flex-col gap-0.5">
68
+ <div class="flex items-center gap-2">
69
+ <h3 class="m-0 text-[0.875rem] font-semibold tracking-tight text-ink-900 group-hover:underline dark:text-ink-100">
70
+ {stack.name}
71
+ </h3>
72
+ <span
73
+ 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"
74
+ title={`${stack.count} apps in this stack`}
75
+ >
76
+ {stack.count} apps
77
+ </span>
78
+ </div>
79
+ <p class="m-0 truncate text-2xs text-ink-500 dark:text-ink-400">
80
+ {stack.blurb}
81
+ </p>
82
+ </div>
83
+
84
+ <span
85
+ class={`shrink-0 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${statusBadge[stack.status]}`}
86
+ aria-label={`Status: ${statusLabel[stack.status]}`}
87
+ >
88
+ {statusLabel[stack.status]}
89
+ </span>
90
+ </a>
91
+ ))
92
+ }
93
+ </div>
94
+ </Container>
95
+ </section>