@grove-dev/astro 0.2.13 → 0.2.16
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 +26 -18
- package/dist/lib/repo.d.ts +2 -1
- package/dist/lib/repo.d.ts.map +1 -1
- package/dist/lib/repo.js +2 -1
- package/dist/lib/repo.js.map +1 -1
- package/dist/lib/search.d.ts +13 -16
- package/dist/lib/search.d.ts.map +1 -1
- package/dist/lib/search.js +6 -9
- package/dist/lib/search.js.map +1 -1
- package/package.json +3 -3
- package/src/components/ItemCard.astro +33 -5
- package/src/components/RecordSection.astro +2 -1
- package/src/components/RefinePanel.astro +3 -3
- package/src/lib/repo.ts +2 -1
- package/src/lib/search.ts +26 -31
- package/src/styles.css +3 -1
- package/templates/default/package.json +3 -3
- package/templates/default/public/llms-full.txt +1 -1
- package/templates/default/public/sitemap.xml +10 -10
- package/templates/default/src/data/records.ts +8 -4
- package/templates/default/src/pages/[slug]/{[itemSlug].astro → [recordSlug].astro} +50 -3
- package/templates/default/src/pages/[slug]/index.astro +111 -33
- package/templates/default/src/pages/apps/[recordSlug].astro +33 -0
- package/templates/default/src/pages/apps/[itemSlug].astro +0 -301
- package/templates/default/src/pages/apps/index.astro +0 -146
- /package/src/components/{AppsIndexRow.astro → IndexRow.astro} +0 -0
- /package/src/components/{AppsPagination.astro → Pagination.astro} +0 -0
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@astrojs/check": "^0.9.9",
|
|
28
|
-
"@grove-dev/astro": "0.2.
|
|
29
|
-
"@grove-dev/cli": "0.2.
|
|
30
|
-
"@grove-dev/core": "0.2.
|
|
28
|
+
"@grove-dev/astro": "0.2.16",
|
|
29
|
+
"@grove-dev/cli": "0.2.16",
|
|
30
|
+
"@grove-dev/core": "0.2.16",
|
|
31
31
|
"@tailwindcss/vite": "^4.3.0",
|
|
32
32
|
"astro": "^6.4.4",
|
|
33
33
|
"marked": "^18.0.0",
|
|
@@ -2,61 +2,61 @@
|
|
|
2
2
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
3
3
|
<url>
|
|
4
4
|
<loc>https://example.com/</loc>
|
|
5
|
-
<lastmod>2026-06-
|
|
5
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
6
6
|
<changefreq>daily</changefreq>
|
|
7
7
|
<priority>1.0</priority>
|
|
8
8
|
</url>
|
|
9
9
|
<url>
|
|
10
10
|
<loc>https://example.com/projects</loc>
|
|
11
|
-
<lastmod>2026-06-
|
|
11
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
12
12
|
<changefreq>daily</changefreq>
|
|
13
13
|
<priority>0.9</priority>
|
|
14
14
|
</url>
|
|
15
15
|
<url>
|
|
16
16
|
<loc>https://example.com/projects/coolify</loc>
|
|
17
|
-
<lastmod>2026-06-
|
|
17
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
18
18
|
<changefreq>weekly</changefreq>
|
|
19
19
|
<priority>0.7</priority>
|
|
20
20
|
</url>
|
|
21
21
|
<url>
|
|
22
22
|
<loc>https://example.com/projects/gitea</loc>
|
|
23
|
-
<lastmod>2026-06-
|
|
23
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
24
24
|
<changefreq>weekly</changefreq>
|
|
25
25
|
<priority>0.7</priority>
|
|
26
26
|
</url>
|
|
27
27
|
<url>
|
|
28
28
|
<loc>https://example.com/projects/hoppscotch</loc>
|
|
29
|
-
<lastmod>2026-06-
|
|
29
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
30
30
|
<changefreq>weekly</changefreq>
|
|
31
31
|
<priority>0.7</priority>
|
|
32
32
|
</url>
|
|
33
33
|
<url>
|
|
34
34
|
<loc>https://example.com/projects/inventree</loc>
|
|
35
|
-
<lastmod>2026-06-
|
|
35
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
36
36
|
<changefreq>weekly</changefreq>
|
|
37
37
|
<priority>0.7</priority>
|
|
38
38
|
</url>
|
|
39
39
|
<url>
|
|
40
40
|
<loc>https://example.com/projects/label-studio</loc>
|
|
41
|
-
<lastmod>2026-06-
|
|
41
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
42
42
|
<changefreq>weekly</changefreq>
|
|
43
43
|
<priority>0.7</priority>
|
|
44
44
|
</url>
|
|
45
45
|
<url>
|
|
46
46
|
<loc>https://example.com/projects/logseq</loc>
|
|
47
|
-
<lastmod>2026-06-
|
|
47
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
48
48
|
<changefreq>weekly</changefreq>
|
|
49
49
|
<priority>0.7</priority>
|
|
50
50
|
</url>
|
|
51
51
|
<url>
|
|
52
52
|
<loc>https://example.com/projects/private-gpt</loc>
|
|
53
|
-
<lastmod>2026-06-
|
|
53
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
54
54
|
<changefreq>weekly</changefreq>
|
|
55
55
|
<priority>0.7</priority>
|
|
56
56
|
</url>
|
|
57
57
|
<url>
|
|
58
58
|
<loc>https://example.com/projects/wakapi</loc>
|
|
59
|
-
<lastmod>2026-06-
|
|
59
|
+
<lastmod>2026-06-15T13:49:38.754Z</lastmod>
|
|
60
60
|
<changefreq>weekly</changefreq>
|
|
61
61
|
<priority>0.7</priority>
|
|
62
62
|
</url>
|
|
@@ -67,6 +67,9 @@ interface SiteConfigPayload {
|
|
|
67
67
|
kind?: "project" | "resource" | "entity";
|
|
68
68
|
routeSlug?: string;
|
|
69
69
|
itemSlug?: string;
|
|
70
|
+
/** V1 canonical field name for the record detail slug
|
|
71
|
+
* (replaces the V0 `itemSlug` field). */
|
|
72
|
+
recordSlug?: string;
|
|
70
73
|
labelSingular?: string;
|
|
71
74
|
labelPlural?: string;
|
|
72
75
|
};
|
|
@@ -226,10 +229,11 @@ export function indexSlug(): string {
|
|
|
226
229
|
return blueprintConfig.routeSlug ?? "projects";
|
|
227
230
|
}
|
|
228
231
|
|
|
229
|
-
/** URL slug for a single
|
|
230
|
-
* `[
|
|
231
|
-
|
|
232
|
-
|
|
232
|
+
/** URL slug for a single record detail page (the dynamic
|
|
233
|
+
* `[recordSlug]` segment). Default "project" for backwards-compat
|
|
234
|
+
* with V0-published configs that exposed the field as `itemSlug`. */
|
|
235
|
+
export function recordSlugConfig(): string {
|
|
236
|
+
return blueprintConfig.recordSlug ?? blueprintConfig.itemSlug ?? "project";
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
/** Singular human label, e.g. "project", "resource", "entity". */
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Generic across the 3 record kinds (project / resource / entity) —
|
|
18
18
|
* non-project fields simply render nothing. The URL is dynamic on
|
|
19
19
|
* both `[slug]` (blueprint route, e.g. `projects`/`resources`/
|
|
20
|
-
* `entities`) and `[
|
|
20
|
+
* `entities`) and `[recordSlug]` (the record slug), so a single page
|
|
21
21
|
* file serves every blueprint and every record.
|
|
22
22
|
*/
|
|
23
23
|
import siteConfig from "../../../data/generated/site-config.json";
|
|
@@ -54,12 +54,12 @@ export async function getStaticPaths() {
|
|
|
54
54
|
/* not present */
|
|
55
55
|
}
|
|
56
56
|
return fullRecords.map((record) => ({
|
|
57
|
-
params: { slug: dirSlug,
|
|
57
|
+
params: { slug: dirSlug, recordSlug: record.slug },
|
|
58
58
|
}));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const slug = Astro.params.slug ?? indexSlug();
|
|
62
|
-
const recordSlug = Astro.params.
|
|
62
|
+
const recordSlug = Astro.params.recordSlug ?? "";
|
|
63
63
|
const project = projectBySlug(recordSlug);
|
|
64
64
|
const resource = resourceBySlug(recordSlug);
|
|
65
65
|
const entity = entityBySlug(recordSlug);
|
|
@@ -162,20 +162,51 @@ const languages = extras.github?.languages
|
|
|
162
162
|
const totalLanguageBytes = Math.max(1, languages.reduce((sum, [, b]) => sum + b, 0));
|
|
163
163
|
|
|
164
164
|
// ── Kind-aware JSON-LD ─────────────────────────────────────────
|
|
165
|
+
// Mirrors the openapps field set (10+ fields for projects):
|
|
166
|
+
// name, headline, description, url, codeRepository, sameAs,
|
|
167
|
+
// programmingLanguage, license, applicationCategory,
|
|
168
|
+
// operatingSystem, keywords, dateCreated, dateModified,
|
|
169
|
+
// interactionStatistic, author, isAccessibleForFree.
|
|
165
170
|
let jsonLd: Record<string, unknown>;
|
|
166
171
|
if (isProject && proj) {
|
|
172
|
+
const sameAs = [repoUrl, homepageUrl].filter((value): value is string =>
|
|
173
|
+
typeof value === "string" && value.length > 0,
|
|
174
|
+
);
|
|
175
|
+
const keywords = (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined;
|
|
176
|
+
const operatingSystem = (proj.platforms ?? []).length > 0
|
|
177
|
+
? (proj.platforms ?? []).join(", ")
|
|
178
|
+
: undefined;
|
|
179
|
+
const applicationCategory = (record.category ?? "") || undefined;
|
|
180
|
+
const dateCreated = (record as { curation?: { reviewedAt?: string } }).curation?.reviewedAt;
|
|
181
|
+
const dateModified = pushedAt ?? dateCreated;
|
|
182
|
+
const interactionStatistic = stars > 0
|
|
183
|
+
? {
|
|
184
|
+
"@type": "InteractionCounter",
|
|
185
|
+
interactionType: { "@type": "LikeAction" },
|
|
186
|
+
userInteractionCount: stars,
|
|
187
|
+
}
|
|
188
|
+
: undefined;
|
|
167
189
|
jsonLd = {
|
|
168
190
|
"@context": "https://schema.org",
|
|
169
191
|
"@type": "SoftwareSourceCode",
|
|
170
192
|
name,
|
|
193
|
+
headline: name,
|
|
171
194
|
description: record.description ?? undefined,
|
|
172
195
|
url: repoUrl || homepageUrl || undefined,
|
|
173
196
|
codeRepository: repoUrl || undefined,
|
|
197
|
+
sameAs: sameAs.length > 0 ? sameAs : undefined,
|
|
174
198
|
programmingLanguage: language ?? undefined,
|
|
175
199
|
license: licenseSpdx ?? undefined,
|
|
200
|
+
applicationCategory,
|
|
201
|
+
operatingSystem,
|
|
202
|
+
keywords,
|
|
203
|
+
dateCreated: dateCreated ?? undefined,
|
|
204
|
+
dateModified: dateModified ?? undefined,
|
|
205
|
+
interactionStatistic,
|
|
176
206
|
author: ownerRepo
|
|
177
207
|
? { "@type": "Organization", name: ownerRepo.owner, url: `https://github.com/${ownerRepo.owner}` }
|
|
178
208
|
: undefined,
|
|
209
|
+
isAccessibleForFree: true,
|
|
179
210
|
};
|
|
180
211
|
} else if (record.kind === "resource") {
|
|
181
212
|
const resourceSchemaType: Record<string, string> = {
|
|
@@ -189,20 +220,25 @@ if (isProject && proj) {
|
|
|
189
220
|
"@context": "https://schema.org",
|
|
190
221
|
"@type": resourceSchemaType[record.type] ?? "CreativeWork",
|
|
191
222
|
name,
|
|
223
|
+
headline: name,
|
|
192
224
|
description: record.description || undefined,
|
|
193
225
|
url: homepageUrl || undefined,
|
|
194
226
|
author: record.author ? { "@type": "Person", name: record.author } : undefined,
|
|
195
227
|
datePublished: record.publishedAt || undefined,
|
|
228
|
+
keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
|
|
229
|
+
isAccessibleForFree: true,
|
|
196
230
|
};
|
|
197
231
|
} else {
|
|
198
232
|
jsonLd = {
|
|
199
233
|
"@context": "https://schema.org",
|
|
200
234
|
"@type": record.type === "person" ? "Person" : "Organization",
|
|
201
235
|
name,
|
|
236
|
+
headline: name,
|
|
202
237
|
description: record.description || undefined,
|
|
203
238
|
url: homepageUrl || undefined,
|
|
204
239
|
foundingDate: record.founded || undefined,
|
|
205
240
|
location: record.location || undefined,
|
|
241
|
+
keywords: (record.tags ?? []).length > 0 ? (record.tags ?? []).join(", ") : undefined,
|
|
206
242
|
};
|
|
207
243
|
}
|
|
208
244
|
---
|
|
@@ -264,6 +300,17 @@ if (isProject && proj) {
|
|
|
264
300
|
{labelDisplay(l) ?? prettySlug(l)}
|
|
265
301
|
</span>
|
|
266
302
|
))}
|
|
303
|
+
{record.curation?.reviewed && (
|
|
304
|
+
<span
|
|
305
|
+
class="inline-flex items-center gap-1 rounded border border-emerald-300/60 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-300"
|
|
306
|
+
title="A human curator reviewed this record. (No reviewer name is shown — the framework is reviewer-agnostic; consumers can wire the curation.by field into their own badge component.)"
|
|
307
|
+
>
|
|
308
|
+
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
|
|
309
|
+
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm3.41 5.09a.75.75 0 0 0-1.06 0L6.75 8.69 5.65 7.59a.75.75 0 1 0-1.06 1.06l1.65 1.65a.75.75 0 0 0 1.06 0l4.11-4.11a.75.75 0 0 0 0-1.06Z" />
|
|
310
|
+
</svg>
|
|
311
|
+
Curator reviewed
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
267
314
|
{healthLabel && (
|
|
268
315
|
<span class="grove-badge grove-badge-stale">{healthLabel}</span>
|
|
269
316
|
)}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Mirrors the openapps list structure:
|
|
6
6
|
* - SmartLensTabs (All / Trending / Recently added / Established / Featured)
|
|
7
7
|
* - RefinePanel (Stack / Platform / Category / License / Sort)
|
|
8
|
-
* -
|
|
8
|
+
* - RecordSection rows (full record cards; the `ItemSection` V0
|
|
9
|
+
* name was renamed to `RecordSection` in V1)
|
|
9
10
|
* - ExploreByCategory / ExploreByStack (compact pill lists)
|
|
10
11
|
*
|
|
11
12
|
* Generic: the URL slug (e.g. `/projects/`, `/resources/`,
|
|
@@ -15,11 +16,12 @@
|
|
|
15
16
|
*/
|
|
16
17
|
import siteConfig from "../../../data/generated/site-config.json";
|
|
17
18
|
import { items, itemLabel, itemLabelPlural } from "../../data/records";
|
|
18
|
-
import type {
|
|
19
|
+
import type { IndexFilters } from "@grove-dev/astro";
|
|
19
20
|
import BaseLayout from "@grove-dev/astro/layouts/BaseLayout.astro";
|
|
20
21
|
import SmartLensTabs from "@grove-dev/astro/components/SmartLensTabs.astro";
|
|
21
22
|
import RefinePanel from "@grove-dev/astro/components/RefinePanel.astro";
|
|
22
|
-
import
|
|
23
|
+
import IndexRow from "@grove-dev/astro/components/IndexRow.astro";
|
|
24
|
+
import Pagination from "@grove-dev/astro/components/Pagination.astro";
|
|
23
25
|
import ExploreByCategory from "@grove-dev/astro/components/ExploreByCategory.astro";
|
|
24
26
|
import ExploreByStack from "@grove-dev/astro/components/ExploreByStack.astro";
|
|
25
27
|
import {
|
|
@@ -30,11 +32,12 @@ import {
|
|
|
30
32
|
countByStack,
|
|
31
33
|
effectivePage,
|
|
32
34
|
effectiveSort,
|
|
33
|
-
|
|
35
|
+
filterRecords,
|
|
34
36
|
filtersFromSearchParams,
|
|
35
37
|
paginate,
|
|
36
38
|
searchParamsFromFilters,
|
|
37
39
|
sortDisplay,
|
|
40
|
+
SORT_OPTIONS,
|
|
38
41
|
totalPages,
|
|
39
42
|
} from "@grove-dev/astro";
|
|
40
43
|
|
|
@@ -57,7 +60,7 @@ const filters = filtersFromSearchParams(Astro.url.searchParams);
|
|
|
57
60
|
const facets = buildFacets(items);
|
|
58
61
|
const sort = effectiveSort(filters);
|
|
59
62
|
const requestedPage = effectivePage(filters);
|
|
60
|
-
const filtered =
|
|
63
|
+
const filtered = filterRecords(items, filters);
|
|
61
64
|
const sorted = applySort(filtered, sort);
|
|
62
65
|
const pages = totalPages(sorted.length);
|
|
63
66
|
const page = Math.min(requestedPage, pages);
|
|
@@ -106,15 +109,15 @@ const stackEntries = [...countByStack(items as Parameters<typeof countByStack>[0
|
|
|
106
109
|
.map(([name, count]) => ({ name, slug: name, count }));
|
|
107
110
|
|
|
108
111
|
function hrefForPage(target: number): string {
|
|
109
|
-
const next:
|
|
112
|
+
const next: IndexFilters = { ...filters, page: target };
|
|
110
113
|
const query = searchParamsFromFilters(next).toString();
|
|
111
114
|
return query ? `/${slug}?${query}` : `/${slug}`;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
function hrefForRemove(key: keyof
|
|
115
|
-
const params = new URLSearchParams(
|
|
116
|
-
|
|
117
|
-
const urlKey: Partial<Record<keyof
|
|
117
|
+
function hrefForRemove(key: keyof IndexFilters, value: string): string {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
const next: IndexFilters = { ...filters, page: 1 };
|
|
120
|
+
const urlKey: Partial<Record<keyof IndexFilters, string>> = {
|
|
118
121
|
stacks: "stack",
|
|
119
122
|
platforms: "platform",
|
|
120
123
|
categories: "category",
|
|
@@ -164,6 +167,7 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
164
167
|
<form
|
|
165
168
|
method="get"
|
|
166
169
|
action={`/${slug}`}
|
|
170
|
+
id="search-form"
|
|
167
171
|
class="mt-6 flex flex-col gap-2 sm:flex-row"
|
|
168
172
|
role="search"
|
|
169
173
|
>
|
|
@@ -177,9 +181,10 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
177
181
|
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-400"
|
|
178
182
|
fill="currentColor"
|
|
179
183
|
>
|
|
180
|
-
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.
|
|
184
|
+
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.5 4.5 0 1 0-9 0 4.5 4.5 0 0 0 9 0Z" />
|
|
181
185
|
</svg>
|
|
182
186
|
<input
|
|
187
|
+
id="search-input"
|
|
183
188
|
type="search"
|
|
184
189
|
name="q"
|
|
185
190
|
value={filters.q ?? ""}
|
|
@@ -187,7 +192,28 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
187
192
|
class="h-10 w-full rounded-md border border-ink-200 bg-white py-2 pl-9 pr-3 text-sm text-ink-900 placeholder:text-ink-400 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-100"
|
|
188
193
|
/>
|
|
189
194
|
</label>
|
|
190
|
-
|
|
195
|
+
<label class="relative">
|
|
196
|
+
<span class="sr-only">{`Sort ${itemLabelPlural()}`}</span>
|
|
197
|
+
<select
|
|
198
|
+
id="sort-select"
|
|
199
|
+
name="sort"
|
|
200
|
+
class="h-10 cursor-pointer appearance-none rounded-md border border-ink-200 bg-white py-1.5 pl-3 pr-8 text-sm font-medium text-ink-700 transition-colors hover:border-ink-300 focus:border-ink-900 focus:outline-none focus:ring-2 focus:ring-ink-900/10 dark:border-ink-800 dark:bg-ink-950 dark:text-ink-300 dark:hover:border-ink-700 dark:focus:border-ink-100 dark:focus:ring-ink-100/15"
|
|
201
|
+
>
|
|
202
|
+
{SORT_OPTIONS.map((o) => (
|
|
203
|
+
<option value={o.value} selected={o.value === sort}>{o.label}</option>
|
|
204
|
+
))}
|
|
205
|
+
</select>
|
|
206
|
+
<svg
|
|
207
|
+
viewBox="0 0 16 16"
|
|
208
|
+
width="11"
|
|
209
|
+
height="11"
|
|
210
|
+
aria-hidden="true"
|
|
211
|
+
class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-ink-400"
|
|
212
|
+
fill="currentColor"
|
|
213
|
+
>
|
|
214
|
+
<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" />
|
|
215
|
+
</svg>
|
|
216
|
+
</label>
|
|
191
217
|
<button type="submit" class="grove-btn grove-btn-primary">Search</button>
|
|
192
218
|
</form>
|
|
193
219
|
|
|
@@ -266,8 +292,8 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
266
292
|
|
|
267
293
|
<ul id="results-list" class="m-0 flex list-none flex-col gap-3 p-0">
|
|
268
294
|
{sorted.map((item) => (
|
|
269
|
-
<li class="m-0 p-0" data-
|
|
270
|
-
<
|
|
295
|
+
<li class="m-0 p-0" data-record-slug={item.slug}>
|
|
296
|
+
<IndexRow record={item as Parameters<typeof IndexRow>[0]["record"]} detailHref={`/${slug}/${item.slug}`} />
|
|
271
297
|
</li>
|
|
272
298
|
))}
|
|
273
299
|
</ul>
|
|
@@ -277,7 +303,28 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
277
303
|
</p>
|
|
278
304
|
<a href={`/${slug}`} class="grove-btn grove-btn-outline mt-4">Clear filters</a>
|
|
279
305
|
</div>
|
|
280
|
-
<nav
|
|
306
|
+
<nav class="mt-8 flex flex-wrap items-center justify-center gap-1.5" aria-label="Pagination">
|
|
307
|
+
<Pagination current={page} total={pages} hrefFor={hrefForPage} />
|
|
308
|
+
</nav>
|
|
309
|
+
</div>
|
|
310
|
+
</section>
|
|
311
|
+
|
|
312
|
+
<section class="border-t border-ink-200 dark:border-ink-800">
|
|
313
|
+
<div class="grove-container-wide mx-auto px-5 sm:px-7">
|
|
314
|
+
<div class="flex flex-col items-center gap-3 py-12 text-center">
|
|
315
|
+
<h2 class="m-0 text-xl font-semibold tracking-tight text-ink-900 dark:text-ink-100">
|
|
316
|
+
{`Know a real ${itemLabel()} that belongs here?`}
|
|
317
|
+
</h2>
|
|
318
|
+
<p class="m-0 max-w-2xl text-sm text-ink-500 dark:text-ink-400">
|
|
319
|
+
{`Open a pull request with a YAML file. We review every submission against the bar — no marketing, no tutorials.`}
|
|
320
|
+
</p>
|
|
321
|
+
<a
|
|
322
|
+
href={siteConfig.submitUrl ?? "/submit"}
|
|
323
|
+
class="grove-btn grove-btn-primary mt-2"
|
|
324
|
+
>
|
|
325
|
+
{`Submit ${itemLabel()}`}
|
|
326
|
+
</a>
|
|
327
|
+
</div>
|
|
281
328
|
</div>
|
|
282
329
|
</section>
|
|
283
330
|
|
|
@@ -287,6 +334,49 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
287
334
|
var list = document.getElementById("results-list");
|
|
288
335
|
if (!list) return;
|
|
289
336
|
|
|
337
|
+
// Sort dropdown: navigate on change so the server re-renders
|
|
338
|
+
// the sorted list (URL is the source of truth for filters/sort).
|
|
339
|
+
var sortSelect = document.getElementById("sort-select");
|
|
340
|
+
if (sortSelect) {
|
|
341
|
+
sortSelect.addEventListener("change", function () {
|
|
342
|
+
var params = new URLSearchParams(location.search);
|
|
343
|
+
if (sortSelect.value && sortSelect.value !== "recently-updated") {
|
|
344
|
+
params.set("sort", sortSelect.value);
|
|
345
|
+
} else {
|
|
346
|
+
params.delete("sort");
|
|
347
|
+
}
|
|
348
|
+
params.delete("page");
|
|
349
|
+
var qs = params.toString();
|
|
350
|
+
location.assign(qs ? "?" + qs : location.pathname);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Search: 250ms debounce; pressing Enter still submits
|
|
355
|
+
// immediately via the form's normal submit handler.
|
|
356
|
+
var searchInput = document.getElementById("search-input");
|
|
357
|
+
var searchForm = document.getElementById("search-form");
|
|
358
|
+
if (searchInput && searchForm) {
|
|
359
|
+
var debounceTimer = null;
|
|
360
|
+
var submit = function () {
|
|
361
|
+
var params = new URLSearchParams(location.search);
|
|
362
|
+
var term = searchInput.value.trim();
|
|
363
|
+
if (term) params.set("q", term);
|
|
364
|
+
else params.delete("q");
|
|
365
|
+
params.delete("page");
|
|
366
|
+
var qs = params.toString();
|
|
367
|
+
location.assign(qs ? "?" + qs : location.pathname);
|
|
368
|
+
};
|
|
369
|
+
searchInput.addEventListener("input", function () {
|
|
370
|
+
if (debounceTimer) window.clearTimeout(debounceTimer);
|
|
371
|
+
debounceTimer = window.setTimeout(submit, 250);
|
|
372
|
+
});
|
|
373
|
+
searchForm.addEventListener("submit", function (event) {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
if (debounceTimer) window.clearTimeout(debounceTimer);
|
|
376
|
+
submit();
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
290
380
|
function values(params, key) {
|
|
291
381
|
return params.getAll(key).filter(Boolean);
|
|
292
382
|
}
|
|
@@ -369,11 +459,11 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
369
459
|
var order = new Map(filtered.map(function (item, index) { return [item.slug, index]; }));
|
|
370
460
|
Array.from(list.children)
|
|
371
461
|
.sort(function (a, b) {
|
|
372
|
-
return (order.get(a.dataset.
|
|
373
|
-
(order.get(b.dataset.
|
|
462
|
+
return (order.get(a.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER) -
|
|
463
|
+
(order.get(b.dataset.recordSlug) ?? Number.MAX_SAFE_INTEGER);
|
|
374
464
|
})
|
|
375
465
|
.forEach(function (node) {
|
|
376
|
-
node.hidden = !visible.has(node.dataset.
|
|
466
|
+
node.hidden = !visible.has(node.dataset.recordSlug);
|
|
377
467
|
list.appendChild(node);
|
|
378
468
|
});
|
|
379
469
|
|
|
@@ -395,21 +485,9 @@ function hrefForRemove(key: keyof AppsFilters, value: string): string {
|
|
|
395
485
|
});
|
|
396
486
|
});
|
|
397
487
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (totalPages > 1) {
|
|
402
|
-
for (var number = 1; number <= totalPages; number += 1) {
|
|
403
|
-
var link = document.createElement("a");
|
|
404
|
-
link.href = pageHref(params, number);
|
|
405
|
-
link.textContent = String(number);
|
|
406
|
-
link.className = "grove-btn min-w-9 px-2 py-1.5 text-2xs " +
|
|
407
|
-
(number === page ? "grove-btn-primary" : "grove-btn-outline");
|
|
408
|
-
if (number === page) link.setAttribute("aria-current", "page");
|
|
409
|
-
pager.appendChild(link);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
488
|
+
// Pagination is server-rendered (see <Pagination /> in the
|
|
489
|
+
// template) so the static HTML contains the page-number
|
|
490
|
+
// links for SEO. No client-side rendering loop here.
|
|
413
491
|
|
|
414
492
|
var chipHost = document.getElementById("active-filters");
|
|
415
493
|
if (chipHost) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* /apps/<slug> — V0-published alias URL → V1 canonical.
|
|
4
|
+
*
|
|
5
|
+
* The canonical V1 detail URL is `/<blueprint>/<recordSlug>` (e.g.
|
|
6
|
+
* `/projects/coolify`). The V0 published Astro template exposed
|
|
7
|
+
* `/apps/<slug>` as a static alias to keep the openapps-style URL
|
|
8
|
+
* working for project-directory blueprints. Now that the V1
|
|
9
|
+
* template uses the blueprint-aware dynamic route everywhere,
|
|
10
|
+
* this page is a pure redirect: any `/apps/<slug>` request is
|
|
11
|
+
* sent to the blueprint-canonical path.
|
|
12
|
+
*/
|
|
13
|
+
import { indexSlug, fullRecords, projectBySlug, recordBySlug } from "../../data/records";
|
|
14
|
+
|
|
15
|
+
export async function getStaticPaths() {
|
|
16
|
+
return fullRecords.map((r) => ({ params: { recordSlug: r.slug } }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const slug = indexSlug();
|
|
20
|
+
const recordSlug = Astro.params.recordSlug ?? "";
|
|
21
|
+
|
|
22
|
+
if (slug !== "projects") {
|
|
23
|
+
return Astro.redirect(`/${slug}/${recordSlug}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const record = recordBySlug(recordSlug);
|
|
27
|
+
if (!record) {
|
|
28
|
+
return Astro.redirect(`/${slug}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Canonical V1 URL.
|
|
32
|
+
return Astro.redirect(`/${slug}/${recordSlug}`, 301);
|
|
33
|
+
---
|