@hanzo/ui 8.0.6 → 8.0.8
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/dist/ThemeToggleNext-7727UR5W.js +4 -0
- package/dist/ThemeToggleNext-7727UR5W.js.map +1 -0
- package/dist/ThemeToggleNext-ICB5KL5J.cjs +19 -0
- package/dist/ThemeToggleNext-ICB5KL5J.cjs.map +1 -0
- package/dist/billing.cjs +15 -0
- package/dist/billing.cjs.map +1 -0
- package/{types → dist}/billing.d.ts +0 -1
- package/dist/billing.js +4 -0
- package/dist/billing.js.map +1 -0
- package/dist/canvas.cjs +15 -0
- package/dist/canvas.cjs.map +1 -0
- package/dist/canvas.d.ts +1 -0
- package/dist/canvas.js +4 -0
- package/dist/canvas.js.map +1 -0
- package/{types → dist}/cd/layout.d.ts +0 -1
- package/{types → dist}/cd/status.d.ts +0 -1
- package/{types → dist}/cd/time.d.ts +0 -1
- package/{types → dist}/cd/types.d.ts +0 -1
- package/dist/chunk-42CU5GSF.js +98 -0
- package/dist/chunk-42CU5GSF.js.map +1 -0
- package/dist/chunk-GDGVXU7W.js +1999 -0
- package/dist/chunk-GDGVXU7W.js.map +1 -0
- package/dist/chunk-HSPMQQVJ.cjs +2066 -0
- package/dist/chunk-HSPMQQVJ.cjs.map +1 -0
- package/dist/chunk-YBXVUV7P.cjs +103 -0
- package/dist/chunk-YBXVUV7P.cjs.map +1 -0
- package/dist/dashboard.cjs +15 -0
- package/dist/dashboard.cjs.map +1 -0
- package/dist/dashboard.d.ts +1 -0
- package/dist/dashboard.js +4 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/data.cjs +15 -0
- package/dist/data.cjs.map +1 -0
- package/dist/data.d.ts +1 -0
- package/dist/data.js +4 -0
- package/dist/data.js.map +1 -0
- package/dist/gitops.cjs +15 -0
- package/dist/gitops.cjs.map +1 -0
- package/dist/gitops.d.ts +1 -0
- package/dist/gitops.js +4 -0
- package/dist/gitops.js.map +1 -0
- package/dist/index.cjs +291 -0
- package/dist/index.cjs.map +1 -0
- package/{types → dist}/index.d.ts +0 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/network.cjs +15 -0
- package/dist/network.cjs.map +1 -0
- package/{types → dist}/network.d.ts +0 -1
- package/dist/network.js +4 -0
- package/dist/network.js.map +1 -0
- package/dist/primitives/bases/data/index.cjs +15 -0
- package/dist/primitives/bases/data/index.cjs.map +1 -0
- package/dist/primitives/bases/data/index.d.ts +1 -0
- package/dist/primitives/bases/data/index.js +4 -0
- package/dist/primitives/bases/data/index.js.map +1 -0
- package/{types → dist}/product/AppHeader.d.ts +0 -1
- package/{types → dist}/product/BrandMark.d.ts +0 -1
- package/{types → dist}/product/Charts.d.ts +0 -1
- package/{types → dist}/product/ComboBox.d.ts +0 -1
- package/{types → dist}/product/DataTable.d.ts +0 -1
- package/{types → dist}/product/Donut.d.ts +0 -1
- package/{types → dist}/product/EmptyState.d.ts +0 -1
- package/{types → dist}/product/FadeIn.d.ts +0 -1
- package/{types → dist}/product/Field.d.ts +0 -1
- package/{types → dist}/product/HanzoMark.d.ts +0 -1
- package/{types → dist}/product/Metric.d.ts +0 -1
- package/{types → dist}/product/OrgSwitcher.d.ts +0 -1
- package/{types → dist}/product/PageHeader.d.ts +0 -1
- package/{types → dist}/product/PrimaryButton.d.ts +0 -1
- package/{types → dist}/product/ProductIcon.d.ts +0 -1
- package/{types → dist}/product/ProviderLogo.d.ts +0 -1
- package/{types → dist}/product/Reorder.d.ts +0 -1
- package/{types → dist}/product/SelectMenu.d.ts +7 -6
- package/{types → dist}/product/SlideOver.d.ts +0 -1
- package/{types → dist}/product/StatusTag.d.ts +0 -1
- package/dist/product/ThemeToggle.d.ts +21 -0
- package/dist/product/ThemeToggleNext.d.ts +4 -0
- package/{types → dist}/product/Toast.d.ts +0 -1
- package/{types → dist}/product/color.d.ts +0 -1
- package/{types → dist}/product/combobox/filter.d.ts +0 -1
- package/dist/product/index.cjs +278 -0
- package/dist/product/index.cjs.map +1 -0
- package/{types → dist}/product/index.d.ts +2 -1
- package/dist/product/index.js +5 -0
- package/dist/product/index.js.map +1 -0
- package/dist/product/menu/ContextMenu.d.ts +21 -0
- package/dist/product/menu/DropdownMenu.d.ts +29 -0
- package/dist/product/menu/FloatingMenu.d.ts +36 -0
- package/dist/product/menu/index.d.ts +5 -0
- package/dist/product/menu/items.d.ts +68 -0
- package/dist/product/menu/portal-theme.d.ts +28 -0
- package/dist/product/menu/roving.d.ts +8 -0
- package/{types → dist}/product/scope.d.ts +0 -1
- package/{types → dist}/product/surfaces.data.d.ts +0 -1
- package/dist/usage.cjs +15 -0
- package/dist/usage.cjs.map +1 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.js +4 -0
- package/dist/usage.js.map +1 -0
- package/dist/wallet.cjs +15 -0
- package/dist/wallet.cjs.map +1 -0
- package/{types → dist}/wallet.d.ts +0 -1
- package/dist/wallet.js +4 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +58 -32
- package/src/billing.ts +0 -9
- package/src/canvas.ts +0 -11
- package/src/cd/layout.ts +0 -184
- package/src/cd/status.ts +0 -158
- package/src/cd/time.ts +0 -52
- package/src/cd/types.ts +0 -318
- package/src/dashboard.ts +0 -8
- package/src/data.ts +0 -12
- package/src/gitops.ts +0 -5
- package/src/gui-env.d.ts +0 -27
- package/src/index.ts +0 -22
- package/src/network.ts +0 -11
- package/src/primitives/bases/data/index.ts +0 -13
- package/src/product/AppHeader.tsx +0 -183
- package/src/product/BrandMark.tsx +0 -27
- package/src/product/Charts.tsx +0 -471
- package/src/product/ComboBox.tsx +0 -147
- package/src/product/DataTable.tsx +0 -99
- package/src/product/Donut.tsx +0 -83
- package/src/product/EmptyState.tsx +0 -129
- package/src/product/FadeIn.tsx +0 -38
- package/src/product/Field.tsx +0 -160
- package/src/product/HanzoMark.tsx +0 -31
- package/src/product/Metric.tsx +0 -253
- package/src/product/OrgSwitcher.tsx +0 -332
- package/src/product/PageHeader.tsx +0 -31
- package/src/product/PrimaryButton.tsx +0 -17
- package/src/product/ProductIcon.tsx +0 -61
- package/src/product/ProviderLogo.tsx +0 -137
- package/src/product/Reorder.test.ts +0 -41
- package/src/product/Reorder.tsx +0 -110
- package/src/product/SelectMenu.tsx +0 -94
- package/src/product/SlideOver.tsx +0 -197
- package/src/product/StatusTag.tsx +0 -49
- package/src/product/ThemeToggle.tsx +0 -25
- package/src/product/Toast.tsx +0 -166
- package/src/product/color.ts +0 -17
- package/src/product/combobox/filter.test.ts +0 -51
- package/src/product/combobox/filter.ts +0 -39
- package/src/product/index.ts +0 -70
- package/src/product/scope.test.ts +0 -92
- package/src/product/scope.ts +0 -123
- package/src/product/surfaces.data.ts +0 -42
- package/src/usage.ts +0 -8
- package/src/wallet.ts +0 -11
- package/types/billing.d.ts.map +0 -1
- package/types/canvas.d.ts +0 -2
- package/types/canvas.d.ts.map +0 -1
- package/types/cd/layout.d.ts.map +0 -1
- package/types/cd/status.d.ts.map +0 -1
- package/types/cd/time.d.ts.map +0 -1
- package/types/cd/types.d.ts.map +0 -1
- package/types/dashboard.d.ts +0 -2
- package/types/dashboard.d.ts.map +0 -1
- package/types/data.d.ts +0 -2
- package/types/data.d.ts.map +0 -1
- package/types/gitops.d.ts +0 -2
- package/types/gitops.d.ts.map +0 -1
- package/types/index.d.ts.map +0 -1
- package/types/network.d.ts.map +0 -1
- package/types/primitives/bases/data/index.d.ts +0 -2
- package/types/primitives/bases/data/index.d.ts.map +0 -1
- package/types/product/AppHeader.d.ts.map +0 -1
- package/types/product/BrandMark.d.ts.map +0 -1
- package/types/product/Charts.d.ts.map +0 -1
- package/types/product/ComboBox.d.ts.map +0 -1
- package/types/product/DataTable.d.ts.map +0 -1
- package/types/product/Donut.d.ts.map +0 -1
- package/types/product/EmptyState.d.ts.map +0 -1
- package/types/product/FadeIn.d.ts.map +0 -1
- package/types/product/Field.d.ts.map +0 -1
- package/types/product/HanzoMark.d.ts.map +0 -1
- package/types/product/Metric.d.ts.map +0 -1
- package/types/product/OrgSwitcher.d.ts.map +0 -1
- package/types/product/PageHeader.d.ts.map +0 -1
- package/types/product/PrimaryButton.d.ts.map +0 -1
- package/types/product/ProductIcon.d.ts.map +0 -1
- package/types/product/ProviderLogo.d.ts.map +0 -1
- package/types/product/Reorder.d.ts.map +0 -1
- package/types/product/SelectMenu.d.ts.map +0 -1
- package/types/product/SlideOver.d.ts.map +0 -1
- package/types/product/StatusTag.d.ts.map +0 -1
- package/types/product/ThemeToggle.d.ts +0 -2
- package/types/product/ThemeToggle.d.ts.map +0 -1
- package/types/product/Toast.d.ts.map +0 -1
- package/types/product/color.d.ts.map +0 -1
- package/types/product/combobox/filter.d.ts.map +0 -1
- package/types/product/index.d.ts.map +0 -1
- package/types/product/scope.d.ts.map +0 -1
- package/types/product/surfaces.data.d.ts.map +0 -1
- package/types/usage.d.ts +0 -2
- package/types/usage.d.ts.map +0 -1
- package/types/wallet.d.ts.map +0 -1
- /package/{src → dist}/styles/hanzo-motion.css +0 -0
package/src/product/Metric.tsx
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Console metric primitives — the ONE shared set of stat tiles, panels, legend
|
|
5
|
-
* rows, util bars, and "real action or honest-disabled" buttons that every
|
|
6
|
-
* resource console (GPUs, Kubernetes, Containers, Training, Tasks) renders.
|
|
7
|
-
*
|
|
8
|
-
* These were first written for the GPU surface; promoted here so a sibling
|
|
9
|
-
* product never reaches into `gpus/` for shell chrome (separation of concerns).
|
|
10
|
-
* `gpus/charts.tsx` now re-exports them, so there is exactly one definition.
|
|
11
|
-
*
|
|
12
|
-
* Honest by construction: a `MetricCard` shows the pre-formatted `value` the
|
|
13
|
-
* caller passes (an absent metric is an em-dash, never a fabricated number); a
|
|
14
|
-
* `Sparkline`/`MiniBars` with no real series renders nothing; a `HintButton`
|
|
15
|
-
* with no `onPress` is visibly disabled with a tooltip saying WHY. Inline SVG is
|
|
16
|
-
* the house pattern (see ui/Loader, ui/HanzoMark); style props use the v5
|
|
17
|
-
* shorthand set.
|
|
18
|
-
*/
|
|
19
|
-
import type { ReactElement, ReactNode } from 'react'
|
|
20
|
-
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
|
21
|
-
|
|
22
|
-
/** Distinct, theme-neutral series colors (mid-saturation reads on dark + light). */
|
|
23
|
-
export const SERIES = ['#6ea8fe', '#7ee787', '#f0a868', '#c792ea', '#56d4c4', '#e879a6', '#d6c15a', '#8b9bb4'] as const
|
|
24
|
-
export const colorForIndex = (i: number): string => SERIES[i % SERIES.length]
|
|
25
|
-
const TRACK = 'rgba(128,128,128,0.18)'
|
|
26
|
-
|
|
27
|
-
/** Tone for a utilization/temperature value (green calm → red hot). */
|
|
28
|
-
export function utilColor(pct: number): string {
|
|
29
|
-
if (pct >= 90) return '#e5534b'
|
|
30
|
-
if (pct >= 70) return '#f0a868'
|
|
31
|
-
return '#7ee787'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A single-series sparkline over real points. Renders nothing meaningful for <2
|
|
36
|
-
* points (the caller shows `—` instead). The y-range is the data's own min/max.
|
|
37
|
-
*/
|
|
38
|
-
export function Sparkline({
|
|
39
|
-
points,
|
|
40
|
-
width = 104,
|
|
41
|
-
height = 30,
|
|
42
|
-
color = SERIES[0],
|
|
43
|
-
}: {
|
|
44
|
-
points: number[]
|
|
45
|
-
width?: number
|
|
46
|
-
height?: number
|
|
47
|
-
color?: string
|
|
48
|
-
}): ReactElement | null {
|
|
49
|
-
if (!points || points.length < 2) return null
|
|
50
|
-
const min = Math.min(...points)
|
|
51
|
-
const max = Math.max(...points)
|
|
52
|
-
const span = max - min || 1
|
|
53
|
-
const stepX = width / (points.length - 1)
|
|
54
|
-
const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
|
|
55
|
-
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * stepX).toFixed(1)},${y(p).toFixed(1)}`).join(' ')
|
|
56
|
-
return (
|
|
57
|
-
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label="trend">
|
|
58
|
-
<path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
|
|
59
|
-
</svg>
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** A small vertical-bar chart (e.g. cost per day). One bar per real datum. */
|
|
64
|
-
export function MiniBars({
|
|
65
|
-
bars,
|
|
66
|
-
width = 320,
|
|
67
|
-
height = 96,
|
|
68
|
-
color = SERIES[0],
|
|
69
|
-
}: {
|
|
70
|
-
bars: { label: string; value: number }[]
|
|
71
|
-
width?: number
|
|
72
|
-
height?: number
|
|
73
|
-
color?: string
|
|
74
|
-
}): ReactElement | null {
|
|
75
|
-
if (!bars.length) return null
|
|
76
|
-
const max = Math.max(...bars.map((b) => b.value), 1)
|
|
77
|
-
const gap = 4
|
|
78
|
-
const bw = (width - gap * (bars.length - 1)) / bars.length
|
|
79
|
-
return (
|
|
80
|
-
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" role="img" aria-label="bars">
|
|
81
|
-
{bars.map((b, i) => {
|
|
82
|
-
const h = Math.max(1, (b.value / max) * (height - 2))
|
|
83
|
-
return (
|
|
84
|
-
<rect key={b.label + i} x={i * (bw + gap)} y={height - h} width={bw} height={h} rx={2} fill={color} opacity={0.85}>
|
|
85
|
-
<title>{`${b.label}: ${b.value}`}</title>
|
|
86
|
-
</rect>
|
|
87
|
-
)
|
|
88
|
-
})}
|
|
89
|
-
</svg>
|
|
90
|
-
)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* A thin horizontal bar (0–100). By default the fill tones by value (green→red,
|
|
95
|
-
* for utilization/temperature). Pass a fixed `color` for a neutral meter like
|
|
96
|
-
* training PROGRESS, where a high value is good, not "hot".
|
|
97
|
-
*/
|
|
98
|
-
export function UtilBar({ value, width = 120, color }: { value: number; width?: number; color?: string }): ReactElement {
|
|
99
|
-
const v = Math.max(0, Math.min(100, value))
|
|
100
|
-
return (
|
|
101
|
-
<svg width={width} height={8} viewBox={`0 0 ${width} 8`} role="img" aria-label={`${Math.round(v)}%`}>
|
|
102
|
-
<rect x={0} y={1} width={width} height={6} rx={3} fill={TRACK} />
|
|
103
|
-
<rect x={0} y={1} width={(v / 100) * width} height={6} rx={3} fill={color ?? utilColor(v)} />
|
|
104
|
-
</svg>
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** A legend row: color swatch + label + value. */
|
|
109
|
-
export function LegendDot({ color, label, value }: { color: string; label: string; value?: ReactNode }) {
|
|
110
|
-
return (
|
|
111
|
-
<XStack items="center" gap="$2" justify="space-between">
|
|
112
|
-
<XStack items="center" gap="$2">
|
|
113
|
-
<YStack width={10} height={10} rounded="$1" bg={color as never} />
|
|
114
|
-
<Text fontSize="$2" color="$color11">
|
|
115
|
-
{label}
|
|
116
|
-
</Text>
|
|
117
|
-
</XStack>
|
|
118
|
-
{value != null ? (
|
|
119
|
-
<Text fontSize="$2" color="$color12" fontWeight="600">
|
|
120
|
-
{value}
|
|
121
|
-
</Text>
|
|
122
|
-
) : null}
|
|
123
|
-
</XStack>
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* One overview metric tile. `value` is pre-formatted by the caller (so an absent
|
|
129
|
-
* metric is an honest `—`, never a fabricated number). The sparkline only appears
|
|
130
|
-
* when the caller has a REAL series; the delta only when a real comparison exists.
|
|
131
|
-
*/
|
|
132
|
-
export function MetricCard({
|
|
133
|
-
icon,
|
|
134
|
-
label,
|
|
135
|
-
value,
|
|
136
|
-
caption,
|
|
137
|
-
spark,
|
|
138
|
-
sparkColor,
|
|
139
|
-
delta,
|
|
140
|
-
}: {
|
|
141
|
-
icon: ReactElement
|
|
142
|
-
label: string
|
|
143
|
-
value: string
|
|
144
|
-
caption?: string
|
|
145
|
-
spark?: number[]
|
|
146
|
-
sparkColor?: string
|
|
147
|
-
delta?: { pct: number }
|
|
148
|
-
}) {
|
|
149
|
-
const up = delta ? delta.pct >= 0 : false
|
|
150
|
-
return (
|
|
151
|
-
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" flex={1} minW={172}>
|
|
152
|
-
<XStack items="center" gap="$2" justify="space-between">
|
|
153
|
-
<XStack items="center" gap="$2">
|
|
154
|
-
{icon}
|
|
155
|
-
<Text fontSize="$2" color="$color11" fontWeight="600">
|
|
156
|
-
{label}
|
|
157
|
-
</Text>
|
|
158
|
-
</XStack>
|
|
159
|
-
{delta ? (
|
|
160
|
-
<Text fontSize="$1" color={up ? '#7ee787' : '#e5534b'}>
|
|
161
|
-
{up ? '▲' : '▼'} {Math.abs(delta.pct)}%
|
|
162
|
-
</Text>
|
|
163
|
-
) : null}
|
|
164
|
-
</XStack>
|
|
165
|
-
<XStack items="flex-end" justify="space-between" gap="$2">
|
|
166
|
-
<Text fontSize="$8" fontWeight="900" color="$color12" numberOfLines={1}>
|
|
167
|
-
{value}
|
|
168
|
-
</Text>
|
|
169
|
-
{spark && spark.length >= 2 ? <Sparkline points={spark} color={sparkColor ?? SERIES[0]} /> : null}
|
|
170
|
-
</XStack>
|
|
171
|
-
{caption ? (
|
|
172
|
-
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
|
173
|
-
{caption}
|
|
174
|
-
</Text>
|
|
175
|
-
) : null}
|
|
176
|
-
</Card>
|
|
177
|
-
)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A button that is either a real action (onPress) or HONESTLY disabled with a
|
|
182
|
-
* native tooltip explaining why (the action has no backend yet). One way to
|
|
183
|
-
* render the "real route or honest-disabled" affordance the consoles use.
|
|
184
|
-
*/
|
|
185
|
-
export function HintButton({
|
|
186
|
-
icon,
|
|
187
|
-
iconAfter,
|
|
188
|
-
children,
|
|
189
|
-
onPress,
|
|
190
|
-
disabled,
|
|
191
|
-
hint,
|
|
192
|
-
theme,
|
|
193
|
-
}: {
|
|
194
|
-
icon?: ReactElement
|
|
195
|
-
iconAfter?: ReactElement
|
|
196
|
-
children: ReactNode
|
|
197
|
-
onPress?: () => void
|
|
198
|
-
disabled?: boolean
|
|
199
|
-
/** Native tooltip text (shown on hover) — say WHY it is disabled. */
|
|
200
|
-
hint?: string
|
|
201
|
-
theme?: 'light'
|
|
202
|
-
}) {
|
|
203
|
-
const btn = (
|
|
204
|
-
<Button
|
|
205
|
-
size="$2"
|
|
206
|
-
theme={theme}
|
|
207
|
-
icon={icon}
|
|
208
|
-
iconAfter={iconAfter}
|
|
209
|
-
disabled={disabled}
|
|
210
|
-
onPress={disabled ? undefined : onPress}
|
|
211
|
-
>
|
|
212
|
-
{children}
|
|
213
|
-
</Button>
|
|
214
|
-
)
|
|
215
|
-
if (!hint) return btn
|
|
216
|
-
return (
|
|
217
|
-
<span title={hint} style={{ display: 'inline-flex' }}>
|
|
218
|
-
{btn}
|
|
219
|
-
</span>
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* A titled panel wrapper used across the consoles (chart + list cards). `grow`
|
|
225
|
-
* (default true) sets `flex={1}` so panels SHARE width in a ROW; pass `grow={false}`
|
|
226
|
-
* inside a vertical rail (a column YStack) so each panel sizes to its content and
|
|
227
|
-
* stacks — flex-grow in a column with no fixed height collapses them onto each other.
|
|
228
|
-
*/
|
|
229
|
-
export function Panel({
|
|
230
|
-
title,
|
|
231
|
-
right,
|
|
232
|
-
children,
|
|
233
|
-
minW = 280,
|
|
234
|
-
grow = true,
|
|
235
|
-
}: {
|
|
236
|
-
title: string
|
|
237
|
-
right?: ReactNode
|
|
238
|
-
children: ReactNode
|
|
239
|
-
minW?: number
|
|
240
|
-
grow?: boolean
|
|
241
|
-
}) {
|
|
242
|
-
return (
|
|
243
|
-
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={grow ? 1 : undefined} minW={minW} width={grow ? undefined : '100%'}>
|
|
244
|
-
<XStack items="center" justify="space-between" gap="$2">
|
|
245
|
-
<Text fontSize="$4" fontWeight="800" color="$color12">
|
|
246
|
-
{title}
|
|
247
|
-
</Text>
|
|
248
|
-
{right}
|
|
249
|
-
</XStack>
|
|
250
|
-
{children}
|
|
251
|
-
</Card>
|
|
252
|
-
)
|
|
253
|
-
}
|
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* OrgSwitcher — the Vercel-style org/team switcher, hoisted from the console
|
|
5
|
-
* (hanzoai/ui#36) onto @hanzo/gui primitives. Shows the org the surface is
|
|
6
|
-
* scoped to, switches between the orgs the caller can see, and creates one.
|
|
7
|
-
*
|
|
8
|
-
* Host-agnostic: the LIST is a lazy, paged loader the host injects (IAM
|
|
9
|
-
* `get-organizations` behind its own proxy) — one page at a time, the search
|
|
10
|
-
* term pushed to the server, more on demand (scroll + "Load more"), so it
|
|
11
|
-
* scales to thousands of orgs. No loader → the current org alone, synthesized
|
|
12
|
-
* from the scope (never fabricated). Selecting re-scopes IN PLACE via
|
|
13
|
-
* `scope.switchOrg` (persist + reload → every module refetches under the new
|
|
14
|
-
* `X-Org-Id`). Create posts through the injected hook, then scopes into the
|
|
15
|
-
* new org.
|
|
16
|
-
*/
|
|
17
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
18
|
-
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
|
19
|
-
import { Check, ChevronsUpDown, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
|
|
20
|
-
|
|
21
|
-
import { filterOrgs, type Org, type OrgScope } from './scope'
|
|
22
|
-
|
|
23
|
-
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
24
|
-
const initialsOf = (o: Org) =>
|
|
25
|
-
(o.displayName || o.name)
|
|
26
|
-
.split(/\s+/)
|
|
27
|
-
.map((w) => w[0])
|
|
28
|
-
.join('')
|
|
29
|
-
.slice(0, 2)
|
|
30
|
-
.toUpperCase()
|
|
31
|
-
|
|
32
|
-
/** An org's avatar — its logo when set, else a monogram tile. */
|
|
33
|
-
function OrgAvatar({ org, size = 22 }: { org: Org; size?: number }) {
|
|
34
|
-
if (org.logo) {
|
|
35
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
36
|
-
return <img src={org.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
|
|
37
|
-
}
|
|
38
|
-
return (
|
|
39
|
-
<YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
|
|
40
|
-
<Text fontSize="$1" fontWeight="800" color="$color12">
|
|
41
|
-
{initialsOf(org)}
|
|
42
|
-
</Text>
|
|
43
|
-
</YStack>
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type OrgSwitcherProps = {
|
|
48
|
-
/** The active-org contract (see `orgScope`). */
|
|
49
|
-
scope: OrgScope
|
|
50
|
-
/**
|
|
51
|
-
* Lazy paged org list: 0-based page + server-pushed search → one page of
|
|
52
|
-
* rows. A full page (=== pageSize) implies another. Omit when the caller
|
|
53
|
-
* can only see its own org.
|
|
54
|
-
*/
|
|
55
|
-
orgs?: (page: number, query: string) => Promise<Org[]>
|
|
56
|
-
/** Rows per page the loader returns. Default 20. */
|
|
57
|
-
pageSize?: number
|
|
58
|
-
/** Create-org hook → the created org's id; omit to hide the affordance. */
|
|
59
|
-
create?: (name: string) => Promise<string>
|
|
60
|
-
/** Show the "All organizations" de-scope row (`scope.leaveOrg`). */
|
|
61
|
-
picker?: boolean
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false }: OrgSwitcherProps) {
|
|
65
|
-
const currentId = scope.currentOrg()
|
|
66
|
-
|
|
67
|
-
const [open, setOpen] = useState(false)
|
|
68
|
-
const [query, setQuery] = useState('')
|
|
69
|
-
const [debounced, setDebounced] = useState('')
|
|
70
|
-
const [rows, setRows] = useState<Org[]>([])
|
|
71
|
-
const [loading, setLoading] = useState(false)
|
|
72
|
-
const [loadingMore, setLoadingMore] = useState(false)
|
|
73
|
-
const [hasMore, setHasMore] = useState(false)
|
|
74
|
-
const [creating, setCreating] = useState(false)
|
|
75
|
-
const [newName, setNewName] = useState('')
|
|
76
|
-
const [busy, setBusy] = useState(false)
|
|
77
|
-
const [err, setErr] = useState<string | null>(null)
|
|
78
|
-
const pageRef = useRef(0)
|
|
79
|
-
const reqRef = useRef(0) // race token — a newer request supersedes older ones
|
|
80
|
-
|
|
81
|
-
// Debounce the search so type-as-you-search re-hits the server at most ~4×/s.
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
const id = setTimeout(() => setDebounced(query.trim()), 250)
|
|
84
|
-
return () => clearTimeout(id)
|
|
85
|
-
}, [query])
|
|
86
|
-
|
|
87
|
-
const fetchPage = useCallback(
|
|
88
|
-
async (page: number, q: string, append: boolean) => {
|
|
89
|
-
if (!orgs) return
|
|
90
|
-
const token = ++reqRef.current
|
|
91
|
-
append ? setLoadingMore(true) : setLoading(true)
|
|
92
|
-
try {
|
|
93
|
-
const incoming = await orgs(page, q)
|
|
94
|
-
if (token !== reqRef.current) return
|
|
95
|
-
pageRef.current = page
|
|
96
|
-
setRows((prev) => {
|
|
97
|
-
const merged = append ? [...prev] : []
|
|
98
|
-
const seen = new Set(merged.map((o) => o.name))
|
|
99
|
-
for (const o of incoming) if (!seen.has(o.name)) merged.push(o)
|
|
100
|
-
return merged
|
|
101
|
-
})
|
|
102
|
-
setHasMore(incoming.length >= pageSize)
|
|
103
|
-
} catch {
|
|
104
|
-
// Honest empty / keep what's shown — never a fabricated list.
|
|
105
|
-
if (token !== reqRef.current) return
|
|
106
|
-
if (!append) setRows([])
|
|
107
|
-
setHasMore(false)
|
|
108
|
-
} finally {
|
|
109
|
-
if (token === reqRef.current) {
|
|
110
|
-
setLoading(false)
|
|
111
|
-
setLoadingMore(false)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
[orgs, pageSize],
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
// (Re)load page 0 when the popover opens or the debounced query changes.
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
if (!open || !orgs) return
|
|
121
|
-
void fetchPage(0, debounced, false)
|
|
122
|
-
}, [open, debounced, fetchPage, orgs])
|
|
123
|
-
|
|
124
|
-
const loadMore = useCallback(() => {
|
|
125
|
-
if (loading || loadingMore || !hasMore) return
|
|
126
|
-
void fetchPage(pageRef.current + 1, debounced, true)
|
|
127
|
-
}, [loading, loadingMore, hasMore, debounced, fetchPage])
|
|
128
|
-
|
|
129
|
-
// Rows to render — the loaded list (client-filtered too), else the current
|
|
130
|
-
// org synthesized from the scope.
|
|
131
|
-
const visible: Org[] = useMemo(() => {
|
|
132
|
-
if (orgs) return filterOrgs(rows, query)
|
|
133
|
-
return [{ name: currentId, displayName: titleCase(currentId) }]
|
|
134
|
-
}, [orgs, rows, query, currentId])
|
|
135
|
-
|
|
136
|
-
const current: Org = useMemo(
|
|
137
|
-
() => rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) },
|
|
138
|
-
[rows, currentId],
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
const select = useCallback(
|
|
142
|
-
(org: string) => {
|
|
143
|
-
setOpen(false)
|
|
144
|
-
scope.switchOrg(org)
|
|
145
|
-
},
|
|
146
|
-
[scope],
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
const submitCreate = async () => {
|
|
150
|
-
const name = newName.trim()
|
|
151
|
-
if (!name || !create) return
|
|
152
|
-
setBusy(true)
|
|
153
|
-
setErr(null)
|
|
154
|
-
try {
|
|
155
|
-
const org = await create(name)
|
|
156
|
-
scope.switchOrg(org)
|
|
157
|
-
} catch (e) {
|
|
158
|
-
setErr(e instanceof Error ? e.message : 'Could not create the organization.')
|
|
159
|
-
setBusy(false)
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return (
|
|
164
|
-
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
|
|
165
|
-
<Popover.Trigger asChild>
|
|
166
|
-
<Button size="$2" chromeless icon={<OrgAvatar org={current} size={18} />} iconAfter={<ChevronsUpDown size={13} />}>
|
|
167
|
-
{current.displayName || titleCase(current.name)}
|
|
168
|
-
</Button>
|
|
169
|
-
</Popover.Trigger>
|
|
170
|
-
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
|
|
171
|
-
{creating ? (
|
|
172
|
-
<YStack gap="$2">
|
|
173
|
-
<Text fontSize="$2" color="$color12" fontWeight="700">
|
|
174
|
-
Create organization
|
|
175
|
-
</Text>
|
|
176
|
-
<Input
|
|
177
|
-
size="$3"
|
|
178
|
-
placeholder="Organization name"
|
|
179
|
-
value={newName}
|
|
180
|
-
onChangeText={setNewName}
|
|
181
|
-
autoCapitalize="words"
|
|
182
|
-
onSubmitEditing={() => void submitCreate()}
|
|
183
|
-
/>
|
|
184
|
-
{err ? (
|
|
185
|
-
<Text fontSize="$1" color="$red10">
|
|
186
|
-
{err}
|
|
187
|
-
</Text>
|
|
188
|
-
) : null}
|
|
189
|
-
<XStack gap="$2" justify="flex-end">
|
|
190
|
-
<Button size="$2" chromeless onPress={() => setCreating(false)} disabled={busy}>
|
|
191
|
-
Cancel
|
|
192
|
-
</Button>
|
|
193
|
-
<Button
|
|
194
|
-
size="$2"
|
|
195
|
-
onPress={() => void submitCreate()}
|
|
196
|
-
disabled={busy || !newName.trim()}
|
|
197
|
-
icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
|
|
198
|
-
>
|
|
199
|
-
Create
|
|
200
|
-
</Button>
|
|
201
|
-
</XStack>
|
|
202
|
-
</YStack>
|
|
203
|
-
) : (
|
|
204
|
-
<YStack gap="$1">
|
|
205
|
-
{orgs ? (
|
|
206
|
-
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
|
|
207
|
-
<Search size={13} opacity={0.6} />
|
|
208
|
-
<Input
|
|
209
|
-
flex={1}
|
|
210
|
-
size="$2"
|
|
211
|
-
borderWidth={0}
|
|
212
|
-
bg="transparent"
|
|
213
|
-
placeholder="Find organization…"
|
|
214
|
-
value={query}
|
|
215
|
-
onChangeText={setQuery}
|
|
216
|
-
autoCapitalize="none"
|
|
217
|
-
autoCorrect={false}
|
|
218
|
-
/>
|
|
219
|
-
</XStack>
|
|
220
|
-
) : null}
|
|
221
|
-
|
|
222
|
-
<div
|
|
223
|
-
style={{ maxHeight: 300, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
|
|
224
|
-
onScroll={(e) => {
|
|
225
|
-
const el = e.currentTarget
|
|
226
|
-
if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) loadMore()
|
|
227
|
-
}}
|
|
228
|
-
>
|
|
229
|
-
{loading ? (
|
|
230
|
-
<XStack items="center" gap="$2" px="$2" py="$3">
|
|
231
|
-
<Spinner size="small" color="$color11" />
|
|
232
|
-
<Text fontSize="$2" color="$color10">
|
|
233
|
-
Loading organizations…
|
|
234
|
-
</Text>
|
|
235
|
-
</XStack>
|
|
236
|
-
) : visible.length === 0 ? (
|
|
237
|
-
<Text px="$2" py="$2" fontSize="$2" color="$color10">
|
|
238
|
-
{query.trim() ? `No organizations match “${query.trim()}”.` : 'No organizations yet.'}
|
|
239
|
-
</Text>
|
|
240
|
-
) : (
|
|
241
|
-
visible.map((org) => (
|
|
242
|
-
<XStack
|
|
243
|
-
key={org.name}
|
|
244
|
-
onPress={() => select(org.name)}
|
|
245
|
-
cursor="pointer"
|
|
246
|
-
items="center"
|
|
247
|
-
gap="$2.5"
|
|
248
|
-
px="$2"
|
|
249
|
-
py="$2"
|
|
250
|
-
rounded="$3"
|
|
251
|
-
bg={org.name === currentId ? '$color4' : 'transparent'}
|
|
252
|
-
hoverStyle={{ bg: '$color5' }}
|
|
253
|
-
>
|
|
254
|
-
<OrgAvatar org={org} />
|
|
255
|
-
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
|
|
256
|
-
{org.displayName || org.name}
|
|
257
|
-
</Text>
|
|
258
|
-
{org.name === currentId ? <Check size={15} /> : null}
|
|
259
|
-
</XStack>
|
|
260
|
-
))
|
|
261
|
-
)}
|
|
262
|
-
|
|
263
|
-
{loadingMore ? (
|
|
264
|
-
<XStack items="center" gap="$2" px="$2" py="$2">
|
|
265
|
-
<Spinner size="small" color="$color11" />
|
|
266
|
-
<Text fontSize="$1" color="$color10">
|
|
267
|
-
Loading more…
|
|
268
|
-
</Text>
|
|
269
|
-
</XStack>
|
|
270
|
-
) : hasMore ? (
|
|
271
|
-
<XStack onPress={loadMore} cursor="pointer" items="center" justify="center" px="$2" py="$1.5" rounded="$3" hoverStyle={{ bg: '$color4' }}>
|
|
272
|
-
<Text fontSize="$1" color="$color11" fontWeight="600">
|
|
273
|
-
Load more
|
|
274
|
-
</Text>
|
|
275
|
-
</XStack>
|
|
276
|
-
) : null}
|
|
277
|
-
</div>
|
|
278
|
-
|
|
279
|
-
{create ? (
|
|
280
|
-
<XStack
|
|
281
|
-
onPress={() => {
|
|
282
|
-
setCreating(true)
|
|
283
|
-
setErr(null)
|
|
284
|
-
}}
|
|
285
|
-
cursor="pointer"
|
|
286
|
-
items="center"
|
|
287
|
-
gap="$2.5"
|
|
288
|
-
px="$2"
|
|
289
|
-
py="$2"
|
|
290
|
-
mt="$1"
|
|
291
|
-
rounded="$3"
|
|
292
|
-
borderTopWidth={1}
|
|
293
|
-
borderColor="$borderColor"
|
|
294
|
-
hoverStyle={{ bg: '$color5' }}
|
|
295
|
-
>
|
|
296
|
-
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
|
|
297
|
-
<Plus size={14} />
|
|
298
|
-
</YStack>
|
|
299
|
-
<Text fontSize="$2" color="$color12">
|
|
300
|
-
Create organization
|
|
301
|
-
</Text>
|
|
302
|
-
</XStack>
|
|
303
|
-
) : null}
|
|
304
|
-
|
|
305
|
-
{picker ? (
|
|
306
|
-
<XStack
|
|
307
|
-
onPress={() => {
|
|
308
|
-
setOpen(false)
|
|
309
|
-
scope.leaveOrg()
|
|
310
|
-
}}
|
|
311
|
-
cursor="pointer"
|
|
312
|
-
items="center"
|
|
313
|
-
gap="$2.5"
|
|
314
|
-
px="$2"
|
|
315
|
-
py="$2"
|
|
316
|
-
rounded="$3"
|
|
317
|
-
hoverStyle={{ bg: '$color5' }}
|
|
318
|
-
>
|
|
319
|
-
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
|
|
320
|
-
<LayoutGrid size={14} />
|
|
321
|
-
</YStack>
|
|
322
|
-
<Text fontSize="$2" color="$color12">
|
|
323
|
-
All organizations
|
|
324
|
-
</Text>
|
|
325
|
-
</XStack>
|
|
326
|
-
) : null}
|
|
327
|
-
</YStack>
|
|
328
|
-
)}
|
|
329
|
-
</Popover.Content>
|
|
330
|
-
</Popover>
|
|
331
|
-
)
|
|
332
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import type { ReactNode } from 'react'
|
|
4
|
-
import { Text, XStack, YStack } from '@hanzo/gui'
|
|
5
|
-
|
|
6
|
-
/** Section title + optional subtitle and right-aligned actions. */
|
|
7
|
-
export function PageHeader({
|
|
8
|
-
title,
|
|
9
|
-
subtitle,
|
|
10
|
-
actions,
|
|
11
|
-
}: {
|
|
12
|
-
title: string
|
|
13
|
-
subtitle?: string
|
|
14
|
-
actions?: ReactNode
|
|
15
|
-
}) {
|
|
16
|
-
return (
|
|
17
|
-
<XStack justify="space-between" items="flex-start" gap="$4">
|
|
18
|
-
<YStack gap="$1">
|
|
19
|
-
<Text fontSize="$7" fontWeight="800">
|
|
20
|
-
{title}
|
|
21
|
-
</Text>
|
|
22
|
-
{subtitle ? (
|
|
23
|
-
<Text fontSize="$3" color="$color11">
|
|
24
|
-
{subtitle}
|
|
25
|
-
</Text>
|
|
26
|
-
) : null}
|
|
27
|
-
</YStack>
|
|
28
|
-
{actions ? <XStack gap="$2">{actions}</XStack> : null}
|
|
29
|
-
</XStack>
|
|
30
|
-
)
|
|
31
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Primary button — the one white, high-emphasis action for the console.
|
|
5
|
-
*
|
|
6
|
-
* Monochrome brand: `theme="light"` flips the button to the light theme inside
|
|
7
|
-
* the dark console, giving a white fill with a near-black label and icon — no
|
|
8
|
-
* hue accent. Use it for the single primary action in a view (sign in, save,
|
|
9
|
-
* get started). Secondary and destructive actions use the default neutral
|
|
10
|
-
* `Button`.
|
|
11
|
-
*/
|
|
12
|
-
import type { ComponentProps } from 'react'
|
|
13
|
-
import { Button } from '@hanzo/gui'
|
|
14
|
-
|
|
15
|
-
export function PrimaryButton(props: ComponentProps<typeof Button>) {
|
|
16
|
-
return <Button theme="light" {...props} />
|
|
17
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ProductIcon — the Linear-style product tile: a rounded-square block filled with
|
|
5
|
-
* the product's accent color, with the product glyph knocked out in near-white so
|
|
6
|
-
* it reads "cut out" of the color. This is the ONE way a product icon renders in
|
|
7
|
-
* the console chrome (sidebar nav, command palette, product overview header) — a
|
|
8
|
-
* bare tinted glyph is replaced everywhere by this tile.
|
|
9
|
-
*
|
|
10
|
-
* Prop-driven and pure: the accent hex is resolved by the caller (registry-curated
|
|
11
|
-
* → per-user override → stable hash, via `useProductColors().colorOf`) and passed
|
|
12
|
-
* in as `color`, so this component owns only the look, never the palette. With no
|
|
13
|
-
* color it falls back to a tasteful neutral chip ($color12 fill + $color1 glyph) —
|
|
14
|
-
* never a random hue — matching the first-party `ProviderLogo` mark, so first-party
|
|
15
|
-
* marks and product tiles read as one family.
|
|
16
|
-
*
|
|
17
|
-
* The knock-out is the same technique the color-swatch button uses (a raw-hex
|
|
18
|
-
* `backgroundColor` fill + a `#ffffff` glyph) so an arbitrary product accent tints
|
|
19
|
-
* cleanly outside Gui's token palette, in the ONE sanctioned place.
|
|
20
|
-
*/
|
|
21
|
-
import { XStack } from '@hanzo/gui'
|
|
22
|
-
|
|
23
|
-
import { asColor, type IconLike } from './color'
|
|
24
|
-
|
|
25
|
-
export function ProductIcon({
|
|
26
|
-
icon: Icon,
|
|
27
|
-
color,
|
|
28
|
-
size = 24,
|
|
29
|
-
}: {
|
|
30
|
-
icon: IconLike
|
|
31
|
-
/** The product's resolved accent hex (e.g. `#5E6AD2`). Omit for the neutral chip. */
|
|
32
|
-
color?: string
|
|
33
|
-
size?: number
|
|
34
|
-
}) {
|
|
35
|
-
const radius = Math.round(size * 0.28)
|
|
36
|
-
const glyph = Math.round(size * 0.58)
|
|
37
|
-
const tinted = typeof color === 'string' && color.trim() !== ''
|
|
38
|
-
|
|
39
|
-
// Accent tile — product color fill + near-white glyph (the Linear "cut out").
|
|
40
|
-
if (tinted) {
|
|
41
|
-
return (
|
|
42
|
-
<XStack
|
|
43
|
-
width={size}
|
|
44
|
-
height={size}
|
|
45
|
-
items="center"
|
|
46
|
-
justify="center"
|
|
47
|
-
rounded={radius}
|
|
48
|
-
style={{ backgroundColor: color }}
|
|
49
|
-
>
|
|
50
|
-
<Icon size={glyph} color={asColor('#ffffff')} />
|
|
51
|
-
</XStack>
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// No accent — a tasteful neutral chip (consistent with ProviderLogo's mark).
|
|
56
|
-
return (
|
|
57
|
-
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
|
|
58
|
-
<Icon size={glyph} color="$color1" />
|
|
59
|
-
</XStack>
|
|
60
|
-
)
|
|
61
|
-
}
|