@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.
Files changed (31) hide show
  1. package/README.md +102 -4
  2. package/package.json +9 -1
  3. package/src/components/command-palette.tsx +702 -0
  4. package/src/components/devcontainer/catalog-card.tsx +269 -0
  5. package/src/components/devcontainer/catalog-tile.tsx +133 -0
  6. package/src/components/devcontainer/extension-card.tsx +146 -0
  7. package/src/components/devcontainer/feature-card.tsx +157 -0
  8. package/src/components/devcontainer/feature-tile.tsx +63 -0
  9. package/src/components/devcontainer/image-card.tsx +107 -0
  10. package/src/components/devcontainer/image-tile.tsx +56 -0
  11. package/src/components/devcontainer/index.ts +68 -0
  12. package/src/components/devcontainer/publisher-badge.tsx +91 -0
  13. package/src/components/devcontainer/registry-ref.tsx +89 -0
  14. package/src/components/devcontainer/registry.ts +154 -0
  15. package/src/components/devcontainer/stack-mark-paths.ts +60 -0
  16. package/src/components/devcontainer/stack-mark.tsx +92 -0
  17. package/src/components/devcontainer/types.ts +84 -0
  18. package/src/components/kbd.tsx +117 -0
  19. package/src/components/projects/advanced-sections.tsx +136 -0
  20. package/src/components/projects/build-manifest.tsx +116 -0
  21. package/src/components/projects/extension-picker.tsx +169 -0
  22. package/src/components/projects/feature-picker.tsx +193 -0
  23. package/src/components/projects/form-section.tsx +97 -0
  24. package/src/components/projects/image-picker.tsx +206 -0
  25. package/src/components/projects/index.ts +58 -0
  26. package/src/components/projects/project-form.tsx +488 -0
  27. package/src/components/projects/repo-list.tsx +302 -0
  28. package/src/components/projects/secret-list.tsx +159 -0
  29. package/src/components/projects/types.ts +136 -0
  30. package/src/index.ts +35 -0
  31. 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
+ }