@grove-dev/astro 0.4.1 → 0.5.0-next.2
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/package.json +3 -2
- package/src/components/EditorialSummary.astro +57 -0
- package/src/components/IndexRow.astro +2 -2
- package/src/components/ItemCard.astro +2 -1
- package/src/components/LanguageBreakdown.astro +63 -0
- package/src/components/MarkdownBody.astro +37 -0
- package/src/components/RecordHeader.astro +187 -0
- package/src/components/RecordSidebar.astro +339 -0
- package/src/components/SubmissionClient.astro +47 -3
- package/src/components/TableOfContents.astro +115 -0
- package/src/server/collections.ts +77 -1
- package/src/server/directory.ts +311 -65
- package/src/server/models.ts +178 -5
- package/src/styles.css +485 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grove-dev/astro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Composable Astro UI, data adapters, and integration for Grove directories.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,8 +54,9 @@
|
|
|
54
54
|
"astro": "^7.1.3",
|
|
55
55
|
"marked": "^18.0.0",
|
|
56
56
|
"sanitize-html": "^2.17.5",
|
|
57
|
+
"shiki": "^1.29.2",
|
|
57
58
|
"yaml": "^2.9.0",
|
|
58
|
-
"@grove-dev/core": "0.
|
|
59
|
+
"@grove-dev/core": "0.5.0-next.2"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"astro": "^6.0.0 || ^7.0.0"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* EditorialSummary.astro
|
|
4
|
+
*
|
|
5
|
+
* Compact card at the top of the record body that surfaces the
|
|
6
|
+
* curator's "Best for" and "Consider before using" notes. Renders
|
|
7
|
+
* nothing when both arrays are empty.
|
|
8
|
+
*
|
|
9
|
+
* This is the editorial half of the page: the sidebar (right) is
|
|
10
|
+
* auto-generated facts; the summary (left) is human-authored
|
|
11
|
+
* curation.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
bestFor: string[];
|
|
15
|
+
caveats: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { bestFor, caveats } = Astro.props;
|
|
19
|
+
const hasAny = bestFor.length > 0 || caveats.length > 0;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
{hasAny && (
|
|
23
|
+
<section class="grove-editorial mb-10 rounded-[var(--radius-lg)] border border-ink-200 bg-background p-5 dark:border-ink-800" aria-label="Editorial summary">
|
|
24
|
+
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
|
25
|
+
{bestFor.length > 0 && (
|
|
26
|
+
<div>
|
|
27
|
+
<h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-400">
|
|
28
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
|
|
29
|
+
<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" />
|
|
30
|
+
</svg>
|
|
31
|
+
Best for
|
|
32
|
+
</h2>
|
|
33
|
+
<ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
|
|
34
|
+
{bestFor.map((b) => (
|
|
35
|
+
<li class="leading-relaxed">{b}</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
{caveats.length > 0 && (
|
|
41
|
+
<div>
|
|
42
|
+
<h2 class="m-0 flex items-center gap-1.5 text-2xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">
|
|
43
|
+
<svg viewBox="0 0 16 16" width="11" height="11" aria-hidden="true" fill="currentColor">
|
|
44
|
+
<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 .4 1.04l1.6.8a.75.75 0 1 0 .67-1.34l-1.17-.59V4.75z" />
|
|
45
|
+
</svg>
|
|
46
|
+
Consider before using
|
|
47
|
+
</h2>
|
|
48
|
+
<ul class="mt-2 m-0 flex list-none flex-col gap-1.5 p-0 text-sm text-ink-700 dark:text-ink-200">
|
|
49
|
+
{caveats.map((c) => (
|
|
50
|
+
<li class="leading-relaxed">{c}</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { IndexRecord } from "@grove-dev/core";
|
|
3
3
|
import StackPlatformChips from "./StackPlatformChips.astro";
|
|
4
|
-
import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay } from "@grove-dev/astro";
|
|
4
|
+
import { formatRelative, formatStars, getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds, statusDisplay, licenseDisplay } from "@grove-dev/astro";
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
7
|
record: IndexRecord;
|
|
@@ -71,7 +71,7 @@ const curated = Boolean(item.curation?.reviewed || (project && (project.bestFor.
|
|
|
71
71
|
<dl class="grid grid-cols-3 gap-x-3 text-2xs">
|
|
72
72
|
<div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Stars</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatStars(stars) ?? "—"}</dd></div>
|
|
73
73
|
<div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">Updated</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{formatRelative(updatedAt)}</dd></div>
|
|
74
|
-
<div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license
|
|
74
|
+
<div class="flex items-baseline gap-1.5"><dt class="font-medium uppercase tracking-wider text-ink-500 dark:text-ink-400">License</dt><dd class="font-mono text-ink-700 dark:text-ink-300">{license ? licenseDisplay(license) : "—"}</dd></div>
|
|
75
75
|
</dl>
|
|
76
76
|
|
|
77
77
|
{showChips && (platforms.length > 0 || stacks.length > 0) && (
|
|
@@ -28,6 +28,7 @@ import StackPlatformChips from "./StackPlatformChips.astro";
|
|
|
28
28
|
import { getOwnerAndRepoFromRepoUrl, getOwnerAvatarUrl, projectStackIds } from "@grove-dev/astro";
|
|
29
29
|
import { formatStars, formatRelative } from "@grove-dev/astro";
|
|
30
30
|
import { statusDisplay } from "@grove-dev/astro";
|
|
31
|
+
import { licenseDisplay } from "@grove-dev/astro";
|
|
31
32
|
|
|
32
33
|
type CardRecord = IndexRecord | Resource | ProjectRecord | IndexProjectRecord;
|
|
33
34
|
|
|
@@ -273,7 +274,7 @@ const category = (item as { category?: string }).category;
|
|
|
273
274
|
)}
|
|
274
275
|
{license && (
|
|
275
276
|
<span class="inline-flex items-center gap-1" title={`License: ${license}`}>
|
|
276
|
-
<span class="font-mono uppercase tracking-wide text-[10px]">{license}</span>
|
|
277
|
+
<span class="font-mono uppercase tracking-wide text-[10px]">{licenseDisplay(license)}</span>
|
|
277
278
|
</span>
|
|
278
279
|
)}
|
|
279
280
|
</div>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* LanguageBreakdown.astro
|
|
4
|
+
*
|
|
5
|
+
* Visual GitHub-Linguist-style code-composition bar plus a legend
|
|
6
|
+
* showing each language's percentage. Reads from the record's
|
|
7
|
+
* pre-computed `languages` array (top-5 by bytes) and `langColor`
|
|
8
|
+
* (in @grove-dev/core).
|
|
9
|
+
*
|
|
10
|
+
* Used inside the Activity sidebar card.
|
|
11
|
+
*/
|
|
12
|
+
import { langColor } from "@grove-dev/core";
|
|
13
|
+
|
|
14
|
+
interface LangEntry {
|
|
15
|
+
name: string;
|
|
16
|
+
bytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Top-N languages as `[name, bytes]` tuples (already sorted). */
|
|
21
|
+
languages: ReadonlyArray<readonly [string, number]>;
|
|
22
|
+
/** Total bytes across all languages (denominator). */
|
|
23
|
+
totalBytes: number;
|
|
24
|
+
/** Optional max segments to show. */
|
|
25
|
+
maxSegments?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { languages, totalBytes, maxSegments = 5 } = Astro.props;
|
|
29
|
+
|
|
30
|
+
const segments = languages.slice(0, maxSegments).map(([name, bytes]) => {
|
|
31
|
+
const pct = Math.round((bytes / Math.max(1, totalBytes)) * 100);
|
|
32
|
+
return { name, pct, color: langColor(name) };
|
|
33
|
+
});
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
{segments.length > 0 && (
|
|
37
|
+
<div class="grove-langbreak mt-4 border-t border-ink-100 pt-4 dark:border-ink-800">
|
|
38
|
+
<h3 class="m-0 text-2xs font-semibold uppercase tracking-wider text-ink-500 dark:text-ink-400">
|
|
39
|
+
Code composition
|
|
40
|
+
</h3>
|
|
41
|
+
<div class="grove-langbreak-bar mt-2 flex h-2 w-full overflow-hidden rounded-full bg-ink-100 dark:bg-ink-800">
|
|
42
|
+
{segments.map((s) => (
|
|
43
|
+
<span
|
|
44
|
+
class="grove-langbreak-seg h-full"
|
|
45
|
+
style={`width:${s.pct}%;background-color:${s.color}`}
|
|
46
|
+
title={`${s.name}: ${s.pct}%`}
|
|
47
|
+
/>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
<ul class="grove-langbreak-list mt-2 flex flex-wrap gap-x-3 gap-y-1 text-2xs text-ink-500 dark:text-ink-400">
|
|
51
|
+
{segments.map((s) => (
|
|
52
|
+
<li class="inline-flex items-center gap-1">
|
|
53
|
+
<span
|
|
54
|
+
class="inline-block h-2 w-2 rounded-sm"
|
|
55
|
+
style={`background-color:${s.color}`}
|
|
56
|
+
/>
|
|
57
|
+
<span class="font-mono">{s.name}</span>
|
|
58
|
+
<span>{s.pct}%</span>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* MarkdownBody.astro
|
|
4
|
+
*
|
|
5
|
+
* Renders a record's pre-sanitized Markdown body (`getContentHtml`)
|
|
6
|
+
* inside the package's `.grove-prose` stylesheet.
|
|
7
|
+
*
|
|
8
|
+
* The renderer itself lives in `@grove-dev/astro/server/directory.ts`
|
|
9
|
+
* (`renderMarkdownToSafeHtml`) and runs once per record at module
|
|
10
|
+
* load; this component is the presentation half of that pipeline.
|
|
11
|
+
*
|
|
12
|
+
* Props:
|
|
13
|
+
* - `recordSlug`: the **record's** slug (not the route prefix).
|
|
14
|
+
* `getContentHtml` is keyed by record slug — `detail.slug` is the
|
|
15
|
+
* route prefix (e.g. "projects"), `detail.record.slug` is the
|
|
16
|
+
* record (e.g. "crewai").
|
|
17
|
+
* - `fallback`: optional content to render when the record has no
|
|
18
|
+
* body (e.g. an empty-state message).
|
|
19
|
+
*/
|
|
20
|
+
import { getContentHtml } from "../server/directory.js";
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/** Record slug — looks up the pre-rendered HTML in the module map. */
|
|
24
|
+
recordSlug: string;
|
|
25
|
+
/** Optional fallback content for records without a body. */
|
|
26
|
+
fallback?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { recordSlug, fallback } = Astro.props;
|
|
30
|
+
const html = getContentHtml(recordSlug);
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
{html ? (
|
|
34
|
+
<div class="grove-prose" set:html={html} />
|
|
35
|
+
) : fallback ? (
|
|
36
|
+
<p class="grove-prose text-ink-500 dark:text-ink-400">{fallback}</p>
|
|
37
|
+
) : null}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* RecordHeader.astro
|
|
4
|
+
*
|
|
5
|
+
* Compact project-identity header used at the top of every record
|
|
6
|
+
* detail page. The header carries:
|
|
7
|
+
* - avatar / initials
|
|
8
|
+
* - pills row (category + curation labels + "Curated" check +
|
|
9
|
+
* optional activity badge)
|
|
10
|
+
* - record name + owner/repo line
|
|
11
|
+
* - short description
|
|
12
|
+
* - GitHub + Homepage CTAs
|
|
13
|
+
*
|
|
14
|
+
* Tags + activity metadata are surfaced separately (sidebar), so
|
|
15
|
+
* the header stays narrow on mobile and doesn't compete with the
|
|
16
|
+
* two-column layout below.
|
|
17
|
+
*
|
|
18
|
+
* Tone classes for the activity badge are static `[data-activity-tone]`
|
|
19
|
+
* selectors in `styles.css` — Tailwind purges any classes built from
|
|
20
|
+
* dynamic strings, so the package ships the rules statically and
|
|
21
|
+
* the badge's `data-activity-tone` attribute picks one at runtime.
|
|
22
|
+
*/
|
|
23
|
+
import type { RecordDetailModel } from "../server/models.js";
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
detail: RecordDetailModel;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { detail } = Astro.props;
|
|
30
|
+
const {
|
|
31
|
+
record,
|
|
32
|
+
repoUrl,
|
|
33
|
+
homepageUrl,
|
|
34
|
+
ownerRepo,
|
|
35
|
+
avatarSrc,
|
|
36
|
+
initials,
|
|
37
|
+
curationLabels,
|
|
38
|
+
activityBadge,
|
|
39
|
+
summary,
|
|
40
|
+
sourceDescription,
|
|
41
|
+
screenshots,
|
|
42
|
+
category,
|
|
43
|
+
description,
|
|
44
|
+
name,
|
|
45
|
+
} = detail;
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<header class="mb-10 flex flex-col gap-5 sm:flex-row sm:items-start sm:gap-6">
|
|
49
|
+
{avatarSrc ? (
|
|
50
|
+
<img
|
|
51
|
+
src={avatarSrc}
|
|
52
|
+
alt=""
|
|
53
|
+
width="64"
|
|
54
|
+
height="64"
|
|
55
|
+
loading="lazy"
|
|
56
|
+
decoding="async"
|
|
57
|
+
class="h-14 w-14 shrink-0 rounded-lg border border-ink-200 object-contain bg-white p-1 dark:border-ink-800 dark:bg-ink-950 sm:h-16 sm:w-16"
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
class="inline-flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border border-ink-200 bg-ink-50 text-xl font-semibold text-ink-700 dark:border-ink-800 dark:bg-ink-900 dark:text-ink-200 sm:h-16 sm:w-16"
|
|
63
|
+
>
|
|
64
|
+
{initials || "·"}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div class="min-w-0 flex-1">
|
|
69
|
+
{/* Pills row — category + curation + Curated check + activity badge. */}
|
|
70
|
+
<div class="flex flex-wrap items-center gap-1.5">
|
|
71
|
+
{category && (
|
|
72
|
+
<span class="rounded-full border border-ink-200 px-2 py-0.5 text-2xs font-medium text-ink-700 dark:border-ink-800 dark:text-ink-300">
|
|
73
|
+
{category}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{curationLabels.map((l) => (
|
|
77
|
+
<span class="rounded-full bg-secondary px-2 py-0.5 text-2xs font-medium text-secondary-foreground">
|
|
78
|
+
{l}
|
|
79
|
+
</span>
|
|
80
|
+
))}
|
|
81
|
+
{record.curation?.reviewed && (
|
|
82
|
+
<span 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">
|
|
83
|
+
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true" fill="currentColor">
|
|
84
|
+
<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" />
|
|
85
|
+
</svg>
|
|
86
|
+
Curated
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
{activityBadge && (
|
|
90
|
+
<span
|
|
91
|
+
data-activity-tone={activityBadge.tone}
|
|
92
|
+
class="grove-pill grove-pill--activity inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium"
|
|
93
|
+
>
|
|
94
|
+
<span class="relative flex h-1.5 w-1.5">
|
|
95
|
+
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"></span>
|
|
96
|
+
<span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
97
|
+
</span>
|
|
98
|
+
{activityBadge.label}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<h1 class="m-0 mt-2 text-[2rem] font-semibold leading-tight tracking-tight text-ink-900 dark:text-ink-100 sm:text-[2.25rem]">
|
|
104
|
+
{name}
|
|
105
|
+
</h1>
|
|
106
|
+
|
|
107
|
+
{ownerRepo && (
|
|
108
|
+
<p class="m-0 mt-1 text-sm text-ink-500 dark:text-ink-400">
|
|
109
|
+
<a
|
|
110
|
+
href={`https://github.com/${ownerRepo.owner}`}
|
|
111
|
+
target="_blank"
|
|
112
|
+
rel="noopener noreferrer"
|
|
113
|
+
class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
|
|
114
|
+
>
|
|
115
|
+
@{ownerRepo.owner}
|
|
116
|
+
</a>
|
|
117
|
+
<span class="mx-1 text-ink-300 dark:text-ink-700">/</span>
|
|
118
|
+
<a
|
|
119
|
+
href={repoUrl}
|
|
120
|
+
target="_blank"
|
|
121
|
+
rel="noopener noreferrer"
|
|
122
|
+
class="text-inherit no-underline hover:text-ink-900 dark:hover:text-ink-100"
|
|
123
|
+
>
|
|
124
|
+
{ownerRepo.repo}
|
|
125
|
+
</a>
|
|
126
|
+
</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{summary && (
|
|
130
|
+
<p class="m-0 mt-3 max-w-[68ch] text-base leading-relaxed text-ink-700 dark:text-ink-200">
|
|
131
|
+
{summary}
|
|
132
|
+
</p>
|
|
133
|
+
)}
|
|
134
|
+
{sourceDescription && sourceDescription !== summary && (
|
|
135
|
+
<p class="m-0 mt-2 max-w-[68ch] text-sm leading-relaxed text-ink-500 dark:text-ink-400">
|
|
136
|
+
<span class="text-2xs font-semibold uppercase tracking-wider">From the project's README: </span>
|
|
137
|
+
{sourceDescription}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* CTA row — primary actions only. */}
|
|
142
|
+
<div class="mt-4 flex flex-wrap items-center gap-2">
|
|
143
|
+
{repoUrl && (
|
|
144
|
+
<a
|
|
145
|
+
href={repoUrl}
|
|
146
|
+
target="_blank"
|
|
147
|
+
rel="noopener noreferrer"
|
|
148
|
+
class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-transparent px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 bg-primary text-primary-foreground hover:bg-primary/90"
|
|
149
|
+
>
|
|
150
|
+
View on GitHub →
|
|
151
|
+
</a>
|
|
152
|
+
)}
|
|
153
|
+
{homepageUrl && (
|
|
154
|
+
<a
|
|
155
|
+
href={homepageUrl}
|
|
156
|
+
target="_blank"
|
|
157
|
+
rel="noopener noreferrer"
|
|
158
|
+
class="inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[var(--radius)] border border-border bg-secondary px-3 text-sm font-medium leading-none no-underline outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 text-foreground hover:bg-accent"
|
|
159
|
+
>
|
|
160
|
+
Homepage
|
|
161
|
+
</a>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
{screenshots && screenshots.length > 0 && (
|
|
166
|
+
<div class="mt-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-label="Screenshots">
|
|
167
|
+
{screenshots.map((shot) => (
|
|
168
|
+
<figure class="m-0 overflow-hidden rounded-[var(--radius-lg)] border border-ink-200 bg-muted dark:border-ink-800">
|
|
169
|
+
<img
|
|
170
|
+
src={shot.src}
|
|
171
|
+
alt={shot.alt}
|
|
172
|
+
loading="lazy"
|
|
173
|
+
decoding="async"
|
|
174
|
+
width={shot.width}
|
|
175
|
+
height={shot.height}
|
|
176
|
+
class="block h-auto w-full object-cover"
|
|
177
|
+
/>
|
|
178
|
+
{shot.source && (
|
|
179
|
+
<figcaption class="px-3 py-1.5 text-2xs text-ink-500 dark:text-ink-400">
|
|
180
|
+
Source: <a href={shot.source} target="_blank" rel="noopener noreferrer" class="text-inherit no-underline hover:underline">{shot.source.replace(/^https?:\/\//, "")}</a>
|
|
181
|
+
</figcaption>
|
|
182
|
+
)}
|
|
183
|
+
</figure>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</header>
|