@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.
- package/README.md +81 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/components/ActiveFilterChips.astro +20 -0
- package/src/components/CategoryGrid.astro +27 -0
- package/src/components/DirectoryFilters.astro +100 -0
- package/src/components/DirectoryHero.astro +32 -0
- package/src/components/HealthBadge.astro +12 -0
- package/src/components/ItemCard.astro +41 -0
- package/src/components/LensTabs.astro +26 -0
- package/src/components/MethodologyPanel.astro +20 -0
- package/src/components/Pagination.astro +24 -0
- package/src/components/ProjectDetail.astro +166 -0
- package/src/components/ScoreBars.astro +32 -0
- package/src/components/SubmitDraft.astro +137 -0
- package/src/index.ts +17 -0
- package/src/layouts/BaseLayout.astro +32 -0
- package/src/styles.css +675 -0
- package/templates/default/astro.config.mjs +23 -0
- package/templates/default/data/generated/apps.json +1 -0
- package/templates/default/data/generated/repo-stats.json +1 -0
- package/templates/default/package.json +35 -0
- package/templates/default/public/icons/platforms/android.svg +1 -0
- package/templates/default/public/icons/platforms/apple-dark.svg +1 -0
- package/templates/default/public/icons/platforms/apple-light.svg +1 -0
- package/templates/default/public/icons/platforms/chrome.svg +1 -0
- package/templates/default/public/icons/platforms/chromeos.svg +5 -0
- package/templates/default/public/icons/platforms/desktop.svg +4 -0
- package/templates/default/public/icons/platforms/embedded.svg +5 -0
- package/templates/default/public/icons/platforms/ios.svg +1 -0
- package/templates/default/public/icons/platforms/linux.svg +1 -0
- package/templates/default/public/icons/platforms/macos.svg +1 -0
- package/templates/default/public/icons/platforms/ubuntu.svg +1 -0
- package/templates/default/public/icons/platforms/web.svg +5 -0
- package/templates/default/public/icons/platforms/windows.svg +6 -0
- package/templates/default/public/icons/stacks/android.svg +1 -0
- package/templates/default/public/icons/stacks/apple-dark.svg +1 -0
- package/templates/default/public/icons/stacks/apple-light.svg +1 -0
- package/templates/default/public/icons/stacks/capacitor.svg +14 -0
- package/templates/default/public/icons/stacks/dart.svg +1 -0
- package/templates/default/public/icons/stacks/firebase.svg +1 -0
- package/templates/default/public/icons/stacks/flutter.svg +1 -0
- package/templates/default/public/icons/stacks/graphql.svg +1 -0
- package/templates/default/public/icons/stacks/ionic.svg +4 -0
- package/templates/default/public/icons/stacks/java.svg +1 -0
- package/templates/default/public/icons/stacks/javascript.svg +1 -0
- package/templates/default/public/icons/stacks/kotlin.svg +1 -0
- package/templates/default/public/icons/stacks/mongodb.svg +1 -0
- package/templates/default/public/icons/stacks/nodejs.svg +1 -0
- package/templates/default/public/icons/stacks/python.svg +1 -0
- package/templates/default/public/icons/stacks/react-native.svg +1 -0
- package/templates/default/public/icons/stacks/react.svg +1 -0
- package/templates/default/public/icons/stacks/rust.svg +1 -0
- package/templates/default/public/icons/stacks/solidity.svg +1 -0
- package/templates/default/public/icons/stacks/swift.svg +1 -0
- package/templates/default/public/icons/stacks/tensorflow.svg +1 -0
- package/templates/default/public/icons/stacks/typescript.svg +1 -0
- package/templates/default/public/og-image.svg +48 -0
- package/templates/default/public/robots.txt +53 -0
- package/templates/default/src/components/AppCard.astro +248 -0
- package/templates/default/src/components/AppSection.astro +58 -0
- package/templates/default/src/components/AppsIndexRow.astro +230 -0
- package/templates/default/src/components/AppsPagination.astro +147 -0
- package/templates/default/src/components/CategoryGrid.astro +80 -0
- package/templates/default/src/components/ContributorsGrid.astro +105 -0
- package/templates/default/src/components/DecisionRow.astro +219 -0
- package/templates/default/src/components/ExploreByCategory.astro +74 -0
- package/templates/default/src/components/ExploreByStack.astro +102 -0
- package/templates/default/src/components/Hero.astro +176 -0
- package/templates/default/src/components/MinimalAbout.astro +57 -0
- package/templates/default/src/components/OriginalCollection.astro +144 -0
- package/templates/default/src/components/RefinePanel.astro +272 -0
- package/templates/default/src/components/ScoreBars.astro +72 -0
- package/templates/default/src/components/SmartLensTabs.astro +65 -0
- package/templates/default/src/components/StackGrid.astro +95 -0
- package/templates/default/src/components/WhyThisExists.astro +81 -0
- package/templates/default/src/components/icons/Icon.astro +148 -0
- package/templates/default/src/components/layout/BaseLayout.astro +94 -0
- package/templates/default/src/components/layout/Container.astro +18 -0
- package/templates/default/src/components/layout/Footer.astro +145 -0
- package/templates/default/src/components/layout/Header.astro +144 -0
- package/templates/default/src/components/layout/SectionHeader.astro +42 -0
- package/templates/default/src/components/layout/Seo.astro +120 -0
- package/templates/default/src/components/layout/ThemeToggle.astro +97 -0
- package/templates/default/src/data/apps.ts +149 -0
- package/templates/default/src/data/categories.ts +27 -0
- package/templates/default/src/data/config.ts +28 -0
- package/templates/default/src/data/contributors.ts +96 -0
- package/templates/default/src/data/stacks.ts +47 -0
- package/templates/default/src/data/stats.ts +65 -0
- package/templates/default/src/data/types.ts +248 -0
- package/templates/default/src/lib/apps-search.ts +228 -0
- package/templates/default/src/lib/format.ts +36 -0
- package/templates/default/src/lib/lenses.ts +98 -0
- package/templates/default/src/lib/repo.ts +12 -0
- package/templates/default/src/lib/scores.ts +51 -0
- package/templates/default/src/lib/taxonomy-counts.ts +31 -0
- package/templates/default/src/pages/about.astro +433 -0
- package/templates/default/src/pages/apps/[slug].astro +709 -0
- package/templates/default/src/pages/apps/index.astro +669 -0
- package/templates/default/src/pages/contributors.astro +142 -0
- package/templates/default/src/pages/index.astro +66 -0
- package/templates/default/src/pages/sitemap.xml.ts +77 -0
- package/templates/default/src/pages/submit.astro +416 -0
- package/templates/default/src/styles/global.css +84 -0
- 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>
|