@spunto/design-system 0.11.0 → 0.12.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 CHANGED
@@ -101,9 +101,12 @@ concept goes behind a domain sub-export, never in the root entry.**
101
101
  import { Button, Card } from "@spunto/design-system" // primitives
102
102
  import { WorkerCard } from "@spunto/design-system/workers" // domain
103
103
  import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
104
- import { ProjectForm } from "@spunto/design-system/projects" // domain
104
+ import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
105
105
  ```
106
106
 
107
+ One entry **per domain**, not per component: a project's form and a project's
108
+ panel are the same concept seen twice, so they share `/projects`.
109
+
107
110
  ### `@spunto/design-system/workers`
108
111
 
109
112
  - **`WorkerCard`** — a Spunto worker as a card: state, author, node, setup
@@ -201,6 +204,47 @@ hand-typed reference that degrades cleanly.
201
204
  whether the manifest sits beside it and whether the catalogs are one column or
202
205
  two.
203
206
 
207
+ And the read-only twin of that form:
208
+
209
+ - **`ProjectPanel`** — the left-hand panel of a project page: identity, image,
210
+ repositories, deploy key, features, extensions, build cache, ports, lifecycle
211
+ commands, secrets, integrations, version history, footer. It draws the same
212
+ configuration once it exists, plus what only a live project has. Each band
213
+ disappears when its data is absent, so a bare project collapses to a header
214
+ plus an image without a single caller-side conditional.
215
+ - **Same rule as `WorkerCard`**: the versions, secrets, builds, nodes and
216
+ integration statuses are fetched by the app and come down as props; the
217
+ pre-build and the restore are callbacks (`onPrebuild`, `onRestoreVersion`);
218
+ the links go through `editHref`/`secretsHref` + `render.link`, never
219
+ `next/link`. **The card is the component's, its placement is not** — the
220
+ `<aside className="lg:w-72 lg:sticky">` stays in the app.
221
+ - **Its bricks, exported individually** because a project's edit page (and
222
+ tomorrow a deployment card) assembles part of them without the panel:
223
+ `PanelSection`/`Eyebrow`, `RepositoryList` (+`projectRepoName`,
224
+ `projectRepoSource`), `ChipList` (+`FeatureChips`, `ExtensionChips`,
225
+ `PortChips`, `SecretChips`), `BuildCacheList`/`BuildStateLabel`/
226
+ `PrebuildButton`, `LifecycleCommands`, `IntegrationsList`, `VersionHistory`,
227
+ `DeployKeySection`, `PanelMeta`, `ProjectStatePill` (+`projectStateConfig`).
228
+ - **Two type modules, on purpose.** The form owns a *complete* value it edits
229
+ (`ProjectFormValue`, every field present); the panel reads a *partial*
230
+ snapshot it merely displays (`ProjectPanelProject`, everything optional but
231
+ `name`/`image`/`createdAt`, in `panel-types.ts`). Opposite invariants — one
232
+ shared type would have to lie for one of the two. The `LinkRender` slot, on
233
+ the other hand, *is* shared with the worker surface: re-exported, not
234
+ redefined.
235
+ - **The clipboard is UI, not network**, so `DeployKeySection`'s copy button (and
236
+ its toast) lives in the package; only the registration guidance, which differs
237
+ per app, comes down as `children`.
238
+ - **The logos are derived, not asked for.** The panel reuses `/devcontainer`'s
239
+ pure helpers on data it already has: `resolveStackMark` turns the base image,
240
+ each feature and each extension into a brand mark, `githubAvatarUrl` turns an
241
+ `owner/repo` remote into its org avatar, and `RegistryRef` prints the image
242
+ reference with the registry dimmed and `name:tag` intact (copyable). Nothing
243
+ unrecognised gets a wrong logo — it falls back to a generic glyph — and an
244
+ integration only needs an `icon` when the shipped marks don't know it.
245
+ - **Container queries** here too: the panel is 288 px in a sidebar on a 27" screen
246
+ and full width on a phone — and the desktop case is the narrow one.
247
+
204
248
  ## Toasts — `SpuntoProvider` + `toast()`
205
249
 
206
250
  `SpuntoProvider` is the design system's client umbrella provider. Mount it once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,7 +4,7 @@ import type { ReactNode } from "react"
4
4
  import { ChevronDownIcon, Settings2Icon } from "lucide-react"
5
5
 
6
6
  import { cn } from "../../utils"
7
- import type { ProjectFormSectionId, ProjectFormValue } from "./types"
7
+ import type { ProjectFormSectionId, ProjectFormValue, ProjectSecretRef } from "./types"
8
8
 
9
9
  export interface AdvancedDisclosureProps {
10
10
  open: boolean
@@ -101,7 +101,17 @@ export function AdvancedDisclosure({
101
101
  * `lifecycle`, `3 secrets`. Pure, and exported, because a caller rendering its
102
102
  * own fold needs the same sentence.
103
103
  */
104
- export function advancedSummary(value: ProjectFormValue, sections: ProjectFormSectionId[]): string[] {
104
+ export function advancedSummary(
105
+ value: ProjectFormValue,
106
+ sections: ProjectFormSectionId[],
107
+ /**
108
+ * Secrets already stored server-side. They count: a project whose only
109
+ * advanced setting is three secrets would otherwise show an empty summary and
110
+ * keep the fold shut over them — exactly the "hides configuration you didn't
111
+ * set yourself" failure this component exists to avoid.
112
+ */
113
+ savedSecrets: ProjectSecretRef[] = []
114
+ ): string[] {
105
115
  const out: string[] = []
106
116
  for (const id of sections) {
107
117
  switch (id) {
@@ -124,9 +134,11 @@ export function advancedSummary(value: ProjectFormValue, sections: ProjectFormSe
124
134
  case "docker":
125
135
  if (value.dockerInDocker) out.push("docker-in-docker")
126
136
  break
127
- case "secrets":
128
- if (value.secrets.length) out.push(`${value.secrets.length} secret${value.secrets.length > 1 ? "s" : ""}`)
137
+ case "secrets": {
138
+ const n = value.secrets.length + savedSecrets.filter((x) => !value.removedSecretIds.includes(x.id)).length
139
+ if (n) out.push(`${n} secret${n > 1 ? "s" : ""}`)
129
140
  break
141
+ }
130
142
  // Identity, image and repositories are never advanced — they're the form.
131
143
  default:
132
144
  break
@@ -0,0 +1,104 @@
1
+ // Pre-build cache: is the project's image already built, and where.
2
+ //
3
+ // The dashboard lists one row per online BYOC node and opens the build logs on
4
+ // click; Lite has a single "local · Docker" row with nothing to open. Hence a
5
+ // list of *targets* whose `onSelect` is optional — a row without one is inert
6
+ // markup, not a dead button.
7
+
8
+ import { CheckCircle2Icon, Loader2Icon, XIcon, ZapIcon } from "lucide-react"
9
+
10
+ import { cn } from "../../utils"
11
+ import type { ProjectBuildTarget } from "./panel-types"
12
+
13
+ export interface PrebuildButtonProps {
14
+ onClick?: () => void
15
+ /** A build is in flight (this one's mutation, or a poll saying so) → spinner + disabled. */
16
+ pending?: boolean
17
+ label?: string
18
+ className?: string
19
+ }
20
+
21
+ /**
22
+ * The "Pre-build" affordance of the build cache section. Lives next to the
23
+ * section's label, so it ships as its own brick and goes into
24
+ * `PanelSection`'s `action` slot.
25
+ */
26
+ export function PrebuildButton({ onClick, pending = false, label = "Pre-build", className }: PrebuildButtonProps) {
27
+ return (
28
+ <button
29
+ type="button"
30
+ onClick={onClick}
31
+ disabled={pending || !onClick}
32
+ className={cn(
33
+ "flex shrink-0 cursor-pointer items-center gap-1 text-[10px] text-primary transition-colors hover:text-primary/80 disabled:cursor-not-allowed disabled:opacity-50",
34
+ className
35
+ )}
36
+ >
37
+ {pending ? <Loader2Icon className="h-3 w-3 animate-spin" /> : <ZapIcon className="h-3 w-3" />}
38
+ {label}
39
+ </button>
40
+ )
41
+ }
42
+
43
+ export interface BuildCacheListProps {
44
+ targets: ProjectBuildTarget[]
45
+ className?: string
46
+ }
47
+
48
+ /** One row per build target: its name, and where its image build stands. */
49
+ export function BuildCacheList({ targets, className }: BuildCacheListProps) {
50
+ if (!targets.length) return null
51
+
52
+ return (
53
+ // `-mx-1` on the list (not on each row): the hover highlight then bleeds
54
+ // one notch past the text on both sides, like the original inline markup,
55
+ // without any row being wider than 100% of its own container.
56
+ <div data-slot="build-cache-list" className={cn("-mx-1 space-y-1", className)}>
57
+ {targets.map((target) => {
58
+ const clickable = !!target.onSelect
59
+ const Tag = clickable ? "button" : "div"
60
+ return (
61
+ <Tag
62
+ key={target.id}
63
+ {...(clickable ? { type: "button" as const, onClick: target.onSelect } : {})}
64
+ title={target.label}
65
+ className={cn(
66
+ "flex w-full items-center justify-between gap-2 rounded px-1 transition-colors",
67
+ clickable && "cursor-pointer hover:bg-muted/50"
68
+ )}
69
+ >
70
+ <span className="min-w-0 truncate text-[11px] text-muted-foreground">{target.label}</span>
71
+ <BuildStateLabel state={target.state} />
72
+ </Tag>
73
+ )
74
+ })}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ /**
80
+ * The right-hand state of a build row. Green = cached and usable, yellow = in
81
+ * flight, red = the build failed; anything unknown (including no build at all)
82
+ * reads "not built" rather than blowing up on a state the package predates.
83
+ */
84
+ export function BuildStateLabel({ state }: { state?: string | null }) {
85
+ if (state === "building")
86
+ return (
87
+ <span className="flex shrink-0 items-center gap-1 text-[10px] text-yellow-600 dark:text-yellow-400">
88
+ <Loader2Icon className="h-2.5 w-2.5 animate-spin" /> building
89
+ </span>
90
+ )
91
+ if (state === "ready")
92
+ return (
93
+ <span className="flex shrink-0 items-center gap-1 text-[10px] text-green-600 dark:text-green-400">
94
+ <CheckCircle2Icon className="h-2.5 w-2.5" /> ready
95
+ </span>
96
+ )
97
+ if (state === "error")
98
+ return (
99
+ <span className="flex shrink-0 items-center gap-1 text-[10px] text-destructive">
100
+ <XIcon className="h-2.5 w-2.5" /> error
101
+ </span>
102
+ )
103
+ return <span className="shrink-0 text-[10px] text-muted-foreground/40">not built</span>
104
+ }
@@ -0,0 +1,168 @@
1
+ // The panel's four chip rows — features, extensions, ports, secrets.
2
+ //
3
+ // They differ only by icon, semantic tone and how the full id is shortened, so
4
+ // they are one parameterised brick rather than four near-identical copies (both
5
+ // apps carried all four, hand-written, twice).
6
+
7
+ import type { ReactNode } from "react"
8
+ import { KeyRoundIcon, NetworkIcon, PuzzleIcon } from "lucide-react"
9
+
10
+ import { cn } from "../../utils"
11
+ // Same package, next domain over: the catalog cards already know how to turn a
12
+ // reference into a logo, and a feature chip is the same feature the catalog
13
+ // showed — deriving it twice, differently, is exactly what this package exists
14
+ // to prevent.
15
+ import { resolveStackMark, StackMark } from "../devcontainer/stack-mark"
16
+ import { parseExtensionId } from "../devcontainer/registry"
17
+ import type { ProjectSecretMeta } from "./panel-types"
18
+
19
+ /** Semantic tone of a chip row — the Build/Ship/Run scale plus the neutral one. */
20
+ export type ChipTone = "build" | "primary" | "flame" | "muted"
21
+
22
+ const CHIP_TONES: Record<ChipTone, string> = {
23
+ build: "bg-build/10 text-build",
24
+ primary: "bg-primary/10 text-primary",
25
+ flame: "bg-flame/10 text-flame",
26
+ muted: "bg-muted text-muted-foreground",
27
+ }
28
+
29
+ export interface ChipItem {
30
+ /** React key, and the `title` fallback: usually the full, unshortened id. */
31
+ id: string
32
+ /** What is actually displayed — the shortened form. Defaults to `id`. */
33
+ label?: ReactNode
34
+ /** Hover text. Defaults to `id`, so the full value is always reachable. */
35
+ title?: string
36
+ /** Overrides the row's icon for this chip — a brand mark, typically. */
37
+ icon?: ReactNode
38
+ }
39
+
40
+ export interface ChipListProps {
41
+ items: ChipItem[]
42
+ /** One icon for the whole row (every chip in a row means the same kind of thing). */
43
+ icon?: ReactNode
44
+ tone?: ChipTone
45
+ /** Rendered instead of the row when `items` is empty. Absent → nothing at all. */
46
+ empty?: ReactNode
47
+ className?: string
48
+ }
49
+
50
+ /**
51
+ * A wrapping row of monospace chips.
52
+ *
53
+ * `max-w-full` + `truncate` on the label is what keeps a chip inside a 288 px
54
+ * sidebar: a feature id or an extension name is one unbreakable token, and
55
+ * without the cap it runs under the card's rounded edge.
56
+ */
57
+ export function ChipList({ items, icon, tone = "muted", empty, className }: ChipListProps) {
58
+ if (!items.length) return empty ? <>{empty}</> : null
59
+
60
+ return (
61
+ <div data-slot="chip-list" className={cn("flex flex-wrap gap-1.5", className)}>
62
+ {items.map((item) => (
63
+ <span
64
+ key={item.id}
65
+ title={item.title ?? item.id}
66
+ className={cn(
67
+ "flex min-w-0 max-w-full items-center gap-1 rounded-md px-2 py-1 font-mono text-[11px]",
68
+ CHIP_TONES[tone]
69
+ )}
70
+ >
71
+ {item.icon ?? icon}
72
+ <span className="min-w-0 truncate">{item.label ?? item.id}</span>
73
+ </span>
74
+ ))}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ /** Fallback line shared by the empty states below. */
80
+ function Empty({ children }: { children: ReactNode }) {
81
+ return <p className="text-[11px] text-muted-foreground/60">{children}</p>
82
+ }
83
+
84
+ /**
85
+ * devcontainer features — last path segment, tone Build, and **the runtime's own
86
+ * logo** rather than the same box twelve times.
87
+ *
88
+ * The mark is derived from the id (`…/features/docker-in-docker:2` → Docker), by
89
+ * the same pure function the catalog cards use, and falls back to a generic
90
+ * glyph when nothing matches — never to a wrong logo.
91
+ */
92
+ export function FeatureChips({ features, className }: { features: { id: string }[]; className?: string }) {
93
+ return (
94
+ <ChipList
95
+ className={className}
96
+ tone="build"
97
+ items={features.map((f) => ({
98
+ id: f.id,
99
+ label: f.id.split("/").pop(),
100
+ icon: <StackMark name={resolveStackMark(f.id)} className="h-3.5 w-3.5 shrink-0" />,
101
+ }))}
102
+ />
103
+ )
104
+ }
105
+
106
+ /**
107
+ * VS Code extensions — `publisher.name` shown by its name half, with the
108
+ * publisher kept in the tooltip. Same logo derivation as the features
109
+ * (`ms-python.python` → Python); anything unrecognised keeps the puzzle piece,
110
+ * which is what a VS Code extension *is*.
111
+ */
112
+ export function ExtensionChips({ extensions, className }: { extensions: string[]; className?: string }) {
113
+ return (
114
+ <ChipList
115
+ className={className}
116
+ tone="primary"
117
+ items={extensions.map((ext) => {
118
+ const { namespace, name } = parseExtensionId(ext)
119
+ const mark = resolveStackMark(name, namespace)
120
+ return {
121
+ id: ext,
122
+ label: name ?? ext,
123
+ title: namespace ? `${name} — ${namespace}` : ext,
124
+ icon: mark ? (
125
+ <StackMark name={mark} className="h-3.5 w-3.5 shrink-0" />
126
+ ) : (
127
+ <PuzzleIcon className="h-3 w-3 shrink-0" />
128
+ ),
129
+ }
130
+ })}
131
+ />
132
+ )
133
+ }
134
+
135
+ /** Forwarded ports, `:3000`-style. */
136
+ export function PortChips({ ports, className }: { ports: (number | string)[]; className?: string }) {
137
+ return (
138
+ <ChipList
139
+ className={className}
140
+ icon={<NetworkIcon className="h-3 w-3 shrink-0" />}
141
+ items={ports.map((p) => ({ id: String(p), label: `:${p}` }))}
142
+ />
143
+ )
144
+ }
145
+
146
+ /**
147
+ * Secrets — **names only**. The panel never receives a value, so there is
148
+ * nothing here that could leak into a screenshot or a DOM dump.
149
+ */
150
+ export function SecretChips({
151
+ secrets,
152
+ emptyLabel = "No secrets configured",
153
+ className,
154
+ }: {
155
+ secrets: ProjectSecretMeta[]
156
+ emptyLabel?: ReactNode
157
+ className?: string
158
+ }) {
159
+ return (
160
+ <ChipList
161
+ className={className}
162
+ tone="flame"
163
+ icon={<KeyRoundIcon className="h-3 w-3 shrink-0" />}
164
+ items={secrets.map((s) => ({ id: s.id, label: s.name, title: s.name }))}
165
+ empty={emptyLabel ? <Empty>{emptyLabel}</Empty> : null}
166
+ />
167
+ )
168
+ }
@@ -0,0 +1,76 @@
1
+ "use client"
2
+
3
+ import { useState, type ReactNode } from "react"
4
+ import { CheckIcon, ClipboardIcon } from "lucide-react"
5
+
6
+ import { cn } from "../../utils"
7
+ import { toast } from "../toast"
8
+
9
+ export interface DeployKeySectionProps {
10
+ /** Public half of the project's SSH deploy key. The private half never leaves the API. */
11
+ publicKey: string
12
+ /**
13
+ * Registration guidance below the key. Deliberately a slot: the dashboard
14
+ * spells out GitLab/Gitea/Bitbucket paths, Lite says one sentence — the same
15
+ * component can't own both texts.
16
+ */
17
+ children?: ReactNode
18
+ /** Toast on success. `false` for a surface with no toast viewport mounted. */
19
+ toastOnCopy?: boolean
20
+ copiedMessage?: string
21
+ className?: string
22
+ }
23
+
24
+ /**
25
+ * The project's deploy key, with a copy button.
26
+ *
27
+ * The clipboard is UI, not network — so unlike everything else in this panel it
28
+ * *is* the design system's business, toast included (`toast()` ships with the
29
+ * package). What stays outside is the guidance text, which differs per app.
30
+ */
31
+ export function DeployKeySection({
32
+ publicKey,
33
+ children,
34
+ toastOnCopy = true,
35
+ copiedMessage = "Clé publique copiée",
36
+ className,
37
+ }: DeployKeySectionProps) {
38
+ const [copied, setCopied] = useState(false)
39
+
40
+ async function copy() {
41
+ try {
42
+ await navigator.clipboard.writeText(publicKey)
43
+ setCopied(true)
44
+ setTimeout(() => setCopied(false), 1500)
45
+ if (toastOnCopy) toast.success(copiedMessage)
46
+ } catch {
47
+ if (toastOnCopy) toast.error("Impossible de copier", { description: "Le presse-papier n'est pas accessible." })
48
+ }
49
+ }
50
+
51
+ return (
52
+ <div data-slot="deploy-key-section" className={cn("space-y-2", className)}>
53
+ <div className="flex items-start gap-2 rounded-lg bg-muted/50 p-3">
54
+ {/* `break-all`, not `truncate`: a deploy key is meant to be *read* and
55
+ checked against what's registered on the git host. */}
56
+ <code className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed break-all text-foreground/80">
57
+ {publicKey}
58
+ </code>
59
+ <button
60
+ type="button"
61
+ onClick={copy}
62
+ title="Copy public key"
63
+ aria-label="Copy public key"
64
+ className="shrink-0 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
65
+ >
66
+ {copied ? (
67
+ <CheckIcon className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
68
+ ) : (
69
+ <ClipboardIcon className="h-3.5 w-3.5" />
70
+ )}
71
+ </button>
72
+ </div>
73
+ {children && <div className="text-xs leading-relaxed text-muted-foreground">{children}</div>}
74
+ </div>
75
+ )
76
+ }
@@ -1,9 +1,19 @@
1
- // @spunto/design-system/projects — composing a project.
1
+ // @spunto/design-system/projects — composing a project, and reading one back.
2
2
  //
3
- // Third domain sub-export, after `/workers` and `/devcontainer`. `ProjectForm`
4
- // is the whole "compose a dev environment" screen: identity, base image, repos,
5
- // features, extensions, lifecycle, ports, prewarm, docker-in-docker, secrets,
6
- // plus the live build manifest that recaps it and carries the submit.
3
+ // Third domain sub-export, after `/workers` and `/devcontainer`, and home to the
4
+ // two faces of a project:
5
+ //
6
+ // - `ProjectForm` — the whole "compose a dev environment" screen: identity,
7
+ // base image, repos, features, extensions, lifecycle, ports, prewarm,
8
+ // docker-in-docker, secrets, plus the live build manifest that recaps it and
9
+ // carries the submit.
10
+ // - `ProjectPanel` — the read-only side panel of a project page: the same
11
+ // configuration once it exists, plus what only a live project has (build
12
+ // cache, secrets by name, integrations, version history).
13
+ //
14
+ // One entry per domain, not one per component: a project's form and a project's
15
+ // panel are the same concept seen twice, and an app that draws one usually
16
+ // draws the other.
7
17
  //
8
18
  // Controlled by a single `ProjectFormValue`, and nothing in here fetches:
9
19
  // catalogs come in as data, the extension search is a callback, the GitHub
@@ -55,4 +65,60 @@ export type {
55
65
  ProjectSecretRef,
56
66
  } from "./types"
57
67
 
58
- export { cn } from "../../utils"
68
+ // ── The panel side ──────────────────────────────────────────────────────────
69
+ // Read-only twin of the form. Its bricks are exported one by one too, because
70
+ // the edit page (and tomorrow a deployment card) reuses part of them without
71
+ // wanting the whole panel.
72
+
73
+ export { ProjectPanel } from "./project-panel"
74
+ export type { ProjectPanelProps } from "./project-panel"
75
+
76
+ export { PanelSection, Eyebrow } from "./panel-section"
77
+ export type { PanelSectionProps, EyebrowProps } from "./panel-section"
78
+
79
+ export { RepositoryList, projectRepoName, projectRepoSource } from "./repository-list"
80
+ export type { RepositoryListProps } from "./repository-list"
81
+
82
+ export { ChipList, FeatureChips, ExtensionChips, PortChips, SecretChips } from "./chip-list"
83
+ export type { ChipListProps, ChipItem, ChipTone } from "./chip-list"
84
+
85
+ export { BuildCacheList, BuildStateLabel, PrebuildButton } from "./build-cache-list"
86
+ export type { BuildCacheListProps, PrebuildButtonProps } from "./build-cache-list"
87
+
88
+ export { LifecycleCommands, LifecycleCommand } from "./lifecycle-commands"
89
+ export type { LifecycleCommandsProps, LifecycleCommandProps } from "./lifecycle-commands"
90
+
91
+ export { IntegrationsList } from "./integrations-list"
92
+ export type { IntegrationsListProps } from "./integrations-list"
93
+
94
+ export { VersionHistory } from "./version-history"
95
+ export type { VersionHistoryProps } from "./version-history"
96
+
97
+ export { DeployKeySection } from "./deploy-key-section"
98
+ export type { DeployKeySectionProps } from "./deploy-key-section"
99
+
100
+ export { PanelMeta } from "./panel-meta"
101
+ export type { PanelMetaProps } from "./panel-meta"
102
+
103
+ export { ProjectStatePill, projectStateConfig } from "./project-state"
104
+ export type { ProjectStatePillProps, ProjectStateKey } from "./project-state"
105
+
106
+ // The panel reads a *partial* snapshot, the form owns a *complete* value — two
107
+ // opposite invariants, hence two type modules (see `panel-types.ts`).
108
+ export type {
109
+ ProjectPanelProject,
110
+ ProjectPanelRepository,
111
+ ProjectPanelMetaItem,
112
+ ProjectBuildTarget,
113
+ ProjectIntegration,
114
+ ProjectSecretMeta,
115
+ ProjectVersionEntry,
116
+ } from "./panel-types"
117
+
118
+ // The link slot is shared with the worker surface — same shape, same rule (the
119
+ // package never imports `next/link`), so it is re-exported rather than redefined.
120
+ export type { LinkRender } from "../workers/worker-card"
121
+
122
+ // Re-exported here so a consumer of the project surface doesn't have to import
123
+ // the root entry just to format what it feeds these components.
124
+ export { cn, formatShortDate } from "../../utils"
@@ -0,0 +1,87 @@
1
+ // External accounts a project can be connected to (GitHub, GCP…).
2
+ //
3
+ // Only the dashboard has this section today, but it is exactly the kind of band
4
+ // Lite will grow — so it ships as its own brick, with the provider glyph as a
5
+ // slot: the design system carries no brand icons.
6
+
7
+ import { CheckCircle2Icon } from "lucide-react"
8
+
9
+ import { cn } from "../../utils"
10
+ import { resolveStackMark, StackMark } from "../devcontainer/stack-mark"
11
+ import type { LinkRender } from "../workers/worker-card"
12
+ import type { ProjectIntegration } from "./panel-types"
13
+
14
+ export interface IntegrationsListProps {
15
+ integrations: ProjectIntegration[]
16
+ /**
17
+ * Renders the "Connect" row as the app's link component. Absent → a plain
18
+ * `<a>`, which is the right default here anyway: connecting is a full-page
19
+ * redirect to the API's OAuth entry point, not a client-side route.
20
+ */
21
+ link?: LinkRender
22
+ connectLabel?: string
23
+ /** Right-hand text when an integration is off *and* offers no connect URL. */
24
+ notConnectedLabel?: string
25
+ className?: string
26
+ }
27
+
28
+ /** One row per integration: connected (green check) or a "Connect" affordance. */
29
+ export function IntegrationsList({
30
+ integrations,
31
+ link,
32
+ connectLabel = "Connect",
33
+ notConnectedLabel = "Not connected",
34
+ className,
35
+ }: IntegrationsListProps) {
36
+ if (!integrations.length) return null
37
+
38
+ const Link: LinkRender =
39
+ link ?? (({ href, className: cls, children }) => <a href={href} className={cls}>{children}</a>)
40
+
41
+ return (
42
+ <div data-slot="integrations-list" className={cn("space-y-1.5", className)}>
43
+ {integrations.map((integration) => {
44
+ const linked = !integration.connected && !!integration.connectHref
45
+ const body = (
46
+ <>
47
+ <span className="flex min-w-0 items-center gap-2">
48
+ {/* The provider's glyph stays a slot, but it no longer *has* to be
49
+ passed: GitHub, GCP, Docker & co. are brand marks the package
50
+ already ships, derived from the id/label by the same function
51
+ the catalog cards use. An app only supplies `icon` for a
52
+ provider the marks don't know. */}
53
+ <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground">
54
+ {integration.icon ?? <StackMark name={resolveStackMark(integration.id, integration.label)} className="h-3.5 w-3.5" />}
55
+ </span>
56
+ <span className="truncate">{integration.label}</span>
57
+ </span>
58
+ {integration.connected ? (
59
+ <CheckCircle2Icon className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-400" />
60
+ ) : linked ? (
61
+ <span className="shrink-0 text-[11px] text-primary group-hover:underline">{connectLabel}</span>
62
+ ) : (
63
+ <span className="shrink-0 text-[11px] text-muted-foreground/50">{notConnectedLabel}</span>
64
+ )}
65
+ </>
66
+ )
67
+
68
+ if (!linked)
69
+ return (
70
+ <div key={integration.id} className="flex items-center justify-between gap-2 text-xs">
71
+ {body}
72
+ </div>
73
+ )
74
+
75
+ return (
76
+ <Link
77
+ key={integration.id}
78
+ href={integration.connectHref!}
79
+ className="group flex items-center justify-between gap-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
80
+ >
81
+ {body}
82
+ </Link>
83
+ )
84
+ })}
85
+ </div>
86
+ )
87
+ }