@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 +45 -1
- package/package.json +1 -1
- package/src/components/projects/advanced-sections.tsx +16 -4
- package/src/components/projects/build-cache-list.tsx +104 -0
- package/src/components/projects/chip-list.tsx +168 -0
- package/src/components/projects/deploy-key-section.tsx +76 -0
- package/src/components/projects/index.ts +72 -6
- package/src/components/projects/integrations-list.tsx +87 -0
- package/src/components/projects/lifecycle-commands.tsx +50 -0
- package/src/components/projects/panel-meta.tsx +40 -0
- package/src/components/projects/panel-section.tsx +68 -0
- package/src/components/projects/panel-types.ts +101 -0
- package/src/components/projects/project-form.tsx +12 -5
- package/src/components/projects/project-panel.tsx +330 -0
- package/src/components/projects/project-state.tsx +47 -0
- package/src/components/projects/repository-list.tsx +108 -0
- package/src/components/projects/version-history.tsx +116 -0
- package/src/utils.ts +22 -0
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"
|
|
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|