@spunto/design-system 0.9.3 → 0.11.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 +102 -4
- package/package.json +9 -1
- package/src/components/command-palette.tsx +702 -0
- package/src/components/devcontainer/catalog-card.tsx +269 -0
- package/src/components/devcontainer/catalog-tile.tsx +133 -0
- package/src/components/devcontainer/extension-card.tsx +146 -0
- package/src/components/devcontainer/feature-card.tsx +157 -0
- package/src/components/devcontainer/feature-tile.tsx +63 -0
- package/src/components/devcontainer/image-card.tsx +107 -0
- package/src/components/devcontainer/image-tile.tsx +56 -0
- package/src/components/devcontainer/index.ts +68 -0
- package/src/components/devcontainer/publisher-badge.tsx +91 -0
- package/src/components/devcontainer/registry-ref.tsx +89 -0
- package/src/components/devcontainer/registry.ts +154 -0
- package/src/components/devcontainer/stack-mark-paths.ts +60 -0
- package/src/components/devcontainer/stack-mark.tsx +92 -0
- package/src/components/devcontainer/types.ts +84 -0
- package/src/components/kbd.tsx +117 -0
- package/src/components/projects/advanced-sections.tsx +136 -0
- package/src/components/projects/build-manifest.tsx +116 -0
- package/src/components/projects/extension-picker.tsx +169 -0
- package/src/components/projects/feature-picker.tsx +193 -0
- package/src/components/projects/form-section.tsx +97 -0
- package/src/components/projects/image-picker.tsx +206 -0
- package/src/components/projects/index.ts +58 -0
- package/src/components/projects/project-form.tsx +488 -0
- package/src/components/projects/repo-list.tsx +302 -0
- package/src/components/projects/secret-list.tsx +159 -0
- package/src/components/projects/types.ts +136 -0
- package/src/index.ts +35 -0
- package/styles.css +13 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from "react"
|
|
4
|
+
import { CheckIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { monogram, PublisherAvatar } from "./publisher-badge"
|
|
8
|
+
import { StackMark, type StackMarkName } from "./stack-mark"
|
|
9
|
+
import type { CatalogPublisher } from "./types"
|
|
10
|
+
|
|
11
|
+
/** Warm accents the catalogs use — one per pillar, plus the brand flame. */
|
|
12
|
+
export type CatalogTone = "build" | "ship" | "run" | "flame"
|
|
13
|
+
|
|
14
|
+
const TONE_TILE: Record<CatalogTone, string> = {
|
|
15
|
+
build: "bg-build/10 text-build",
|
|
16
|
+
ship: "bg-ship/10 text-ship",
|
|
17
|
+
run: "bg-run/10 text-run",
|
|
18
|
+
flame: "bg-primary/10 text-primary",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TONE_WATERMARK: Record<CatalogTone, string> = {
|
|
22
|
+
build: "text-build",
|
|
23
|
+
ship: "text-ship",
|
|
24
|
+
run: "text-run",
|
|
25
|
+
flame: "text-primary",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CatalogMarkProps {
|
|
29
|
+
/** A real logo (an extension icon, say). Beats every derived mark. */
|
|
30
|
+
iconUrl?: string | null
|
|
31
|
+
/** Embedded brand mark, when one could be derived. */
|
|
32
|
+
mark?: StackMarkName | string | null
|
|
33
|
+
/** Used as the mark when no brand matched — an author's avatar still says something. */
|
|
34
|
+
publisher?: CatalogPublisher
|
|
35
|
+
/** Last resort: initials. */
|
|
36
|
+
label?: string
|
|
37
|
+
tone?: CatalogTone
|
|
38
|
+
compact?: boolean
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tile at the left of every catalog card, resolved by a single cascade —
|
|
44
|
+
* **real icon → brand mark → publisher avatar → monogram**. Each catalog has a
|
|
45
|
+
* different amount of truth available (Open VSX ships an icon, ghcr.io only
|
|
46
|
+
* implies a GitHub org, a hand-typed ref implies nothing), and this cascade is
|
|
47
|
+
* what makes all three grids look like one product anyway.
|
|
48
|
+
*/
|
|
49
|
+
export function CatalogMark({
|
|
50
|
+
iconUrl,
|
|
51
|
+
mark,
|
|
52
|
+
publisher,
|
|
53
|
+
label,
|
|
54
|
+
tone = "build",
|
|
55
|
+
compact = false,
|
|
56
|
+
className,
|
|
57
|
+
}: CatalogMarkProps) {
|
|
58
|
+
const [broken, setBroken] = useState(false)
|
|
59
|
+
const box = compact ? "h-5 w-5 rounded" : "h-9 w-9 rounded-md"
|
|
60
|
+
|
|
61
|
+
if (iconUrl && !broken) {
|
|
62
|
+
return (
|
|
63
|
+
<img
|
|
64
|
+
src={iconUrl}
|
|
65
|
+
alt=""
|
|
66
|
+
loading="lazy"
|
|
67
|
+
onError={() => setBroken(true)}
|
|
68
|
+
className={cn(box, "shrink-0 bg-muted object-contain ring-1 ring-border/60", className)}
|
|
69
|
+
/>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (mark) {
|
|
74
|
+
return (
|
|
75
|
+
<span className={cn(box, "flex shrink-0 items-center justify-center", TONE_TILE[tone], className)}>
|
|
76
|
+
<StackMark name={mark} className={compact ? "h-3 w-3" : "h-5 w-5"} />
|
|
77
|
+
</span>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (publisher?.avatarUrl) {
|
|
82
|
+
return <PublisherAvatar publisher={publisher} fallbackLabel={label} className={cn(box, "rounded-md", className)} />
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
aria-hidden
|
|
88
|
+
className={cn(
|
|
89
|
+
box,
|
|
90
|
+
"flex shrink-0 items-center justify-center font-semibold",
|
|
91
|
+
compact ? "text-[8px]" : "text-[11px]",
|
|
92
|
+
TONE_TILE[tone],
|
|
93
|
+
className
|
|
94
|
+
)}
|
|
95
|
+
>
|
|
96
|
+
{monogram(label || publisher?.name || publisher?.login)}
|
|
97
|
+
</span>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CatalogCardProps {
|
|
102
|
+
/** The tile — usually a `CatalogMark`. */
|
|
103
|
+
mark?: ReactNode
|
|
104
|
+
title: ReactNode
|
|
105
|
+
/** Same line as the title, right after it: a version, a tag. */
|
|
106
|
+
titleMeta?: ReactNode
|
|
107
|
+
/** The publisher line, under the title. */
|
|
108
|
+
meta?: ReactNode
|
|
109
|
+
description?: ReactNode
|
|
110
|
+
/** Chips, refs — anything under the description. */
|
|
111
|
+
children?: ReactNode
|
|
112
|
+
|
|
113
|
+
selected?: boolean
|
|
114
|
+
/**
|
|
115
|
+
* Provided → the card is selectable (a stretched button covers it, and the
|
|
116
|
+
* flame check + ring appear when `selected`). Absent → a plain read-only
|
|
117
|
+
* card, which is exactly what the project sheet needs.
|
|
118
|
+
*/
|
|
119
|
+
onSelect?: () => void
|
|
120
|
+
disabled?: boolean
|
|
121
|
+
|
|
122
|
+
/** One-line, denser rendering: no description, no watermark. */
|
|
123
|
+
compact?: boolean
|
|
124
|
+
/** Nothing was derivable (a hand-typed ref) → dashed border. */
|
|
125
|
+
custom?: boolean
|
|
126
|
+
|
|
127
|
+
/** Trailing control (a remove ✕). Sits above the selection overlay. */
|
|
128
|
+
action?: ReactNode
|
|
129
|
+
/** Brand mark bled large and faint into the background. */
|
|
130
|
+
watermark?: StackMarkName | string | null
|
|
131
|
+
tone?: CatalogTone
|
|
132
|
+
/** Accessible name of the selection button when the title isn't a string. */
|
|
133
|
+
selectLabel?: string
|
|
134
|
+
className?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The shell all three catalog cards share: tile, title, meta, description,
|
|
139
|
+
* extras — plus the selected state (flame check + ring) that the three pickers
|
|
140
|
+
* each used to re-implement in a `<button>` of their own.
|
|
141
|
+
*
|
|
142
|
+
* **Why the selection is a stretched overlay button rather than a `<button>`
|
|
143
|
+
* wrapping everything:** these cards carry controls of their own (copy the ref,
|
|
144
|
+
* remove a custom entry), and a button inside a button is invalid HTML —
|
|
145
|
+
* browsers unnest it and the inner control stops working. So the card is a
|
|
146
|
+
* `<div>`, selection is one `absolute inset-0` button covering it, and inner
|
|
147
|
+
* controls sit above that overlay on `z-10`. The card's own text is left
|
|
148
|
+
* *static* on purpose: static content paints below positioned siblings, so the
|
|
149
|
+
* overlay covers it and a click anywhere on the card still selects it.
|
|
150
|
+
*/
|
|
151
|
+
export function CatalogCard({
|
|
152
|
+
mark,
|
|
153
|
+
title,
|
|
154
|
+
titleMeta,
|
|
155
|
+
meta,
|
|
156
|
+
description,
|
|
157
|
+
children,
|
|
158
|
+
selected = false,
|
|
159
|
+
onSelect,
|
|
160
|
+
disabled = false,
|
|
161
|
+
compact = false,
|
|
162
|
+
custom = false,
|
|
163
|
+
action,
|
|
164
|
+
watermark,
|
|
165
|
+
tone = "build",
|
|
166
|
+
selectLabel,
|
|
167
|
+
className,
|
|
168
|
+
}: CatalogCardProps) {
|
|
169
|
+
const selectable = Boolean(onSelect)
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
data-slot="catalog-card"
|
|
174
|
+
data-selected={selected || undefined}
|
|
175
|
+
// `isolate`: the watermark sits on a negative z-index so it stays behind
|
|
176
|
+
// the text, and without a stacking context here it would slide behind the
|
|
177
|
+
// card's own background instead.
|
|
178
|
+
className={cn(
|
|
179
|
+
"group relative isolate flex min-w-0 items-start gap-3 overflow-hidden border bg-card text-left transition-colors",
|
|
180
|
+
compact ? "gap-2 rounded-md px-2 py-1.5" : "rounded-lg px-3 py-2.5",
|
|
181
|
+
custom ? "border-dashed border-border" : "border-border",
|
|
182
|
+
selected && "border-primary bg-primary/5 ring-1 ring-primary",
|
|
183
|
+
selectable && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
|
|
184
|
+
disabled && "opacity-50",
|
|
185
|
+
className
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{/* The runtime's mark, bled large into the corner and almost invisible.
|
|
189
|
+
It's texture, not information — it's what lets you tell a Python card
|
|
190
|
+
from a Go one out of the corner of your eye, in a grid where every
|
|
191
|
+
card is otherwise the same three lines of text. */}
|
|
192
|
+
{watermark && !compact && (
|
|
193
|
+
<span
|
|
194
|
+
aria-hidden
|
|
195
|
+
className={cn(
|
|
196
|
+
"pointer-events-none absolute -top-5 -right-6 -z-10 transition-opacity",
|
|
197
|
+
selected ? "opacity-[0.08]" : "opacity-[0.04] group-hover:opacity-[0.06]",
|
|
198
|
+
TONE_WATERMARK[tone]
|
|
199
|
+
)}
|
|
200
|
+
>
|
|
201
|
+
<StackMark name={watermark} className="h-28 w-28" />
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
|
|
205
|
+
{selectable && (
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={onSelect}
|
|
209
|
+
disabled={disabled}
|
|
210
|
+
aria-pressed={selected}
|
|
211
|
+
aria-label={selectLabel ?? (typeof title === "string" ? title : undefined)}
|
|
212
|
+
className={cn(
|
|
213
|
+
"absolute inset-0 cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed",
|
|
214
|
+
compact ? "rounded-md" : "rounded-lg"
|
|
215
|
+
)}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{mark && <span className="mt-px">{mark}</span>}
|
|
220
|
+
|
|
221
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
222
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
223
|
+
<span className={cn("min-w-0 truncate font-medium leading-tight", compact ? "text-xs" : "text-sm")}>
|
|
224
|
+
{title}
|
|
225
|
+
</span>
|
|
226
|
+
{titleMeta}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{meta && <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">{meta}</div>}
|
|
230
|
+
|
|
231
|
+
{description && !compact && <p className="line-clamp-2 text-xs text-muted-foreground">{description}</p>}
|
|
232
|
+
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* The check sits in the flow rather than pinned absolutely: at 288 px a
|
|
237
|
+
title wrapping onto two lines used to slide under a pinned check. */}
|
|
238
|
+
{(selected || action) && (
|
|
239
|
+
<span className="relative z-10 flex shrink-0 items-center gap-1 self-start pt-px">
|
|
240
|
+
{selected && <CheckIcon className={cn("text-primary", compact ? "h-3.5 w-3.5" : "h-4 w-4")} aria-hidden />}
|
|
241
|
+
{action}
|
|
242
|
+
</span>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Chip for a feature option, an extension version, a download count. */
|
|
249
|
+
export function CatalogChip({
|
|
250
|
+
children,
|
|
251
|
+
title,
|
|
252
|
+
className,
|
|
253
|
+
}: {
|
|
254
|
+
children: ReactNode
|
|
255
|
+
title?: string
|
|
256
|
+
className?: string
|
|
257
|
+
}) {
|
|
258
|
+
return (
|
|
259
|
+
<span
|
|
260
|
+
title={title}
|
|
261
|
+
className={cn(
|
|
262
|
+
"inline-flex max-w-full items-center gap-1 truncate rounded border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground",
|
|
263
|
+
className
|
|
264
|
+
)}
|
|
265
|
+
>
|
|
266
|
+
{children}
|
|
267
|
+
</span>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { CheckIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import type { CatalogTone } from "./catalog-card"
|
|
8
|
+
|
|
9
|
+
const TONE = {
|
|
10
|
+
build: "text-build",
|
|
11
|
+
ship: "text-ship",
|
|
12
|
+
run: "text-run",
|
|
13
|
+
flame: "text-primary",
|
|
14
|
+
} as const satisfies Record<CatalogTone, string>
|
|
15
|
+
|
|
16
|
+
export interface CatalogTileProps {
|
|
17
|
+
/** The glyph — a brand mark, an avatar, a `+`. Sized by the tile. */
|
|
18
|
+
mark: ReactNode
|
|
19
|
+
label: string
|
|
20
|
+
/** Trailing detail: an image tag, a version. Dropped in `tile` when it doesn't fit. */
|
|
21
|
+
meta?: string
|
|
22
|
+
/** Full text for the native tooltip — the label truncates. */
|
|
23
|
+
title?: string
|
|
24
|
+
|
|
25
|
+
selected?: boolean
|
|
26
|
+
onSelect?: () => void
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
custom?: boolean
|
|
29
|
+
variant?: "chip" | "tile"
|
|
30
|
+
tone?: CatalogTone
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The shell behind `ImageTile` and `FeatureTile`: a catalog entry reduced to a
|
|
36
|
+
* glyph, a name and one trailing detail.
|
|
37
|
+
*
|
|
38
|
+
* Extracted rather than copied, because the two only differ in what they *put*
|
|
39
|
+
* in it — an image derives a runtime mark and a tag, a feature derives its
|
|
40
|
+
* author's avatar — and this package exists to stop that kind of near-duplicate
|
|
41
|
+
* from drifting apart.
|
|
42
|
+
*
|
|
43
|
+
* `chip` flows and wraps like a tag; `tile` is a square-ish grid cell. Both
|
|
44
|
+
* carry the same flame check as the full cards, because a chip grid and a card
|
|
45
|
+
* grid are the same choice and "this one is picked" has to look the same.
|
|
46
|
+
*/
|
|
47
|
+
export function CatalogTile({
|
|
48
|
+
mark,
|
|
49
|
+
label,
|
|
50
|
+
meta,
|
|
51
|
+
title,
|
|
52
|
+
selected = false,
|
|
53
|
+
onSelect,
|
|
54
|
+
disabled = false,
|
|
55
|
+
custom = false,
|
|
56
|
+
variant = "chip",
|
|
57
|
+
tone = "ship",
|
|
58
|
+
className,
|
|
59
|
+
}: CatalogTileProps) {
|
|
60
|
+
const Root = onSelect ? "button" : "div"
|
|
61
|
+
|
|
62
|
+
const common = {
|
|
63
|
+
"data-slot": "catalog-tile",
|
|
64
|
+
"data-selected": selected || undefined,
|
|
65
|
+
"data-variant": variant,
|
|
66
|
+
title: title ?? label,
|
|
67
|
+
...(onSelect
|
|
68
|
+
? { type: "button" as const, onClick: onSelect, disabled, "aria-pressed": selected, "aria-label": label }
|
|
69
|
+
: {}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const state = cn(
|
|
73
|
+
custom ? "border-dashed border-border" : "border-border",
|
|
74
|
+
selected ? "border-primary bg-primary/5 ring-1 ring-primary" : "bg-card",
|
|
75
|
+
onSelect && !disabled && "cursor-pointer",
|
|
76
|
+
onSelect && !selected && !disabled && "hover:border-muted-foreground/40 hover:bg-muted/50",
|
|
77
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const markClass = cn(
|
|
81
|
+
"flex shrink-0 items-center justify-center transition-colors",
|
|
82
|
+
selected ? TONE[tone] : "text-muted-foreground group-hover:text-foreground"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (variant === "chip") {
|
|
86
|
+
return (
|
|
87
|
+
<Root
|
|
88
|
+
{...common}
|
|
89
|
+
className={cn(
|
|
90
|
+
// `inline-flex` + the parent's `flex-wrap`: entries flow and break
|
|
91
|
+
// onto the next line like text, so a catalog of any length just takes
|
|
92
|
+
// the rows it needs — no column count to choose, no ragged grid.
|
|
93
|
+
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-md border py-1.5 pr-2.5 pl-2 text-left transition-colors",
|
|
94
|
+
state,
|
|
95
|
+
className
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<span className={cn(markClass, "h-5 w-5")}>{mark}</span>
|
|
99
|
+
<span className="min-w-0 truncate text-xs font-medium">{label}</span>
|
|
100
|
+
{meta && <span className="shrink-0 font-mono text-[10px] leading-none text-muted-foreground/60">{meta}</span>}
|
|
101
|
+
{selected && <CheckIcon className="h-3.5 w-3.5 shrink-0 text-primary" aria-hidden />}
|
|
102
|
+
</Root>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<Root
|
|
108
|
+
{...common}
|
|
109
|
+
className={cn(
|
|
110
|
+
// Square-ISH, via a min-height rather than `aspect-square`: a strict
|
|
111
|
+
// square clipped "Node.js 20 (TypeScript)" the moment it wrapped onto a
|
|
112
|
+
// second line. Grid rows stretch their cells to the tallest anyway, so
|
|
113
|
+
// the row stays even without forcing the ratio.
|
|
114
|
+
"group relative flex min-h-[5.5rem] min-w-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-lg border px-2 py-2.5 text-center transition-colors",
|
|
115
|
+
state,
|
|
116
|
+
className
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
{selected && <CheckIcon className="absolute top-1.5 right-1.5 h-3.5 w-3.5 text-primary" aria-hidden />}
|
|
120
|
+
|
|
121
|
+
<span className={cn(markClass, "h-8 w-8")}>{mark}</span>
|
|
122
|
+
|
|
123
|
+
{/* Two lines then ellipsis: "Node.js 20 (TypeScript)" needs both, and a
|
|
124
|
+
single truncated line would cut every entry of a same runtime to the
|
|
125
|
+
identical, useless "Node.js 20 (Type…". */}
|
|
126
|
+
<span className="line-clamp-2 w-full text-[11px] leading-tight font-medium break-words">{label}</span>
|
|
127
|
+
|
|
128
|
+
{meta && (
|
|
129
|
+
<span className="max-w-full truncate font-mono text-[9px] leading-none text-muted-foreground/70">{meta}</span>
|
|
130
|
+
)}
|
|
131
|
+
</Root>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { DownloadIcon, StarIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { CatalogCard, CatalogMark, type CatalogTone } from "./catalog-card"
|
|
8
|
+
import { PublisherBadge } from "./publisher-badge"
|
|
9
|
+
import { extensionId, formatCount, parseExtensionId } from "./registry"
|
|
10
|
+
import { resolveStackMark } from "./stack-mark"
|
|
11
|
+
import type { VscodeExtensionEntry } from "./types"
|
|
12
|
+
|
|
13
|
+
export interface ExtensionCardProps {
|
|
14
|
+
extension: VscodeExtensionEntry
|
|
15
|
+
selected?: boolean
|
|
16
|
+
onSelect?: () => void
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
compact?: boolean
|
|
19
|
+
/** An id typed by hand: no icon, no publisher, no counts to show. */
|
|
20
|
+
custom?: boolean
|
|
21
|
+
action?: ReactNode
|
|
22
|
+
tone?: CatalogTone
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A VS Code extension, as a card: its real Open VSX icon, its display name, the
|
|
28
|
+
* publisher (with the marketplace's verified check), version, install count and
|
|
29
|
+
* rating.
|
|
30
|
+
*
|
|
31
|
+
* Open VSX already returns every one of those on `/api/-/search` — the picker
|
|
32
|
+
* used to fetch them and throw them away, rendering `displayName` + `id`. Wiring
|
|
33
|
+
* them in is what turns a list of identifiers into a catalogue you can choose
|
|
34
|
+
* from.
|
|
35
|
+
*/
|
|
36
|
+
export function ExtensionCard({
|
|
37
|
+
extension,
|
|
38
|
+
selected = false,
|
|
39
|
+
onSelect,
|
|
40
|
+
disabled = false,
|
|
41
|
+
compact = false,
|
|
42
|
+
custom = false,
|
|
43
|
+
action,
|
|
44
|
+
tone = "flame",
|
|
45
|
+
className,
|
|
46
|
+
}: ExtensionCardProps) {
|
|
47
|
+
const id = extensionId(extension)
|
|
48
|
+
// A project only ever stores the id, so a card drawn from the database has to
|
|
49
|
+
// split it back apart to have anything to say at all.
|
|
50
|
+
const fromId = parseExtensionId(id)
|
|
51
|
+
const namespace = extension.namespace ?? fromId.namespace
|
|
52
|
+
const name = extension.name ?? fromId.name
|
|
53
|
+
const title = extension.displayName || name || id || "Extension"
|
|
54
|
+
const iconUrl = custom ? undefined : (extension.iconUrl ?? extension.files?.icon ?? undefined)
|
|
55
|
+
// Only ever a *fallback* mark: an extension's own icon is always better than
|
|
56
|
+
// a guess made from its id, so this is what fills the tile when Open VSX has
|
|
57
|
+
// no icon (or the card is drawn from a stored id alone).
|
|
58
|
+
const mark = custom ? undefined : resolveStackMark(name, extension.displayName)
|
|
59
|
+
const publisher =
|
|
60
|
+
extension.publisher ??
|
|
61
|
+
(namespace ? { name: namespace, login: namespace, verified: extension.verified } : undefined)
|
|
62
|
+
|
|
63
|
+
const downloads = formatCount(extension.downloadCount)
|
|
64
|
+
const rating = extension.averageRating != null ? Math.round(extension.averageRating * 10) / 10 : undefined
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<CatalogCard
|
|
68
|
+
className={className}
|
|
69
|
+
compact={compact}
|
|
70
|
+
custom={custom}
|
|
71
|
+
selected={selected}
|
|
72
|
+
onSelect={onSelect}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
action={action}
|
|
75
|
+
tone={tone}
|
|
76
|
+
selectLabel={typeof title === "string" ? title : undefined}
|
|
77
|
+
mark={
|
|
78
|
+
<CatalogMark iconUrl={iconUrl} mark={mark} publisher={publisher} label={title} tone={tone} compact={compact} />
|
|
79
|
+
}
|
|
80
|
+
title={title}
|
|
81
|
+
titleMeta={
|
|
82
|
+
extension.version && !compact ? (
|
|
83
|
+
<span className="shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
|
|
84
|
+
v{extension.version}
|
|
85
|
+
</span>
|
|
86
|
+
) : undefined
|
|
87
|
+
}
|
|
88
|
+
meta={
|
|
89
|
+
!compact ? (
|
|
90
|
+
<>
|
|
91
|
+
{publisher && <PublisherBadge publisher={publisher} showAvatar={false} />}
|
|
92
|
+
{downloads && (
|
|
93
|
+
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70" title="Installs">
|
|
94
|
+
<DownloadIcon className="h-3 w-3 shrink-0" />
|
|
95
|
+
{downloads}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
{rating != null && rating > 0 && (
|
|
99
|
+
<span
|
|
100
|
+
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70"
|
|
101
|
+
title={extension.reviewCount ? `${extension.reviewCount} reviews` : "Average rating"}
|
|
102
|
+
>
|
|
103
|
+
<StarIcon className="h-3 w-3 shrink-0 fill-current" />
|
|
104
|
+
{rating.toFixed(1)}
|
|
105
|
+
</span>
|
|
106
|
+
)}
|
|
107
|
+
</>
|
|
108
|
+
) : undefined
|
|
109
|
+
}
|
|
110
|
+
description={extension.description}
|
|
111
|
+
>
|
|
112
|
+
{id && (
|
|
113
|
+
<span
|
|
114
|
+
title={id}
|
|
115
|
+
className={cn(
|
|
116
|
+
"block max-w-full truncate font-mono text-[10px] leading-none text-muted-foreground/60",
|
|
117
|
+
compact ? "mt-0.5" : "mt-1.5"
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{id}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
</CatalogCard>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Placeholder while the Open VSX search is in flight. The search used to show a
|
|
129
|
+
* spinner in the input and nothing else, so the dropdown appeared to pop out of
|
|
130
|
+
* nowhere; three of these keep the space and let you see it coming.
|
|
131
|
+
*/
|
|
132
|
+
export function ExtensionCardSkeleton({ className }: { className?: string }) {
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
aria-hidden
|
|
136
|
+
className={cn("flex items-start gap-3 rounded-lg border border-border bg-card px-3 py-2.5", className)}
|
|
137
|
+
>
|
|
138
|
+
<span className="h-9 w-9 shrink-0 animate-pulse rounded-md bg-muted" />
|
|
139
|
+
<div className="min-w-0 flex-1 space-y-1.5 py-0.5">
|
|
140
|
+
<span className="block h-3 w-1/3 animate-pulse rounded bg-muted" />
|
|
141
|
+
<span className="block h-2.5 w-1/4 animate-pulse rounded bg-muted/70" />
|
|
142
|
+
<span className="block h-2.5 w-3/4 animate-pulse rounded bg-muted/70" />
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|