@spunto/design-system 0.10.0 → 0.11.1
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 +85 -3
- package/package.json +9 -1
- 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/projects/advanced-sections.tsx +148 -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 +495 -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/styles.css +13 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { FileCode2Icon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { CatalogCard, CatalogChip, CatalogMark, type CatalogTone } from "./catalog-card"
|
|
8
|
+
import { PublisherBadge } from "./publisher-badge"
|
|
9
|
+
import { derivePublisher, parseRegistryRef } from "./registry"
|
|
10
|
+
import { RegistryRef } from "./registry-ref"
|
|
11
|
+
import { resolveStackMark } from "./stack-mark"
|
|
12
|
+
import type { DevcontainerFeatureEntry } from "./types"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The options the feature will actually be installed with: the catalog's
|
|
16
|
+
* declared defaults, overridden by the platform's forced ones, overridden by
|
|
17
|
+
* whatever the user picked. Exported because a picker needs the same merge to
|
|
18
|
+
* decide what to send.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveFeatureOptions(
|
|
21
|
+
feature: DevcontainerFeatureEntry,
|
|
22
|
+
selected?: Record<string, string> | null
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
const merged: Record<string, string> = {}
|
|
25
|
+
for (const option of feature.options ?? []) {
|
|
26
|
+
if (option?.name && option.default != null) merged[option.name] = option.default
|
|
27
|
+
}
|
|
28
|
+
Object.assign(merged, feature.defaultOptions ?? {}, selected ?? {})
|
|
29
|
+
return merged
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FeatureCardProps {
|
|
33
|
+
feature: DevcontainerFeatureEntry
|
|
34
|
+
selected?: boolean
|
|
35
|
+
onSelect?: () => void
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
compact?: boolean
|
|
38
|
+
/** An OCI ref the user pasted: dashed border, no invented author. */
|
|
39
|
+
custom?: boolean
|
|
40
|
+
/** Options actually in effect — merged over the catalog defaults. */
|
|
41
|
+
options?: Record<string, string> | null
|
|
42
|
+
/**
|
|
43
|
+
* Replaces the derived option chips entirely. That's the seam for an editable
|
|
44
|
+
* version=lts / moby=false row: the chips are drawn here, the state and the
|
|
45
|
+
* persistence stay in the app.
|
|
46
|
+
*/
|
|
47
|
+
optionsSlot?: ReactNode
|
|
48
|
+
action?: ReactNode
|
|
49
|
+
copyable?: boolean
|
|
50
|
+
onCopyRef?: (value: string) => void
|
|
51
|
+
tone?: CatalogTone
|
|
52
|
+
className?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A devcontainer feature, as a card: the author behind its `ghcr.io/<owner>/…`
|
|
57
|
+
* ref (avatar included), the label, what it installs, the options it will be
|
|
58
|
+
* installed with, and the OCI reference itself.
|
|
59
|
+
*
|
|
60
|
+
* The author isn't stored anywhere — it *is* the ref. `ghcr.io/coderhammer/…`
|
|
61
|
+
* is `coderhammer` on GitHub and `github.com/coderhammer.png` is their avatar,
|
|
62
|
+
* which means the whole catalog gets faces without a single API change. A
|
|
63
|
+
* feature installed from a local script has no registry and says so.
|
|
64
|
+
*/
|
|
65
|
+
export function FeatureCard({
|
|
66
|
+
feature,
|
|
67
|
+
selected = false,
|
|
68
|
+
onSelect,
|
|
69
|
+
disabled = false,
|
|
70
|
+
compact = false,
|
|
71
|
+
custom = false,
|
|
72
|
+
options,
|
|
73
|
+
optionsSlot,
|
|
74
|
+
action,
|
|
75
|
+
copyable = false,
|
|
76
|
+
onCopyRef,
|
|
77
|
+
tone = "build",
|
|
78
|
+
className,
|
|
79
|
+
}: FeatureCardProps) {
|
|
80
|
+
const ref = feature.ociRef
|
|
81
|
+
const parsed = parseRegistryRef(ref)
|
|
82
|
+
const title = feature.label || parsed.name || feature.id || ref || "Feature"
|
|
83
|
+
const mark = custom ? undefined : resolveStackMark(feature.mark, feature.id, parsed.name)
|
|
84
|
+
const publisher = custom ? feature.publisher : (feature.publisher ?? derivePublisher(ref))
|
|
85
|
+
|
|
86
|
+
const effective = optionsSlot ? {} : resolveFeatureOptions(feature, options)
|
|
87
|
+
const chips = Object.entries(effective)
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<CatalogCard
|
|
91
|
+
className={className}
|
|
92
|
+
compact={compact}
|
|
93
|
+
custom={custom}
|
|
94
|
+
selected={selected}
|
|
95
|
+
onSelect={onSelect}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
action={action}
|
|
98
|
+
tone={tone}
|
|
99
|
+
watermark={mark}
|
|
100
|
+
selectLabel={typeof title === "string" ? title : undefined}
|
|
101
|
+
mark={
|
|
102
|
+
<CatalogMark
|
|
103
|
+
iconUrl={feature.iconUrl}
|
|
104
|
+
mark={mark}
|
|
105
|
+
publisher={publisher}
|
|
106
|
+
label={title}
|
|
107
|
+
tone={tone}
|
|
108
|
+
compact={compact}
|
|
109
|
+
/>
|
|
110
|
+
}
|
|
111
|
+
title={title}
|
|
112
|
+
meta={
|
|
113
|
+
!compact ? (
|
|
114
|
+
publisher ? (
|
|
115
|
+
<PublisherBadge publisher={publisher} />
|
|
116
|
+
) : feature.localScript ? (
|
|
117
|
+
// No registry at all: worth saying, since "no author" here means
|
|
118
|
+
// "shipped by Spunto", not "unknown".
|
|
119
|
+
<span className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
120
|
+
<FileCode2Icon className="h-3 w-3 shrink-0" />
|
|
121
|
+
Local install script
|
|
122
|
+
</span>
|
|
123
|
+
) : undefined
|
|
124
|
+
) : undefined
|
|
125
|
+
}
|
|
126
|
+
description={feature.description}
|
|
127
|
+
>
|
|
128
|
+
{!compact && (optionsSlot ?? (chips.length > 0 && (
|
|
129
|
+
<div className="flex flex-wrap items-center gap-1 pt-0.5">
|
|
130
|
+
{chips.map(([name, value]) => (
|
|
131
|
+
<CatalogChip key={name} title={optionDescription(feature, name)}>
|
|
132
|
+
<span className="text-muted-foreground/60">{name}=</span>
|
|
133
|
+
<span className="text-foreground/80">{value}</span>
|
|
134
|
+
</CatalogChip>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
)))}
|
|
138
|
+
|
|
139
|
+
{ref ? (
|
|
140
|
+
<RegistryRef
|
|
141
|
+
value={ref}
|
|
142
|
+
copyable={copyable && !compact}
|
|
143
|
+
onCopy={onCopyRef}
|
|
144
|
+
className={cn(compact ? "mt-0.5" : "mt-1.5")}
|
|
145
|
+
/>
|
|
146
|
+
) : feature.localScript ? (
|
|
147
|
+
<span className={cn("block truncate font-mono text-[10px] text-muted-foreground/50", compact ? "mt-0.5" : "mt-1.5")}>
|
|
148
|
+
{feature.localScript}
|
|
149
|
+
</span>
|
|
150
|
+
) : null}
|
|
151
|
+
</CatalogCard>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function optionDescription(feature: DevcontainerFeatureEntry, name: string): string | undefined {
|
|
156
|
+
return feature.options?.find((option) => option?.name === name)?.description
|
|
157
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { PlusIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { CatalogTile, type CatalogTileProps } from "./catalog-tile"
|
|
7
|
+
import { PublisherAvatar } from "./publisher-badge"
|
|
8
|
+
import { derivePublisher, parseRegistryRef } from "./registry"
|
|
9
|
+
import { StackMark, resolveStackMark } from "./stack-mark"
|
|
10
|
+
import type { DevcontainerFeatureEntry } from "./types"
|
|
11
|
+
|
|
12
|
+
export interface FeatureTileProps extends Omit<CatalogTileProps, "mark" | "label" | "meta" | "title"> {
|
|
13
|
+
feature: DevcontainerFeatureEntry
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A devcontainer feature, small: a mark, its name. Same object as `ImageTile`,
|
|
19
|
+
* same shell — the catalogs are picked the same way, so they look the same.
|
|
20
|
+
*
|
|
21
|
+
* The one difference is the fallback. An image without a recognised runtime is
|
|
22
|
+
* just a box; a feature without one still has an **author** — the GitHub org
|
|
23
|
+
* behind its `ghcr.io` ref — and that avatar says more than a generic glyph
|
|
24
|
+
* would. So the cascade is brand mark → author avatar, mirroring `CatalogMark`
|
|
25
|
+
* on the full cards.
|
|
26
|
+
*/
|
|
27
|
+
export function FeatureTile({
|
|
28
|
+
feature,
|
|
29
|
+
icon,
|
|
30
|
+
custom = false,
|
|
31
|
+
variant = "chip",
|
|
32
|
+
tone = "build",
|
|
33
|
+
...rest
|
|
34
|
+
}: FeatureTileProps) {
|
|
35
|
+
const parsed = parseRegistryRef(feature.ociRef)
|
|
36
|
+
const mark = custom ? undefined : resolveStackMark(feature.mark, feature.id, parsed.name)
|
|
37
|
+
const publisher = custom ? feature.publisher : (feature.publisher ?? derivePublisher(feature.ociRef))
|
|
38
|
+
const label = feature.label || parsed.name || feature.id || "Feature"
|
|
39
|
+
const glyph = variant === "chip" ? "h-4 w-4" : "h-7 w-7"
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<CatalogTile
|
|
43
|
+
{...rest}
|
|
44
|
+
custom={custom}
|
|
45
|
+
variant={variant}
|
|
46
|
+
tone={tone}
|
|
47
|
+
label={label}
|
|
48
|
+
title={feature.ociRef ?? feature.localScript ?? label}
|
|
49
|
+
mark={
|
|
50
|
+
icon ??
|
|
51
|
+
(custom ? (
|
|
52
|
+
<PlusIcon className={variant === "chip" ? "h-4 w-4" : "h-6 w-6"} />
|
|
53
|
+
) : mark ? (
|
|
54
|
+
<StackMark name={mark} className={glyph} />
|
|
55
|
+
) : publisher?.avatarUrl ? (
|
|
56
|
+
<PublisherAvatar publisher={publisher} fallbackLabel={label} className={glyph} />
|
|
57
|
+
) : (
|
|
58
|
+
<StackMark className={glyph} />
|
|
59
|
+
))
|
|
60
|
+
}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { CatalogCard, CatalogMark, type CatalogTone } from "./catalog-card"
|
|
7
|
+
import { PublisherBadge } from "./publisher-badge"
|
|
8
|
+
import { derivePublisher, parseRegistryRef } from "./registry"
|
|
9
|
+
import { RegistryRef } from "./registry-ref"
|
|
10
|
+
import { resolveStackMark } from "./stack-mark"
|
|
11
|
+
import type { DevcontainerImageEntry } from "./types"
|
|
12
|
+
|
|
13
|
+
export interface ImageCardProps {
|
|
14
|
+
image: DevcontainerImageEntry
|
|
15
|
+
selected?: boolean
|
|
16
|
+
/** Absent → read-only card (no overlay, no check). */
|
|
17
|
+
onSelect?: () => void
|
|
18
|
+
disabled?: boolean
|
|
19
|
+
/** One-line rendering for a project sheet or a summary. */
|
|
20
|
+
compact?: boolean
|
|
21
|
+
/**
|
|
22
|
+
* A ref the user typed rather than a catalog entry. Dashed border, generic
|
|
23
|
+
* tile, and no invented publisher — an unknown registry stays unknown.
|
|
24
|
+
*/
|
|
25
|
+
custom?: boolean
|
|
26
|
+
/** Trailing control (a remove ✕). */
|
|
27
|
+
action?: ReactNode
|
|
28
|
+
/** Copy button next to the reference. */
|
|
29
|
+
copyable?: boolean
|
|
30
|
+
onCopyRef?: (value: string) => void
|
|
31
|
+
tone?: CatalogTone
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A devcontainer base image, as a card: the runtime's mark, its label, who
|
|
37
|
+
* publishes it, the image reference, and what it ships.
|
|
38
|
+
*
|
|
39
|
+
* **Everything but the label is derived from the reference** —
|
|
40
|
+
* `mcr.microsoft.com/devcontainers/python:3.12` is the Dev Containers team's
|
|
41
|
+
* Python image — because the catalog carries no author today. `image.publisher`
|
|
42
|
+
* and `image.mark` are the overrides for the day the API knows better; nothing
|
|
43
|
+
* else has to change here when it does.
|
|
44
|
+
*/
|
|
45
|
+
export function ImageCard({
|
|
46
|
+
image,
|
|
47
|
+
selected = false,
|
|
48
|
+
onSelect,
|
|
49
|
+
disabled = false,
|
|
50
|
+
compact = false,
|
|
51
|
+
custom = false,
|
|
52
|
+
action,
|
|
53
|
+
copyable = false,
|
|
54
|
+
onCopyRef,
|
|
55
|
+
tone = "ship",
|
|
56
|
+
className,
|
|
57
|
+
}: ImageCardProps) {
|
|
58
|
+
const ref = image.image
|
|
59
|
+
const parsed = parseRegistryRef(ref)
|
|
60
|
+
const title = image.label || parsed.name || ref || "Image"
|
|
61
|
+
const mark = custom ? undefined : resolveStackMark(image.mark, image.id, parsed.name)
|
|
62
|
+
const publisher = custom ? image.publisher : (image.publisher ?? derivePublisher(ref))
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<CatalogCard
|
|
66
|
+
className={className}
|
|
67
|
+
compact={compact}
|
|
68
|
+
custom={custom}
|
|
69
|
+
selected={selected}
|
|
70
|
+
onSelect={onSelect}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
action={action}
|
|
73
|
+
tone={tone}
|
|
74
|
+
watermark={mark}
|
|
75
|
+
selectLabel={typeof title === "string" ? title : undefined}
|
|
76
|
+
mark={
|
|
77
|
+
<CatalogMark
|
|
78
|
+
iconUrl={image.iconUrl}
|
|
79
|
+
mark={mark}
|
|
80
|
+
publisher={publisher}
|
|
81
|
+
label={title}
|
|
82
|
+
tone={tone}
|
|
83
|
+
compact={compact}
|
|
84
|
+
/>
|
|
85
|
+
}
|
|
86
|
+
title={title}
|
|
87
|
+
titleMeta={
|
|
88
|
+
parsed.tag && !compact ? (
|
|
89
|
+
<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">
|
|
90
|
+
{parsed.tag}
|
|
91
|
+
</span>
|
|
92
|
+
) : undefined
|
|
93
|
+
}
|
|
94
|
+
meta={!compact && publisher ? <PublisherBadge publisher={publisher} /> : undefined}
|
|
95
|
+
description={image.description}
|
|
96
|
+
>
|
|
97
|
+
{ref && (
|
|
98
|
+
<RegistryRef
|
|
99
|
+
value={ref}
|
|
100
|
+
copyable={copyable && !compact}
|
|
101
|
+
onCopy={onCopyRef}
|
|
102
|
+
className={cn(compact ? "mt-0.5" : "mt-1.5")}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</CatalogCard>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { PlusIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { CatalogTile, type CatalogTileProps } from "./catalog-tile"
|
|
7
|
+
import { parseRegistryRef } from "./registry"
|
|
8
|
+
import { StackMark, resolveStackMark } from "./stack-mark"
|
|
9
|
+
import type { DevcontainerImageEntry } from "./types"
|
|
10
|
+
|
|
11
|
+
export interface ImageTileProps extends Omit<CatalogTileProps, "mark" | "label" | "meta" | "title"> {
|
|
12
|
+
image: DevcontainerImageEntry
|
|
13
|
+
/** Replaces the derived mark — for the "custom image" entry, mostly. */
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A base image, small: the runtime's mark, its name, its tag. That's it.
|
|
19
|
+
*
|
|
20
|
+
* The full `ImageCard` is the right object when the catalog is six entries and
|
|
21
|
+
* the publisher, the reference and the description each deserve a line. Past a
|
|
22
|
+
* dozen it's a wall of near-identical blocks all repeating the same
|
|
23
|
+
* `mcr.microsoft.com/devcontainers/` prefix — and picking a runtime is a *logo*
|
|
24
|
+
* task, not a reading one.
|
|
25
|
+
*
|
|
26
|
+
* So the default is `chip`: tag-sized, flowing and wrapping like tags rather
|
|
27
|
+
* than sitting in a rigid grid, which lets thirty images fit in the space six
|
|
28
|
+
* cards used to. What the chip drops comes back in the detail card
|
|
29
|
+
* `ImagePicker` shows for whichever one is selected.
|
|
30
|
+
*/
|
|
31
|
+
export function ImageTile({ image, icon, custom = false, variant = "chip", tone = "ship", ...rest }: ImageTileProps) {
|
|
32
|
+
const parsed = parseRegistryRef(image.image)
|
|
33
|
+
const mark = custom ? undefined : resolveStackMark(image.mark, image.id, parsed.name)
|
|
34
|
+
const label = image.label || parsed.name || image.image || "Image"
|
|
35
|
+
const glyph = variant === "chip" ? "h-4 w-4" : "h-7 w-7"
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<CatalogTile
|
|
39
|
+
{...rest}
|
|
40
|
+
custom={custom}
|
|
41
|
+
variant={variant}
|
|
42
|
+
tone={tone}
|
|
43
|
+
label={label}
|
|
44
|
+
title={image.image ?? label}
|
|
45
|
+
meta={custom ? undefined : parsed.tag}
|
|
46
|
+
mark={
|
|
47
|
+
icon ??
|
|
48
|
+
(custom ? (
|
|
49
|
+
<PlusIcon className={variant === "chip" ? "h-4 w-4" : "h-6 w-6"} />
|
|
50
|
+
) : (
|
|
51
|
+
<StackMark name={mark} className={glyph} />
|
|
52
|
+
))
|
|
53
|
+
}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @spunto/design-system/devcontainer — the three catalogs you compose an
|
|
2
|
+
// environment from: base images, devcontainer features, VS Code extensions.
|
|
3
|
+
//
|
|
4
|
+
// A second *domain* sub-export, next to `/workers`: the root entry stays
|
|
5
|
+
// "primitives with no domain" (a Button knows nothing about an OCI reference).
|
|
6
|
+
// These cards are the screens where a user builds their environment, they exist
|
|
7
|
+
// identically in the dashboard and in Spunto Lite, and they were hand-drawn as
|
|
8
|
+
// raw `<button>`s in both — which is exactly the duplication this package is for.
|
|
9
|
+
//
|
|
10
|
+
// Presentational only: no fetch, no useQuery, no next/link. What talks to an API
|
|
11
|
+
// is a callback (`onSelect`) or a slot (`action`, `optionsSlot`); what draws is
|
|
12
|
+
// in here. The author and the logo are *derived* from the registry reference by
|
|
13
|
+
// pure functions (`derivePublisher`, `resolveStackMark`) — see registry.ts.
|
|
14
|
+
|
|
15
|
+
export { ImageCard } from "./image-card"
|
|
16
|
+
export type { ImageCardProps } from "./image-card"
|
|
17
|
+
|
|
18
|
+
export { ImageTile } from "./image-tile"
|
|
19
|
+
export type { ImageTileProps } from "./image-tile"
|
|
20
|
+
|
|
21
|
+
export { FeatureTile } from "./feature-tile"
|
|
22
|
+
export type { FeatureTileProps } from "./feature-tile"
|
|
23
|
+
|
|
24
|
+
export { CatalogTile } from "./catalog-tile"
|
|
25
|
+
export type { CatalogTileProps } from "./catalog-tile"
|
|
26
|
+
|
|
27
|
+
export { FeatureCard, resolveFeatureOptions } from "./feature-card"
|
|
28
|
+
export type { FeatureCardProps } from "./feature-card"
|
|
29
|
+
|
|
30
|
+
export { ExtensionCard, ExtensionCardSkeleton } from "./extension-card"
|
|
31
|
+
export type { ExtensionCardProps } from "./extension-card"
|
|
32
|
+
|
|
33
|
+
// The bricks, on their own — a picker's custom-ref row or a summary line needs
|
|
34
|
+
// the shell without any of the three cards.
|
|
35
|
+
export { CatalogCard, CatalogChip, CatalogMark } from "./catalog-card"
|
|
36
|
+
export type { CatalogCardProps, CatalogMarkProps, CatalogTone } from "./catalog-card"
|
|
37
|
+
|
|
38
|
+
export { PublisherAvatar, PublisherBadge, monogram } from "./publisher-badge"
|
|
39
|
+
export type { PublisherAvatarProps, PublisherBadgeProps } from "./publisher-badge"
|
|
40
|
+
|
|
41
|
+
export { RegistryRef } from "./registry-ref"
|
|
42
|
+
export type { RegistryRefProps } from "./registry-ref"
|
|
43
|
+
|
|
44
|
+
export { StackMark, resolveStackMark, STACK_MARK_PATHS } from "./stack-mark"
|
|
45
|
+
export type { StackMarkName, StackMarkProps } from "./stack-mark"
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
derivePublisher,
|
|
49
|
+
extensionId,
|
|
50
|
+
formatCount,
|
|
51
|
+
githubAvatarUrl,
|
|
52
|
+
githubOwner,
|
|
53
|
+
parseExtensionId,
|
|
54
|
+
parseRegistryRef,
|
|
55
|
+
} from "./registry"
|
|
56
|
+
export type { ParsedRegistryRef } from "./registry"
|
|
57
|
+
|
|
58
|
+
export type {
|
|
59
|
+
CatalogPublisher,
|
|
60
|
+
DevcontainerFeatureEntry,
|
|
61
|
+
DevcontainerFeatureOption,
|
|
62
|
+
DevcontainerImageEntry,
|
|
63
|
+
VscodeExtensionEntry,
|
|
64
|
+
} from "./types"
|
|
65
|
+
|
|
66
|
+
// Re-exported here too, so a consumer of this surface doesn't have to reach for
|
|
67
|
+
// the root entry just to merge a class name.
|
|
68
|
+
export { cn } from "../../utils"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
import { BadgeCheckIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import type { CatalogPublisher } from "./types"
|
|
8
|
+
|
|
9
|
+
/** Initials for a monogram — "Dev Containers" → "DC", "dbaeumer" → "DB". */
|
|
10
|
+
export function monogram(label?: string | null): string {
|
|
11
|
+
const value = (label ?? "").trim()
|
|
12
|
+
if (!value) return "?"
|
|
13
|
+
const words = value.split(/[\s._/-]+/).filter(Boolean)
|
|
14
|
+
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase()
|
|
15
|
+
return value.slice(0, 2).toUpperCase()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PublisherAvatarProps {
|
|
19
|
+
publisher?: CatalogPublisher
|
|
20
|
+
/** Label the monogram falls back to when the publisher has no name. */
|
|
21
|
+
fallbackLabel?: string
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The publisher's avatar — a plain `<img>`, deliberately: the package is
|
|
27
|
+
* consumed by two Next apps *and* a Vite showcase, so it can't reach for
|
|
28
|
+
* `next/image`. A dead URL (GitHub org renamed, extension icon 404) falls back
|
|
29
|
+
* to the monogram instead of leaving a broken-image glyph in the grid.
|
|
30
|
+
*/
|
|
31
|
+
export function PublisherAvatar({ publisher, fallbackLabel, className }: PublisherAvatarProps) {
|
|
32
|
+
const [broken, setBroken] = useState(false)
|
|
33
|
+
const label = publisher?.name || publisher?.login || fallbackLabel || ""
|
|
34
|
+
const url = publisher?.avatarUrl
|
|
35
|
+
|
|
36
|
+
if (url && !broken) {
|
|
37
|
+
return (
|
|
38
|
+
<img
|
|
39
|
+
src={url}
|
|
40
|
+
alt=""
|
|
41
|
+
loading="lazy"
|
|
42
|
+
onError={() => setBroken(true)}
|
|
43
|
+
className={cn("h-4 w-4 shrink-0 rounded-full object-cover ring-1 ring-border", className)}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<span
|
|
50
|
+
aria-hidden
|
|
51
|
+
className={cn(
|
|
52
|
+
"flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-semibold text-muted-foreground ring-1 ring-border",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
{monogram(label)}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PublisherBadgeProps {
|
|
62
|
+
publisher?: CatalogPublisher
|
|
63
|
+
/** Drawn when the publisher itself has no name (a hand-typed ref). */
|
|
64
|
+
fallbackLabel?: string
|
|
65
|
+
/** `false` → the name alone, for rows already carrying a logo of their own. */
|
|
66
|
+
showAvatar?: boolean
|
|
67
|
+
className?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Who published this — avatar, name, and a flame check when the registry vouches
|
|
72
|
+
* for the namespace. Nothing derivable → renders nothing at all rather than an
|
|
73
|
+
* "Unknown publisher" line that says less than the empty space it takes.
|
|
74
|
+
*/
|
|
75
|
+
export function PublisherBadge({ publisher, fallbackLabel, showAvatar = true, className }: PublisherBadgeProps) {
|
|
76
|
+
const name = publisher?.name || publisher?.login || fallbackLabel
|
|
77
|
+
if (!name) return null
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<span className={cn("inline-flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground", className)}>
|
|
81
|
+
{showAvatar && <PublisherAvatar publisher={publisher} fallbackLabel={fallbackLabel} />}
|
|
82
|
+
<span className="truncate">{name}</span>
|
|
83
|
+
{publisher?.verified && (
|
|
84
|
+
<BadgeCheckIcon
|
|
85
|
+
className="h-3 w-3 shrink-0 text-primary"
|
|
86
|
+
aria-label="Verified publisher"
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
</span>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react"
|
|
4
|
+
import { CheckIcon, CopyIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../utils"
|
|
7
|
+
import { parseRegistryRef } from "./registry"
|
|
8
|
+
|
|
9
|
+
export interface RegistryRefProps {
|
|
10
|
+
/** The full reference — `mcr.microsoft.com/devcontainers/python:3.12`. */
|
|
11
|
+
value?: string | null
|
|
12
|
+
/**
|
|
13
|
+
* Adds a copy button. The ref is the one thing on these cards you actually
|
|
14
|
+
* want in your clipboard (to paste into a devcontainer.json), and it was
|
|
15
|
+
* already printed in mono — this just makes it useful.
|
|
16
|
+
*/
|
|
17
|
+
copyable?: boolean
|
|
18
|
+
/** Called instead of `navigator.clipboard` when the app wants to own the copy. */
|
|
19
|
+
onCopy?: (value: string) => void
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A registry reference, mono, with its host dimmed so the eye lands on the
|
|
25
|
+
* name+tag — the part that differs between two entries of the same catalog.
|
|
26
|
+
* Always carries the full ref as a `title`, because it truncates.
|
|
27
|
+
*/
|
|
28
|
+
export function RegistryRef({ value, copyable = false, onCopy, className }: RegistryRefProps) {
|
|
29
|
+
const [copied, setCopied] = useState(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!copied) return
|
|
33
|
+
const id = setTimeout(() => setCopied(false), 1400)
|
|
34
|
+
return () => clearTimeout(id)
|
|
35
|
+
}, [copied])
|
|
36
|
+
|
|
37
|
+
const ref = value?.trim()
|
|
38
|
+
if (!ref) return null
|
|
39
|
+
|
|
40
|
+
const parsed = parseRegistryRef(ref)
|
|
41
|
+
// Prefix = everything up to the name; it's the boilerplate part
|
|
42
|
+
// ("ghcr.io/devcontainers/features/") that's identical across a whole catalog.
|
|
43
|
+
const prefix = [parsed.registry, parsed.owner, ...parsed.path].filter(Boolean).join("/")
|
|
44
|
+
const tail = [parsed.name, parsed.tag && `:${parsed.tag}`].filter(Boolean).join("")
|
|
45
|
+
|
|
46
|
+
function copy(event: React.MouseEvent) {
|
|
47
|
+
event.stopPropagation()
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
if (onCopy) {
|
|
50
|
+
onCopy(ref!)
|
|
51
|
+
} else {
|
|
52
|
+
void navigator?.clipboard?.writeText(ref!).catch(() => {})
|
|
53
|
+
}
|
|
54
|
+
setCopied(true)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
// `relative z-10 w-fit`: on a selectable card the selection overlay covers
|
|
59
|
+
// everything, so the ref has to be raised above it — otherwise neither its
|
|
60
|
+
// `title` tooltip nor the copy button below would ever be reachable.
|
|
61
|
+
<span
|
|
62
|
+
title={ref}
|
|
63
|
+
className={cn(
|
|
64
|
+
"relative z-10 inline-flex w-fit min-w-0 max-w-full items-center gap-1 font-mono text-[10px] leading-none",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{/* The prefix truncates, the name+tag never does. Truncating the whole ref
|
|
69
|
+
as one string eats the only part that differs between two entries of
|
|
70
|
+
the same catalog ("mcr.microsoft.com/devcontai…" says nothing), and in
|
|
71
|
+
this grid the prefix is identical on every card anyway. */}
|
|
72
|
+
{prefix && <span className="min-w-0 truncate text-muted-foreground/50">{prefix}/</span>}
|
|
73
|
+
<span className="shrink-0 text-muted-foreground">{tail || ref}</span>
|
|
74
|
+
{copyable && (
|
|
75
|
+
// `relative z-10`: the card's selection hit-area is an overlay button
|
|
76
|
+
// stretched across the whole card, so anything clickable inside has to
|
|
77
|
+
// sit above it — otherwise copying would select the card instead.
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={copy}
|
|
81
|
+
aria-label={copied ? "Copied" : `Copy ${ref}`}
|
|
82
|
+
className="relative z-10 shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground/40 transition-colors hover:bg-accent hover:text-foreground"
|
|
83
|
+
>
|
|
84
|
+
{copied ? <CheckIcon className="h-3 w-3 text-primary" /> : <CopyIcon className="h-3 w-3" />}
|
|
85
|
+
</button>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
)
|
|
89
|
+
}
|