@hanzo/ui 8.0.2 → 8.0.4
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/package.json +63 -4
- package/src/billing.ts +9 -0
- package/src/canvas.ts +11 -0
- package/src/cd/layout.ts +184 -0
- package/src/cd/status.ts +158 -0
- package/src/cd/time.ts +52 -0
- package/src/cd/types.ts +318 -0
- package/src/dashboard.ts +8 -0
- package/src/gitops.ts +5 -0
- package/src/network.ts +11 -0
- package/src/product/AppHeader.tsx +175 -0
- package/src/product/BrandMark.tsx +27 -0
- package/src/product/HanzoMark.tsx +7 -4
- package/src/product/OrgSwitcher.tsx +332 -0
- package/src/product/index.ts +10 -5
- package/src/product/scope.test.ts +92 -0
- package/src/product/scope.ts +123 -0
- package/src/usage.ts +8 -0
- package/src/wallet.ts +11 -0
- package/types/billing.d.ts +2 -0
- package/types/billing.d.ts.map +1 -0
- package/types/canvas.d.ts +2 -0
- package/types/canvas.d.ts.map +1 -0
- package/types/cd/layout.d.ts +57 -0
- package/types/cd/layout.d.ts.map +1 -0
- package/types/cd/status.d.ts +50 -0
- package/types/cd/status.d.ts.map +1 -0
- package/types/cd/time.d.ts +15 -0
- package/types/cd/time.d.ts.map +1 -0
- package/types/cd/types.d.ts +269 -0
- package/types/cd/types.d.ts.map +1 -0
- package/types/dashboard.d.ts +2 -0
- package/types/dashboard.d.ts.map +1 -0
- package/types/gitops.d.ts +2 -0
- package/types/gitops.d.ts.map +1 -0
- package/types/network.d.ts +2 -0
- package/types/network.d.ts.map +1 -0
- package/types/product/AppHeader.d.ts +49 -0
- package/types/product/AppHeader.d.ts.map +1 -0
- package/types/product/BrandMark.d.ts +8 -0
- package/types/product/BrandMark.d.ts.map +1 -0
- package/types/product/Charts.d.ts +5 -5
- package/types/product/Charts.d.ts.map +1 -1
- package/types/product/ComboBox.d.ts +1 -1
- package/types/product/ComboBox.d.ts.map +1 -1
- package/types/product/DataTable.d.ts +1 -1
- package/types/product/DataTable.d.ts.map +1 -1
- package/types/product/Donut.d.ts +1 -1
- package/types/product/Donut.d.ts.map +1 -1
- package/types/product/EmptyState.d.ts +1 -1
- package/types/product/EmptyState.d.ts.map +1 -1
- package/types/product/FadeIn.d.ts +1 -1
- package/types/product/FadeIn.d.ts.map +1 -1
- package/types/product/Field.d.ts +6 -6
- package/types/product/Field.d.ts.map +1 -1
- package/types/product/HanzoMark.d.ts +6 -5
- package/types/product/HanzoMark.d.ts.map +1 -1
- package/types/product/Metric.d.ts +4 -4
- package/types/product/Metric.d.ts.map +1 -1
- package/types/product/OrgSwitcher.d.ts +19 -0
- package/types/product/OrgSwitcher.d.ts.map +1 -0
- package/types/product/PageHeader.d.ts +1 -1
- package/types/product/PageHeader.d.ts.map +1 -1
- package/types/product/PrimaryButton.d.ts +1 -1
- package/types/product/PrimaryButton.d.ts.map +1 -1
- package/types/product/ProductIcon.d.ts +1 -1
- package/types/product/ProductIcon.d.ts.map +1 -1
- package/types/product/ProviderLogo.d.ts +2 -2
- package/types/product/ProviderLogo.d.ts.map +1 -1
- package/types/product/Reorder.d.ts +1 -1
- package/types/product/Reorder.d.ts.map +1 -1
- package/types/product/SelectMenu.d.ts +1 -1
- package/types/product/SelectMenu.d.ts.map +1 -1
- package/types/product/SlideOver.d.ts +1 -1
- package/types/product/SlideOver.d.ts.map +1 -1
- package/types/product/StatusTag.d.ts +1 -1
- package/types/product/StatusTag.d.ts.map +1 -1
- package/types/product/ThemeToggle.d.ts +1 -1
- package/types/product/ThemeToggle.d.ts.map +1 -1
- package/types/product/Toast.d.ts +1 -1
- package/types/product/Toast.d.ts.map +1 -1
- package/types/product/index.d.ts +4 -2
- package/types/product/index.d.ts.map +1 -1
- package/types/product/scope.d.ts +56 -0
- package/types/product/scope.d.ts.map +1 -0
- package/types/usage.d.ts +2 -0
- package/types/usage.d.ts.map +1 -0
- package/types/wallet.d.ts +2 -0
- package/types/wallet.d.ts.map +1 -0
- package/src/product/social/CampaignCard.tsx +0 -62
- package/src/product/social/ChannelBadge.tsx +0 -60
- package/src/product/social/PostCard.tsx +0 -80
- package/src/product/social/index.ts +0 -6
- package/src/product/usage/UsageDashboard.tsx +0 -54
- package/src/product/usage/UsageMeter.tsx +0 -75
- package/src/product/usage/UsageProviderCard.tsx +0 -153
- package/src/product/usage/index.ts +0 -10
- package/types/product/social/CampaignCard.d.ts +0 -14
- package/types/product/social/CampaignCard.d.ts.map +0 -1
- package/types/product/social/ChannelBadge.d.ts +0 -7
- package/types/product/social/ChannelBadge.d.ts.map +0 -1
- package/types/product/social/PostCard.d.ts +0 -16
- package/types/product/social/PostCard.d.ts.map +0 -1
- package/types/product/social/index.d.ts +0 -4
- package/types/product/social/index.d.ts.map +0 -1
- package/types/product/usage/UsageDashboard.d.ts +0 -10
- package/types/product/usage/UsageDashboard.d.ts.map +0 -1
- package/types/product/usage/UsageMeter.d.ts +0 -15
- package/types/product/usage/UsageMeter.d.ts.map +0 -1
- package/types/product/usage/UsageProviderCard.d.ts +0 -35
- package/types/product/usage/UsageProviderCard.d.ts.map +0 -1
- package/types/product/usage/index.d.ts +0 -4
- package/types/product/usage/index.d.ts.map +0 -1
|
@@ -0,0 +1,332 @@
|
|
|
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
|
+
}
|
package/src/product/index.ts
CHANGED
|
@@ -38,6 +38,13 @@ export { Donut as DonutRing, type DonutSegment } from './Donut'
|
|
|
38
38
|
export { ComboBox } from './ComboBox'
|
|
39
39
|
export * from './combobox/filter'
|
|
40
40
|
|
|
41
|
+
// The shared shell — brand mark, org scope + switcher, app header (the
|
|
42
|
+
// console's org-scope contract + switcher hoisted here; hanzoai/ui#36).
|
|
43
|
+
export * from './AppHeader'
|
|
44
|
+
export * from './BrandMark'
|
|
45
|
+
export * from './OrgSwitcher'
|
|
46
|
+
export * from './scope'
|
|
47
|
+
|
|
41
48
|
// The rest — every exported name below is unique across the layer.
|
|
42
49
|
export * from './DataTable'
|
|
43
50
|
export * from './EmptyState'
|
|
@@ -56,8 +63,6 @@ export * from './ThemeToggle'
|
|
|
56
63
|
export * from './Toast'
|
|
57
64
|
export * from './color'
|
|
58
65
|
|
|
59
|
-
// Usage — the
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// Social — ChannelBadge, PostCard, CampaignCard (extracted from Hanzo Social).
|
|
63
|
-
export * from './social'
|
|
66
|
+
// Usage — the canonical AI-usage surface (totals, chart, spend-by-model, activity)
|
|
67
|
+
// now lives in ONE home: `@hanzo/usage/panel` (<UsagePanel>). The former meter/card/
|
|
68
|
+
// dashboard kit here was orphaned (zero consumers) and folded into it.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { filterOrgs, orgScope } from './scope'
|
|
4
|
+
|
|
5
|
+
/** In-memory Storage double. */
|
|
6
|
+
function memory(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
|
|
7
|
+
const m = new Map<string, string>()
|
|
8
|
+
return {
|
|
9
|
+
getItem: (k) => m.get(k) ?? null,
|
|
10
|
+
setItem: (k, v) => void m.set(k, v),
|
|
11
|
+
removeItem: (k) => void m.delete(k),
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function harness() {
|
|
16
|
+
const calls: string[] = []
|
|
17
|
+
const scope = orgScope({
|
|
18
|
+
brandOrg: 'hanzo',
|
|
19
|
+
storage: memory(),
|
|
20
|
+
navigate: (path) => calls.push(`nav:${path}`),
|
|
21
|
+
reload: () => calls.push('reload'),
|
|
22
|
+
})
|
|
23
|
+
return { scope, calls }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('orgScope', () => {
|
|
27
|
+
it('defaults to the brand org, un-selected', () => {
|
|
28
|
+
const { scope } = harness()
|
|
29
|
+
expect(scope.currentOrg()).toBe('hanzo')
|
|
30
|
+
expect(scope.isScopedAway()).toBe(false)
|
|
31
|
+
expect(scope.hasSelectedOrg()).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('setCurrentOrg persists; the brand org clears the override', () => {
|
|
35
|
+
const { scope } = harness()
|
|
36
|
+
scope.setCurrentOrg('acme')
|
|
37
|
+
expect(scope.currentOrg()).toBe('acme')
|
|
38
|
+
expect(scope.isScopedAway()).toBe(true)
|
|
39
|
+
scope.setCurrentOrg('hanzo')
|
|
40
|
+
expect(scope.currentOrg()).toBe('hanzo')
|
|
41
|
+
expect(scope.isScopedAway()).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('enterOrg selects + scopes + navigates home', () => {
|
|
45
|
+
const { scope, calls } = harness()
|
|
46
|
+
scope.enterOrg('acme')
|
|
47
|
+
expect(scope.currentOrg()).toBe('acme')
|
|
48
|
+
expect(scope.hasSelectedOrg()).toBe(true)
|
|
49
|
+
expect(calls).toEqual(['nav:/'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('leaveOrg de-selects, resets to the brand org, navigates home', () => {
|
|
53
|
+
const { scope, calls } = harness()
|
|
54
|
+
scope.enterOrg('acme')
|
|
55
|
+
scope.leaveOrg()
|
|
56
|
+
expect(scope.currentOrg()).toBe('hanzo')
|
|
57
|
+
expect(scope.hasSelectedOrg()).toBe(false)
|
|
58
|
+
expect(calls).toEqual(['nav:/', 'nav:/'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('switchOrg persists, keeps the selection, reloads; same org is a no-op', () => {
|
|
62
|
+
const { scope, calls } = harness()
|
|
63
|
+
scope.enterOrg('acme')
|
|
64
|
+
scope.switchOrg('zoo')
|
|
65
|
+
expect(scope.currentOrg()).toBe('zoo')
|
|
66
|
+
expect(scope.hasSelectedOrg()).toBe(true)
|
|
67
|
+
expect(calls).toEqual(['nav:/', 'reload'])
|
|
68
|
+
scope.switchOrg('zoo')
|
|
69
|
+
expect(calls).toEqual(['nav:/', 'reload'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('two scopes with different keys never collide', () => {
|
|
73
|
+
const store = memory()
|
|
74
|
+
const a = orgScope({ brandOrg: 'hanzo', key: 'a', storage: store })
|
|
75
|
+
const b = orgScope({ brandOrg: 'hanzo', key: 'b', storage: store })
|
|
76
|
+
a.setCurrentOrg('acme')
|
|
77
|
+
expect(b.currentOrg()).toBe('hanzo')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('filterOrgs', () => {
|
|
82
|
+
const orgs = [
|
|
83
|
+
{ name: 'acme', displayName: 'Acme Corp' },
|
|
84
|
+
{ name: 'zoo', displayName: 'Zoo Labs' },
|
|
85
|
+
]
|
|
86
|
+
it('matches name and displayName, case-insensitive; blank returns all', () => {
|
|
87
|
+
expect(filterOrgs(orgs, '')).toHaveLength(2)
|
|
88
|
+
expect(filterOrgs(orgs, 'ACME')).toEqual([orgs[0]])
|
|
89
|
+
expect(filterOrgs(orgs, 'labs')).toEqual([orgs[1]])
|
|
90
|
+
expect(filterOrgs(orgs, 'nope')).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orgScope — the active-organization contract every Hanzo surface shares
|
|
3
|
+
* (hoisted from the console; see hanzoai/ui#36).
|
|
4
|
+
*
|
|
5
|
+
* Brand identity (which IAM you log into, the wordmark + logo) is fixed per
|
|
6
|
+
* host; the DATA scope — which org's identity, secrets and resources the app
|
|
7
|
+
* acts on — is orthogonal. This module owns only that switch. Every
|
|
8
|
+
* tenant-scoped call reads `currentOrg()` and stamps it as `X-Org-Id`.
|
|
9
|
+
*
|
|
10
|
+
* Two-level model: the org VALUE (which org) and the SELECTION (has one been
|
|
11
|
+
* chosen yet) are orthogonal. A fresh login is org-LESS: `hasSelectedOrg()` is
|
|
12
|
+
* false so the host can show its org picker even for a one-org user. Entering
|
|
13
|
+
* (`enterOrg`) sets value + flag and navigates home; leaving (`leaveOrg`)
|
|
14
|
+
* clears the flag back to the picker; the in-shell quick switch (`switchOrg`)
|
|
15
|
+
* persists and reloads so every module refetches under the new `X-Org-Id`.
|
|
16
|
+
*
|
|
17
|
+
* Host-agnostic: storage + navigation are injected (browser defaults), so the
|
|
18
|
+
* same contract runs in Next, Vite, Svelte or a test.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type Org = { name: string; displayName?: string; logo?: string }
|
|
22
|
+
|
|
23
|
+
export type OrgScopeConfig = {
|
|
24
|
+
/** The brand's own org — the default scope (e.g. `config.iamOrgName`). */
|
|
25
|
+
brandOrg: string
|
|
26
|
+
/** Storage key namespace — ONE per surface. Default `'hanzo.org'`. */
|
|
27
|
+
key?: string
|
|
28
|
+
/** Storage — defaults to `window.localStorage`, null-safe on SSR/blocked. */
|
|
29
|
+
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
30
|
+
/** Navigate to a path (enter/leave land on home). Default `location.assign`. */
|
|
31
|
+
navigate?: (path: string) => void
|
|
32
|
+
/** Hard reload (quick-switch refetches everything). Default `location.reload`. */
|
|
33
|
+
reload?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type OrgScope = {
|
|
37
|
+
/** The org the surface is currently scoped to (default: the brand org). */
|
|
38
|
+
currentOrg(): string
|
|
39
|
+
/** Switch the active scope. Passing the brand org clears the override. */
|
|
40
|
+
setCurrentOrg(org: string): void
|
|
41
|
+
/** True when scoped to a non-default (switched) org. */
|
|
42
|
+
isScopedAway(): boolean
|
|
43
|
+
/** True once an org has been explicitly ENTERED (picker → shell). */
|
|
44
|
+
hasSelectedOrg(): boolean
|
|
45
|
+
/** Enter an org from the picker: scope + select + navigate home. */
|
|
46
|
+
enterOrg(org: string): void
|
|
47
|
+
/** Leave the org (back to the picker): de-select + reset + navigate home. */
|
|
48
|
+
leaveOrg(): void
|
|
49
|
+
/** In-shell quick switch: persist + keep selection + reload. Same org: no-op. */
|
|
50
|
+
switchOrg(org: string): void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function browserStorage(): Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null {
|
|
54
|
+
if (typeof window === 'undefined') return null
|
|
55
|
+
try {
|
|
56
|
+
return window.localStorage
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function orgScope(config: OrgScopeConfig): OrgScope {
|
|
63
|
+
const key = config.key ?? 'hanzo.org'
|
|
64
|
+
const selectedKey = `${key}.selected`
|
|
65
|
+
const store = () => config.storage ?? browserStorage()
|
|
66
|
+
const read = (k: string): string | null => {
|
|
67
|
+
try {
|
|
68
|
+
return store()?.getItem(k) ?? null
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const write = (k: string, v: string | null): void => {
|
|
74
|
+
try {
|
|
75
|
+
if (v === null) store()?.removeItem(k)
|
|
76
|
+
else store()?.setItem(k, v)
|
|
77
|
+
} catch {
|
|
78
|
+
// Storage blocked — the scope simply stays at the brand org.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const goHome = () => {
|
|
82
|
+
if (config.navigate) return config.navigate('/')
|
|
83
|
+
if (typeof window !== 'undefined') window.location.assign('/')
|
|
84
|
+
}
|
|
85
|
+
const doReload = () => {
|
|
86
|
+
if (config.reload) return config.reload()
|
|
87
|
+
if (typeof window !== 'undefined') window.location.reload()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const scope: OrgScope = {
|
|
91
|
+
currentOrg: () => read(key) || config.brandOrg,
|
|
92
|
+
setCurrentOrg: (org) => write(key, !org || org === config.brandOrg ? null : org),
|
|
93
|
+
isScopedAway: () => scope.currentOrg() !== config.brandOrg,
|
|
94
|
+
hasSelectedOrg: () => read(selectedKey) === '1',
|
|
95
|
+
enterOrg: (org) => {
|
|
96
|
+
if (!org) return
|
|
97
|
+
scope.setCurrentOrg(org)
|
|
98
|
+
write(selectedKey, '1')
|
|
99
|
+
goHome()
|
|
100
|
+
},
|
|
101
|
+
leaveOrg: () => {
|
|
102
|
+
write(selectedKey, null)
|
|
103
|
+
scope.setCurrentOrg(config.brandOrg)
|
|
104
|
+
goHome()
|
|
105
|
+
},
|
|
106
|
+
switchOrg: (org) => {
|
|
107
|
+
if (!org || org === scope.currentOrg()) return
|
|
108
|
+
scope.setCurrentOrg(org)
|
|
109
|
+
write(selectedKey, '1')
|
|
110
|
+
doReload()
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
return scope
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Case-insensitive filter over org name + display name (the switcher search). */
|
|
117
|
+
export function filterOrgs<T extends Org>(orgs: T[], query: string): T[] {
|
|
118
|
+
const q = query.trim().toLowerCase()
|
|
119
|
+
if (!q) return orgs
|
|
120
|
+
return orgs.filter(
|
|
121
|
+
(o) => o.name.toLowerCase().includes(q) || (o.displayName ?? '').toLowerCase().includes(q),
|
|
122
|
+
)
|
|
123
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @hanzo/ui/usage — the shared AI-usage surface (the meter bar → provider card →
|
|
2
|
+
// dashboard progression), re-exported from its home package @hanzo/usage. Thin
|
|
3
|
+
// subpath so every console shows the same quota UI:
|
|
4
|
+
//
|
|
5
|
+
// import { UsageMeter, UsageProviderCard, UsageDashboard } from '@hanzo/ui/usage'
|
|
6
|
+
//
|
|
7
|
+
// Optional peer — only pulled when the subpath is used.
|
|
8
|
+
export * from '@hanzo/usage'
|
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @hanzo/ui/wallet — the ONE wallet surface, re-exported from its home module
|
|
2
|
+
// @hanzo/ui-shadcn/wallet. `WalletMenu` is presentation only (address, PQ
|
|
3
|
+
// identity, selected-network alignment, explorer link, connect/disconnect);
|
|
4
|
+
// custody lives in the injected `WalletAdapter` (desktop wallet, web EIP-1193).
|
|
5
|
+
// Thin subpath so every console imports the same wallet surface:
|
|
6
|
+
//
|
|
7
|
+
// import { WalletMenu, injectedEvmAdapter, walletAvailable, ensureEvmNetwork,
|
|
8
|
+
// shortAddress, type WalletAdapter } from '@hanzo/ui/wallet'
|
|
9
|
+
//
|
|
10
|
+
// Optional peer — only pulled when the subpath is used.
|
|
11
|
+
export * from '@hanzo/ui-shadcn/wallet'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"billing.d.ts","sourceRoot":"","sources":["../src/billing.ts"],"names":[],"mappings":"AAQA,cAAc,0BAA0B,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.d.ts","sourceRoot":"","sources":["../src/canvas.ts"],"names":[],"mappings":"AAUA,cAAc,eAAe,CAAA"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic layered graph layout — pure, no React, no DOM, unit-tested. This
|
|
3
|
+
* is the dependency-free equivalent of the GitOps engine UI's dagre `rankdir:LR`
|
|
4
|
+
* pass: resources flow left→right by ownership depth, each tier a clean column.
|
|
5
|
+
*
|
|
6
|
+
* Three folds:
|
|
7
|
+
* 1. columns — longest-path layering (Kahn topological order). A root (no
|
|
8
|
+
* incoming edge) sits in column 0; every other node one column right of its
|
|
9
|
+
* deepest parent. Cycles degrade gracefully (a child keeps its best column),
|
|
10
|
+
* so a malformed graph can never wedge the layout.
|
|
11
|
+
* 2. order — within a column, nodes are ordered by the barycentre of their
|
|
12
|
+
* parents' rows (then name), so children cluster under their owner and edge
|
|
13
|
+
* crossings stay low — dagre's crossing-reduction, cheaply.
|
|
14
|
+
* 3. place — nodes stack top-to-bottom at their own height (pods are taller
|
|
15
|
+
* than controllers) with a fixed gap, and each column is vertically centred
|
|
16
|
+
* on a shared axis so tiers read as balanced columns.
|
|
17
|
+
*
|
|
18
|
+
* The same graph always yields byte-identical positions.
|
|
19
|
+
*/
|
|
20
|
+
export interface LayoutNode {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Rendered height in px (pods are taller than controllers). */
|
|
23
|
+
h: number;
|
|
24
|
+
}
|
|
25
|
+
export interface LayoutEdge {
|
|
26
|
+
source: string;
|
|
27
|
+
target: string;
|
|
28
|
+
}
|
|
29
|
+
export interface LayoutOptions {
|
|
30
|
+
/** Uniform node width in px (x is placed on a fixed column pitch). */
|
|
31
|
+
nodeWidth?: number;
|
|
32
|
+
/** Horizontal gap between a column's right edge and the next column. */
|
|
33
|
+
columnGap?: number;
|
|
34
|
+
/** Vertical gap between stacked nodes in a column. */
|
|
35
|
+
rowGap?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface PositionedNode {
|
|
38
|
+
id: string;
|
|
39
|
+
/** Top-left corner, px. */
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
w: number;
|
|
43
|
+
h: number;
|
|
44
|
+
col: number;
|
|
45
|
+
}
|
|
46
|
+
export interface GraphLayout {
|
|
47
|
+
nodes: PositionedNode[];
|
|
48
|
+
/** Content bounds — the transformed layer's intrinsic size. */
|
|
49
|
+
width: number;
|
|
50
|
+
height: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Lay a resource graph out into centred LR columns. Returns one positioned node
|
|
54
|
+
* per input node (input order preserved in the result array) plus content bounds.
|
|
55
|
+
*/
|
|
56
|
+
export declare function layoutTree(nodes: LayoutNode[], edges: LayoutEdge[], opts?: LayoutOptions): GraphLayout;
|
|
57
|
+
//# sourceMappingURL=layout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../../src/cd/layout.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAA;IACV,gEAAgE;IAChE,CAAC,EAAE,MAAM,CAAA;CACV;AAED,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAA;IACV,2BAA2B;IAC3B,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AA6CD;;;GAGG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,UAAU,EAAE,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,GAAE,aAAkB,GACvB,WAAW,CA2Eb"}
|