@spunto/design-system 0.11.1 → 0.13.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 +53 -2
- package/package.json +1 -1
- 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-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/components/workers/git-branch-chips.tsx +30 -7
- package/src/components/workers/index.ts +2 -2
- package/src/components/workers/types.ts +6 -0
- package/src/components/workers/worker-card.tsx +8 -2
- package/src/utils.ts +22 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The panel's footer: a few label → value lines, no border, no title.
|
|
2
|
+
//
|
|
3
|
+
// The two apps put different things here (the dashboard "Nodes online", Lite
|
|
4
|
+
// "Runtime: Docker · local"), so this is a plain list of items rather than a
|
|
5
|
+
// fixed set of fields — only "Created" is filled in by `ProjectPanel` itself.
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../utils"
|
|
8
|
+
import type { ProjectPanelMetaItem } from "./panel-types"
|
|
9
|
+
|
|
10
|
+
const TONES = {
|
|
11
|
+
default: "text-muted-foreground",
|
|
12
|
+
ok: "font-medium text-green-600 dark:text-green-400",
|
|
13
|
+
muted: "text-muted-foreground/60",
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
export interface PanelMetaProps {
|
|
17
|
+
items: ProjectPanelMetaItem[]
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One line per metadata item: icon + label on the left, value on the right. */
|
|
22
|
+
export function PanelMeta({ items, className }: PanelMetaProps) {
|
|
23
|
+
if (!items.length) return null
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div data-slot="panel-meta" className={cn("space-y-1.5", className)}>
|
|
27
|
+
{items.map((item) => (
|
|
28
|
+
<div key={item.id} className="flex items-center justify-between gap-2 text-[11px]">
|
|
29
|
+
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground/60">
|
|
30
|
+
{item.icon}
|
|
31
|
+
<span className="truncate">{item.label}</span>
|
|
32
|
+
</span>
|
|
33
|
+
<span className={cn("min-w-0 truncate text-right", TONES[item.tone ?? "default"])}>
|
|
34
|
+
{item.value}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The panel's structural shell: one band per topic, with its uppercase label.
|
|
2
|
+
//
|
|
3
|
+
// This is the motif that was copy-pasted ~12 times per app (`px-4 py-3` +
|
|
4
|
+
// `border-b border-border/60` + a `text-[10px] uppercase` paragraph). Lite had
|
|
5
|
+
// already extracted the label as a local `Eyebrow`; the dashboard rewrote it by
|
|
6
|
+
// hand in every section. Both are this.
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from "react"
|
|
9
|
+
|
|
10
|
+
import { cn } from "../../utils"
|
|
11
|
+
|
|
12
|
+
export interface EyebrowProps {
|
|
13
|
+
/** Small glyph before the label — 3×3 (`h-3 w-3`) to sit on the cap height. */
|
|
14
|
+
icon?: ReactNode
|
|
15
|
+
children: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The uppercase label above a section's content. */
|
|
20
|
+
export function Eyebrow({ icon, children, className }: EyebrowProps) {
|
|
21
|
+
return (
|
|
22
|
+
<p
|
|
23
|
+
data-slot="eyebrow"
|
|
24
|
+
className={cn(
|
|
25
|
+
"text-[10px] font-semibold tracking-wider text-muted-foreground/60 uppercase",
|
|
26
|
+
icon && "flex items-center gap-1.5",
|
|
27
|
+
className
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
{icon}
|
|
31
|
+
{children}
|
|
32
|
+
</p>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PanelSectionProps {
|
|
37
|
+
/** Rendered as an `Eyebrow`. Absent → the section is just a bordered band. */
|
|
38
|
+
title?: ReactNode
|
|
39
|
+
icon?: ReactNode
|
|
40
|
+
/**
|
|
41
|
+
* Right-hand affordance on the title row — a "Manage" link, a "Pre-build"
|
|
42
|
+
* button. Anything that calls an API stays the caller's business.
|
|
43
|
+
*/
|
|
44
|
+
action?: ReactNode
|
|
45
|
+
/** Last section of a panel → no bottom border (the card's edge closes it). */
|
|
46
|
+
last?: boolean
|
|
47
|
+
children?: ReactNode
|
|
48
|
+
className?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One band of the project panel. Keeps the header row and the content spaced
|
|
53
|
+
* consistently whether or not there is an action on the right, so a section
|
|
54
|
+
* with a "Manage" link lines up with one without.
|
|
55
|
+
*/
|
|
56
|
+
export function PanelSection({ title, icon, action, last = false, children, className }: PanelSectionProps) {
|
|
57
|
+
return (
|
|
58
|
+
<div data-slot="panel-section" className={cn("px-4 py-3", !last && "border-b border-border/60", className)}>
|
|
59
|
+
{(title || action) && (
|
|
60
|
+
<div className={cn("mb-2 flex min-w-0 items-center justify-between gap-2", !title && "justify-end")}>
|
|
61
|
+
{title && <Eyebrow icon={icon}>{title}</Eyebrow>}
|
|
62
|
+
{action}
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Structural, deliberately laxist shapes for what the project PANEL reads.
|
|
2
|
+
//
|
|
3
|
+
// Separate from `types.ts`, which holds `ProjectFormValue` & co: the form owns a
|
|
4
|
+
// *complete* value it edits (every field present, never undefined), the panel
|
|
5
|
+
// reads a *partial* snapshot it merely displays. Same domain, opposite
|
|
6
|
+
// invariants — merging them would force one of the two to lie.
|
|
7
|
+
//
|
|
8
|
+
// Same rule as the worker types: the design system must NOT import the
|
|
9
|
+
// OpenAPI-generated types of the Spunto dashboard, nor Spunto Lite's
|
|
10
|
+
// `lib/types.ts` — the two model a project differently. A repository carries a
|
|
11
|
+
// `project` slug on the dashboard and a full `cloneUrl` on Lite;
|
|
12
|
+
// `deployPublicKey` is a `(project as any)` cast on the dashboard and a real
|
|
13
|
+
// field on Lite; `currentVersion` is optional on one side and guaranteed on the
|
|
14
|
+
// other. So the shapes below describe the *minimum* these components read,
|
|
15
|
+
// everything optional but `name` / `image` / `createdAt`, and an unknown value
|
|
16
|
+
// (an unheard-of `state`, a missing repo slug) degrades instead of crashing.
|
|
17
|
+
|
|
18
|
+
import type { ReactNode } from "react"
|
|
19
|
+
|
|
20
|
+
/** One repository cloned into a worker's `/workspace`. */
|
|
21
|
+
export interface ProjectPanelRepository {
|
|
22
|
+
id: string
|
|
23
|
+
/** `owner/repo` slug — how the dashboard names a GitHub repository. */
|
|
24
|
+
project?: string | null
|
|
25
|
+
/** Full clone URL (SSH or HTTPS) — how Lite names a generic "git" repository. */
|
|
26
|
+
cloneUrl?: string | null
|
|
27
|
+
/** Folder under `/workspace` the repo is checked out into. */
|
|
28
|
+
workspacePath: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The project snapshot a `ProjectPanel` draws. */
|
|
32
|
+
export interface ProjectPanelProject {
|
|
33
|
+
name: string
|
|
34
|
+
description?: string | null
|
|
35
|
+
/** `created` | `creating` | `ready` | `updating` | `error`… Absent → no badge. */
|
|
36
|
+
state?: string | null
|
|
37
|
+
/** Base image reference — only its last segment is displayed. */
|
|
38
|
+
image: string
|
|
39
|
+
/** Current config version → the `vN` of the stats row. */
|
|
40
|
+
currentVersion?: number
|
|
41
|
+
/** ISO date → the "Created" line of the footer. */
|
|
42
|
+
createdAt: string
|
|
43
|
+
repositories?: ProjectPanelRepository[]
|
|
44
|
+
/** devcontainer features, by full id (`ghcr.io/devcontainers/features/node:1`). */
|
|
45
|
+
features?: { id: string }[]
|
|
46
|
+
vscodeExtensions?: string[]
|
|
47
|
+
forwardPorts?: (number | string)[]
|
|
48
|
+
postCreateCommand?: string | null
|
|
49
|
+
postStartCommand?: string | null
|
|
50
|
+
/** Public half of the project's SSH deploy key (RFC 0013). */
|
|
51
|
+
deployPublicKey?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A secret, by NAME only — a value never reaches the design system. */
|
|
55
|
+
export interface ProjectSecretMeta {
|
|
56
|
+
id: string
|
|
57
|
+
name: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One target of the pre-build cache: a BYOC node on the dashboard, the single
|
|
62
|
+
* local Docker daemon on Lite.
|
|
63
|
+
*/
|
|
64
|
+
export interface ProjectBuildTarget {
|
|
65
|
+
id: string
|
|
66
|
+
label: string
|
|
67
|
+
/** `building` | `ready` | `error`. Absent/unknown → "not built". */
|
|
68
|
+
state?: "building" | "ready" | "error" | string | null
|
|
69
|
+
/** Absent → the row is inert (Lite has no build log to open). */
|
|
70
|
+
onSelect?: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An external account the project can be connected to (GitHub, GCP…). */
|
|
74
|
+
export interface ProjectIntegration {
|
|
75
|
+
id: string
|
|
76
|
+
label: string
|
|
77
|
+
/** The provider's logo/glyph — a slot, the design system ships no brand icons. */
|
|
78
|
+
icon?: ReactNode
|
|
79
|
+
connected?: boolean
|
|
80
|
+
/** Target of the "Connect" affordance when not connected. */
|
|
81
|
+
connectHref?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One entry of the version history. */
|
|
85
|
+
export interface ProjectVersionEntry {
|
|
86
|
+
id: string
|
|
87
|
+
version: number
|
|
88
|
+
createdAt: string
|
|
89
|
+
/** Image of *that* version — shown under the date, last segment only. */
|
|
90
|
+
image?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One line of the panel's footer: icon, label, value. */
|
|
94
|
+
export interface ProjectPanelMetaItem {
|
|
95
|
+
id: string
|
|
96
|
+
label: string
|
|
97
|
+
value: ReactNode
|
|
98
|
+
icon?: ReactNode
|
|
99
|
+
/** `ok` greens the value (a positive count), `muted` dims it. */
|
|
100
|
+
tone?: "default" | "ok" | "muted"
|
|
101
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { CalendarIcon, KeyRoundIcon, SquarePenIcon, ZapIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn, formatShortDate } from "../../utils"
|
|
7
|
+
// The catalog surface already turns a registry reference into a logo and a
|
|
8
|
+
// readable ref — the panel shows the same image, so it reuses them instead of
|
|
9
|
+
// re-deriving its own.
|
|
10
|
+
import { RegistryRef } from "../devcontainer/registry-ref"
|
|
11
|
+
import { resolveStackMark, StackMark } from "../devcontainer/stack-mark"
|
|
12
|
+
import { buttonVariants } from "../button-variants"
|
|
13
|
+
import type { LinkRender } from "../workers/worker-card"
|
|
14
|
+
import { BuildCacheList, PrebuildButton } from "./build-cache-list"
|
|
15
|
+
import { ExtensionChips, FeatureChips, PortChips, SecretChips } from "./chip-list"
|
|
16
|
+
import { DeployKeySection } from "./deploy-key-section"
|
|
17
|
+
import { IntegrationsList } from "./integrations-list"
|
|
18
|
+
import { LifecycleCommands } from "./lifecycle-commands"
|
|
19
|
+
import { PanelMeta } from "./panel-meta"
|
|
20
|
+
import { PanelSection } from "./panel-section"
|
|
21
|
+
import { ProjectStatePill } from "./project-state"
|
|
22
|
+
import { RepositoryList } from "./repository-list"
|
|
23
|
+
import type {
|
|
24
|
+
ProjectBuildTarget,
|
|
25
|
+
ProjectIntegration,
|
|
26
|
+
ProjectPanelMetaItem,
|
|
27
|
+
ProjectPanelProject,
|
|
28
|
+
ProjectSecretMeta,
|
|
29
|
+
ProjectVersionEntry,
|
|
30
|
+
} from "./panel-types"
|
|
31
|
+
import { VersionHistory } from "./version-history"
|
|
32
|
+
|
|
33
|
+
export interface ProjectPanelProps {
|
|
34
|
+
project: ProjectPanelProject
|
|
35
|
+
|
|
36
|
+
/** Worker counts of the stats row. Absent → the row shows only `vN`, or nothing. */
|
|
37
|
+
stats?: { running: number; total: number }
|
|
38
|
+
|
|
39
|
+
/** Target of the header's "Edit" button. Absent → no button. */
|
|
40
|
+
editHref?: string
|
|
41
|
+
editLabel?: string
|
|
42
|
+
/** Target of the "Manage" link of the Secrets section. */
|
|
43
|
+
secretsHref?: string
|
|
44
|
+
manageLabel?: string
|
|
45
|
+
/**
|
|
46
|
+
* Escape hatch for links. `link` receives `{ href, className, children }` and
|
|
47
|
+
* must return an anchor-ish node (`next/link`, a router link…); without it the
|
|
48
|
+
* panel falls back to a plain `<a>`. Same slot as `WorkerCard` — the package
|
|
49
|
+
* never imports `next/link`, it is also consumed by a Vite app.
|
|
50
|
+
*/
|
|
51
|
+
render?: { link?: LinkRender }
|
|
52
|
+
|
|
53
|
+
/** Secret NAMES. `[]` renders the empty state, `undefined` hides the section. */
|
|
54
|
+
secrets?: ProjectSecretMeta[]
|
|
55
|
+
|
|
56
|
+
/** Build cache targets: BYOC nodes on the dashboard, one local daemon on Lite. */
|
|
57
|
+
buildTargets?: ProjectBuildTarget[]
|
|
58
|
+
onPrebuild?: () => void
|
|
59
|
+
prebuilding?: boolean
|
|
60
|
+
|
|
61
|
+
integrations?: ProjectIntegration[]
|
|
62
|
+
|
|
63
|
+
versions?: ProjectVersionEntry[]
|
|
64
|
+
/** Restoring is a mutation → the app's. The callback gets the whole entry. */
|
|
65
|
+
onRestoreVersion?: (version: ProjectVersionEntry) => void
|
|
66
|
+
restoringVersionId?: string | null
|
|
67
|
+
versionsDefaultOpen?: boolean
|
|
68
|
+
/** Inline error of the version history (a failed restore). */
|
|
69
|
+
error?: string | null
|
|
70
|
+
|
|
71
|
+
/** Registration guidance under the deploy key — differs per app, hence a slot. */
|
|
72
|
+
deployKeyHelp?: ReactNode
|
|
73
|
+
|
|
74
|
+
/** Extra footer lines. "Created" is always first, from `project.createdAt`. */
|
|
75
|
+
meta?: ProjectPanelMetaItem[]
|
|
76
|
+
|
|
77
|
+
/** Sections an app has and the others don't, appended before the footer. */
|
|
78
|
+
children?: ReactNode
|
|
79
|
+
className?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The left-hand panel of a project page: identity, image, repositories, deploy
|
|
84
|
+
* key, features, extensions, build cache, ports, lifecycle, secrets,
|
|
85
|
+
* integrations, version history, footer.
|
|
86
|
+
*
|
|
87
|
+
* **Purely presentational.** Hand it a project plus slots and it draws — no
|
|
88
|
+
* query, no mutation, no router. Everything that would call an API is a
|
|
89
|
+
* callback (`onPrebuild`, `onRestoreVersion`) or a link the app renders
|
|
90
|
+
* (`editHref` + `render.link`). That is what makes the same panel renderable by
|
|
91
|
+
* the dashboard, by Spunto Lite and by the showcase with hand-written fixtures.
|
|
92
|
+
*
|
|
93
|
+
* The layout is a fixed sequence of optional bands, each disappearing when its
|
|
94
|
+
* data is absent: a project with no repository, no feature and no secret
|
|
95
|
+
* collapses to a header plus an image without a single caller-side conditional.
|
|
96
|
+
*
|
|
97
|
+
* The card is the panel's business; **its placement is not** — the
|
|
98
|
+
* `<aside className="w-full lg:w-72 lg:sticky …">` that pins it in the page grid
|
|
99
|
+
* stays in the app.
|
|
100
|
+
*/
|
|
101
|
+
export function ProjectPanel({
|
|
102
|
+
project,
|
|
103
|
+
stats,
|
|
104
|
+
editHref,
|
|
105
|
+
editLabel = "Edit",
|
|
106
|
+
secretsHref,
|
|
107
|
+
manageLabel = "Manage",
|
|
108
|
+
render,
|
|
109
|
+
secrets,
|
|
110
|
+
buildTargets,
|
|
111
|
+
onPrebuild,
|
|
112
|
+
prebuilding = false,
|
|
113
|
+
integrations,
|
|
114
|
+
versions,
|
|
115
|
+
onRestoreVersion,
|
|
116
|
+
restoringVersionId,
|
|
117
|
+
versionsDefaultOpen = false,
|
|
118
|
+
error,
|
|
119
|
+
deployKeyHelp,
|
|
120
|
+
meta,
|
|
121
|
+
children,
|
|
122
|
+
className,
|
|
123
|
+
}: ProjectPanelProps) {
|
|
124
|
+
const Link: LinkRender =
|
|
125
|
+
render?.link ?? (({ href, className: cls, children: kids }) => <a href={href} className={cls}>{kids}</a>)
|
|
126
|
+
|
|
127
|
+
const imageMark = resolveStackMark(project.image)
|
|
128
|
+
const repositories = project.repositories ?? []
|
|
129
|
+
const features = project.features ?? []
|
|
130
|
+
const extensions = project.vscodeExtensions ?? []
|
|
131
|
+
const ports = project.forwardPorts ?? []
|
|
132
|
+
const showBuildCache = !!buildTargets?.length || (!!buildTargets && !!onPrebuild)
|
|
133
|
+
const showStats = !!stats || project.currentVersion != null
|
|
134
|
+
|
|
135
|
+
const metaItems: ProjectPanelMetaItem[] = [
|
|
136
|
+
{
|
|
137
|
+
id: "created",
|
|
138
|
+
label: "Created",
|
|
139
|
+
icon: <CalendarIcon className="h-3 w-3 shrink-0" />,
|
|
140
|
+
value: formatShortDate(project.createdAt),
|
|
141
|
+
},
|
|
142
|
+
...(meta ?? []),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
data-slot="project-panel"
|
|
148
|
+
// `@container`: the few responsive rules below are container queries, never
|
|
149
|
+
// viewport ones. This panel is a 288 px sidebar column on a desktop and a
|
|
150
|
+
// full-width block on a phone — and the desktop case is the *narrow* one.
|
|
151
|
+
className={cn(
|
|
152
|
+
"@container overflow-hidden rounded-xl border border-border bg-card shadow-sm",
|
|
153
|
+
className
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
{/* ── Header : identité, état, Edit, ligne de stats ── */}
|
|
157
|
+
<div className="border-b border-border/60 px-4 pt-4 pb-3">
|
|
158
|
+
<div className="flex items-start justify-between gap-2">
|
|
159
|
+
<div className="min-w-0">
|
|
160
|
+
{/* A project name is one unbreakable token more often than not. */}
|
|
161
|
+
<p className="truncate text-sm leading-tight font-semibold" title={project.name}>
|
|
162
|
+
{project.name}
|
|
163
|
+
</p>
|
|
164
|
+
{project.description && (
|
|
165
|
+
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{project.description}</p>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
169
|
+
{project.state && <ProjectStatePill state={project.state} />}
|
|
170
|
+
{editHref && (
|
|
171
|
+
<Link
|
|
172
|
+
href={editHref}
|
|
173
|
+
className={cn(
|
|
174
|
+
buttonVariants({ variant: "ghost", size: "sm" }),
|
|
175
|
+
"h-7 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<SquarePenIcon className="h-3 w-3" />
|
|
179
|
+
{/* Below ~256 px the state pill plus a labelled button push the
|
|
180
|
+
name down to nothing — the pencil alone still reads. */}
|
|
181
|
+
<span className="@max-[16rem]:hidden">{editLabel}</span>
|
|
182
|
+
</Link>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{showStats && (
|
|
188
|
+
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
189
|
+
{stats && (
|
|
190
|
+
<>
|
|
191
|
+
<span className="flex items-center gap-1.5">
|
|
192
|
+
<span className={cn("h-2 w-2 rounded-full", stats.running > 0 ? "bg-green-500" : "bg-muted-foreground/40")} />
|
|
193
|
+
<span className="text-xs text-muted-foreground">
|
|
194
|
+
<span className="font-medium text-foreground">{stats.running}</span> running
|
|
195
|
+
</span>
|
|
196
|
+
</span>
|
|
197
|
+
<Separator />
|
|
198
|
+
<span className="text-xs text-muted-foreground">
|
|
199
|
+
<span className="font-medium text-foreground">{stats.total}</span> total
|
|
200
|
+
</span>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
{project.currentVersion != null && (
|
|
204
|
+
<>
|
|
205
|
+
{stats && <Separator />}
|
|
206
|
+
<span className="font-mono text-xs text-muted-foreground">v{project.currentVersion}</span>
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<PanelSection title="Image">
|
|
214
|
+
{/* The base image, drawn like the catalog draws it: the runtime's own
|
|
215
|
+
mark (derived from the ref, never fetched) and the reference with its
|
|
216
|
+
host dimmed — so the eye lands on `name:tag`, the part that actually
|
|
217
|
+
varies. Copyable, because pasting it into a devcontainer.json is the
|
|
218
|
+
one thing you want to do with it. */}
|
|
219
|
+
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
|
220
|
+
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
|
221
|
+
<StackMark name={imageMark} className="h-4 w-4" />
|
|
222
|
+
</span>
|
|
223
|
+
<RegistryRef value={project.image} copyable className="min-w-0 text-[11px]" />
|
|
224
|
+
</div>
|
|
225
|
+
</PanelSection>
|
|
226
|
+
|
|
227
|
+
{repositories.length > 0 && (
|
|
228
|
+
<PanelSection title="Repositories">
|
|
229
|
+
<RepositoryList repositories={repositories} />
|
|
230
|
+
</PanelSection>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{project.deployPublicKey && (
|
|
234
|
+
<PanelSection title="Deploy key" icon={<KeyRoundIcon className="h-3 w-3" />}>
|
|
235
|
+
<DeployKeySection publicKey={project.deployPublicKey}>{deployKeyHelp}</DeployKeySection>
|
|
236
|
+
</PanelSection>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
{features.length > 0 && (
|
|
240
|
+
<PanelSection title="Features">
|
|
241
|
+
<FeatureChips features={features} />
|
|
242
|
+
</PanelSection>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{extensions.length > 0 && (
|
|
246
|
+
<PanelSection title="Extensions">
|
|
247
|
+
<ExtensionChips extensions={extensions} />
|
|
248
|
+
</PanelSection>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{showBuildCache && (
|
|
252
|
+
<PanelSection
|
|
253
|
+
title="Build cache"
|
|
254
|
+
icon={<ZapIcon className="h-3 w-3" />}
|
|
255
|
+
action={onPrebuild && <PrebuildButton onClick={onPrebuild} pending={prebuilding} />}
|
|
256
|
+
>
|
|
257
|
+
<BuildCacheList targets={buildTargets ?? []} />
|
|
258
|
+
</PanelSection>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{ports.length > 0 && (
|
|
262
|
+
<PanelSection title="Forwarded ports">
|
|
263
|
+
<PortChips ports={ports} />
|
|
264
|
+
</PanelSection>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{(project.postCreateCommand || project.postStartCommand) && (
|
|
268
|
+
<PanelSection title="Lifecycle">
|
|
269
|
+
<LifecycleCommands
|
|
270
|
+
postCreateCommand={project.postCreateCommand}
|
|
271
|
+
postStartCommand={project.postStartCommand}
|
|
272
|
+
/>
|
|
273
|
+
</PanelSection>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{secrets && (
|
|
277
|
+
<PanelSection
|
|
278
|
+
title="Secrets"
|
|
279
|
+
icon={<KeyRoundIcon className="h-3 w-3" />}
|
|
280
|
+
action={
|
|
281
|
+
secretsHref && (
|
|
282
|
+
<Link
|
|
283
|
+
href={secretsHref}
|
|
284
|
+
className="shrink-0 text-[10px] text-primary transition-colors hover:text-primary/80"
|
|
285
|
+
>
|
|
286
|
+
{manageLabel}
|
|
287
|
+
</Link>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
>
|
|
291
|
+
<SecretChips secrets={secrets} />
|
|
292
|
+
</PanelSection>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{!!integrations?.length && (
|
|
296
|
+
<PanelSection title="Integrations">
|
|
297
|
+
<IntegrationsList integrations={integrations} link={render?.link} />
|
|
298
|
+
</PanelSection>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{!!versions?.length && (
|
|
302
|
+
<PanelSection>
|
|
303
|
+
<VersionHistory
|
|
304
|
+
versions={versions}
|
|
305
|
+
currentVersion={project.currentVersion}
|
|
306
|
+
onRestore={onRestoreVersion}
|
|
307
|
+
restoringVersionId={restoringVersionId}
|
|
308
|
+
error={error}
|
|
309
|
+
defaultOpen={versionsDefaultOpen}
|
|
310
|
+
/>
|
|
311
|
+
</PanelSection>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{children}
|
|
315
|
+
|
|
316
|
+
<PanelSection last>
|
|
317
|
+
<PanelMeta items={metaItems} />
|
|
318
|
+
</PanelSection>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The "·" between two stats. Hidden below ~272 px: the row is two lines deep by
|
|
325
|
+
* then, and a separator that ends up alone at the end of a line points at
|
|
326
|
+
* nothing (same reasoning as `WorkerCard`'s meta line).
|
|
327
|
+
*/
|
|
328
|
+
function Separator() {
|
|
329
|
+
return <span className="text-border @max-[17rem]:hidden">·</span>
|
|
330
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The project's lifecycle state, as a pill.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the `stateColors` map both apps kept inline, in raw Tailwind palette
|
|
4
|
+
// classes with hand-written `dark:` variants. Same semantics as the worker
|
|
5
|
+
// statuses (green = healthy, yellow = in flight, red = failure, neutral =
|
|
6
|
+
// nothing happening), and errors go through the `destructive` token rather than
|
|
7
|
+
// a raw red so they track the theme.
|
|
8
|
+
//
|
|
9
|
+
// No "use client": a pure table plus a hookless component.
|
|
10
|
+
|
|
11
|
+
import { cn } from "../../utils"
|
|
12
|
+
|
|
13
|
+
export type ProjectStateKey = "created" | "creating" | "ready" | "updating" | "error"
|
|
14
|
+
|
|
15
|
+
const NEUTRAL = "bg-muted text-muted-foreground border-border"
|
|
16
|
+
|
|
17
|
+
export const projectStateConfig: Record<ProjectStateKey, string> = {
|
|
18
|
+
created: NEUTRAL,
|
|
19
|
+
creating: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20 dark:text-yellow-400",
|
|
20
|
+
ready: "bg-green-500/10 text-green-700 border-green-500/20 dark:text-green-400",
|
|
21
|
+
updating: "bg-primary/10 text-primary border-primary/20",
|
|
22
|
+
error: "bg-destructive/10 text-destructive border-destructive/20",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProjectStatePillProps {
|
|
26
|
+
/** Raw state string. Unknown values render neutral instead of throwing. */
|
|
27
|
+
state: string
|
|
28
|
+
label?: string
|
|
29
|
+
className?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The state chip next to the project name. */
|
|
33
|
+
export function ProjectStatePill({ state, label, className }: ProjectStatePillProps) {
|
|
34
|
+
const tone = projectStateConfig[state as ProjectStateKey] ?? NEUTRAL
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
data-slot="project-state-pill"
|
|
38
|
+
className={cn(
|
|
39
|
+
"inline-flex h-5 shrink-0 items-center rounded-full border px-2 text-[10px] leading-none font-medium capitalize",
|
|
40
|
+
tone,
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{label ?? state}
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|