Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a660fa3f |
@@ -99,6 +99,7 @@
|
||||
"@hanzogui/label": "workspace:*",
|
||||
"@hanzogui/linear-gradient": "workspace:*",
|
||||
"@hanzogui/list-item": "workspace:*",
|
||||
"@hanzogui/lucide-icons-2": "workspace:*",
|
||||
"@hanzogui/menu": "workspace:*",
|
||||
"@hanzogui/polyfill-dev": "workspace:*",
|
||||
"@hanzogui/popover": "workspace:*",
|
||||
|
||||
@@ -60,6 +60,7 @@ export * from './views/GuiProvider'
|
||||
export * from './views/Anchor'
|
||||
export * from './views/EnsureFlexed'
|
||||
export * from './views/Fieldset'
|
||||
export * from './views/cloud'
|
||||
export * from '@hanzogui/input'
|
||||
export * from '@hanzogui/spinner'
|
||||
export * from './views/Text'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* DataTable — the shared list primitive for cloud-console modules.
|
||||
*
|
||||
* Rows are typed `<T>`; columns declare a key, header, optional fixed width, and
|
||||
* an optional cell renderer. Built on the Gui stack/text/spinner primitives
|
||||
* (shorthand style props) so it adapts across platforms. Loading and empty
|
||||
* states are first-class.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Spinner } from '@hanzogui/spinner'
|
||||
import { XStack, YStack } from '@hanzogui/stacks'
|
||||
import { Text } from '../Text'
|
||||
|
||||
export type Column<T> = {
|
||||
key: string
|
||||
header: string
|
||||
/** Cell renderer; defaults to `String(row[key])`. */
|
||||
render?: (row: T) => ReactNode
|
||||
/** Fixed width (px) for the column; otherwise flexes. */
|
||||
width?: number
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
empty = 'Nothing here yet.',
|
||||
rowKey,
|
||||
onRowPress,
|
||||
}: {
|
||||
columns: Column<T>[]
|
||||
rows: T[]
|
||||
loading?: boolean
|
||||
empty?: string
|
||||
rowKey: (row: T) => string
|
||||
onRowPress?: (row: T) => void
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
|
||||
<Text color="$color11">{empty}</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
||||
<XStack bg="$color2" py="$2.5" px="$3" gap="$3">
|
||||
{columns.map((c) => (
|
||||
<Text
|
||||
key={c.key}
|
||||
width={c.width}
|
||||
flex={c.width ? undefined : 1}
|
||||
fontSize="$2"
|
||||
fontWeight="700"
|
||||
color="$color11"
|
||||
>
|
||||
{c.header}
|
||||
</Text>
|
||||
))}
|
||||
</XStack>
|
||||
<YStack>
|
||||
{rows.map((row) => (
|
||||
<XStack
|
||||
key={rowKey(row)}
|
||||
py="$2.5"
|
||||
px="$3"
|
||||
gap="$3"
|
||||
borderTopWidth={1}
|
||||
borderColor="$borderColor"
|
||||
items="center"
|
||||
hoverStyle={onRowPress ? { bg: '$color2' } : undefined}
|
||||
cursor={onRowPress ? 'pointer' : undefined}
|
||||
onPress={onRowPress ? () => onRowPress(row) : undefined}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<YStack key={c.key} width={c.width} flex={c.width ? undefined : 1}>
|
||||
{c.render ? (
|
||||
c.render(row)
|
||||
) : (
|
||||
<Text fontSize="$3" numberOfLines={1}>
|
||||
{String((row as Record<string, unknown>)[c.key] ?? '')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Field primitives — labeled rows over the Gui input controls.
|
||||
*
|
||||
* One way to render a labeled control. Every editor field goes through these, so
|
||||
* spacing, disabled styling, and the label column stay consistent across a
|
||||
* cloud-console form. Style props use the v5 shorthand set (p/px/items/…).
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Input, TextArea } from '@hanzogui/input'
|
||||
import { Label } from '@hanzogui/label'
|
||||
import { Select } from '@hanzogui/select'
|
||||
import { Switch } from '@hanzogui/switch'
|
||||
import { Slider } from '@hanzogui/slider'
|
||||
import { XStack, YStack } from '@hanzogui/stacks'
|
||||
import { ChevronDown } from '@hanzogui/lucide-icons-2'
|
||||
import { Text } from '../Text'
|
||||
|
||||
/** Labeled row: fixed label column + flexing control. */
|
||||
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<XStack gap="$3" items="flex-start" flexWrap="wrap">
|
||||
<Label width={180} pt="$2" color="$color11" fontSize="$3">
|
||||
{label}
|
||||
</Label>
|
||||
<YStack flex={1} minW={240}>
|
||||
{children}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldText({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
secure,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
secure?: boolean
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
disabled={disabled}
|
||||
secureTextEntry={secure}
|
||||
placeholder={placeholder}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldTextArea({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
rows = 6,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
rows?: number
|
||||
}) {
|
||||
return (
|
||||
<TextArea value={value} onChangeText={onChange} disabled={disabled} numberOfLines={rows} />
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Switch checked={checked} onCheckedChange={onChange} disabled={disabled} size="$3">
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: string
|
||||
options: string[]
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange} disablePreventBodyScroll native>
|
||||
<Select.Trigger disabled={disabled} iconAfter={<ChevronDown size={16} />}>
|
||||
<Select.Value placeholder="Select…" />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Viewport>
|
||||
{options.map((opt, i) => (
|
||||
<Select.Item key={opt} index={i} value={opt}>
|
||||
<Select.ItemText>{opt}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
onChange: (v: number) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<XStack gap="$3" items="center">
|
||||
<Text width={56} fontSize="$3" color="$color11">
|
||||
{value}
|
||||
</Text>
|
||||
<Slider
|
||||
flex={1}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(v) => onChange(v[0] ?? min)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Slider.Track>
|
||||
<Slider.TrackActive />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb index={0} circular />
|
||||
</Slider>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Loader — a centered loading state with an optional label.
|
||||
*
|
||||
* Brand-agnostic on purpose: the shared Gui is white-labeled across brands, so
|
||||
* this uses the neutral Gui Spinner (not a brand mark). Apps that want a branded
|
||||
* full-screen loader compose their own mark over this layout.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { Spinner } from '@hanzogui/spinner'
|
||||
import { YStack } from '@hanzogui/stacks'
|
||||
import { Text } from '../Text'
|
||||
|
||||
export function Loader({
|
||||
label,
|
||||
size = 'large',
|
||||
}: {
|
||||
label?: ReactNode
|
||||
size?: 'small' | 'large'
|
||||
}) {
|
||||
return (
|
||||
<YStack flex={1} minH={240} items="center" justify="center" gap="$3">
|
||||
<Spinner size={size} color="$color11" />
|
||||
{label ? (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* PageHeader — section title + optional subtitle and right-aligned actions.
|
||||
*
|
||||
* A cloud-console layout primitive: the heading row every admin/data surface
|
||||
* opens with. Built on the Gui stack + text primitives (shorthand style props),
|
||||
* so it adapts across web and native and inherits the active theme.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { XStack, YStack } from '@hanzogui/stacks'
|
||||
import { Text } from '../Text'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<XStack justify="space-between" items="flex-start" gap="$4">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$7" fontWeight="800">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{actions ? <XStack gap="$2">{actions}</XStack> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Honest async states — one truthful way to explain a failed/empty load.
|
||||
*
|
||||
* A `/v1`-style endpoint can 404 (not routed on this deployment), 401/403
|
||||
* (access enforced server-side), or 503 (backend starting). `honestError` maps a
|
||||
* structural error (`{ status?, message }`) to a specific, truthful title+body —
|
||||
* never a generic crash, never fabricated data — and `ErrorState` renders it with
|
||||
* an optional retry. Decoupled from any HTTP client: pass any error with a
|
||||
* numeric `status` and a `message`.
|
||||
*/
|
||||
import { Button } from '@hanzogui/button'
|
||||
import { Card } from '@hanzogui/card'
|
||||
import { XStack } from '@hanzogui/stacks'
|
||||
import { TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
import { Text } from '../Text'
|
||||
|
||||
/** Surface-specific overrides for the 404/unauthorized explanations. */
|
||||
export type HonestCopy = { notFound?: string; unauthorized?: string }
|
||||
|
||||
/** A structural error: an HTTP-ish status plus a message. No client coupling. */
|
||||
export type HonestErrorLike = { status?: number; message: string }
|
||||
|
||||
/** Map a structural error to an honest title + body. Defaults are truthful. */
|
||||
export function honestError(
|
||||
err: HonestErrorLike,
|
||||
copy: HonestCopy = {},
|
||||
): { title: string; body: string } {
|
||||
if (err.status === 404)
|
||||
return {
|
||||
title: 'Not available on this deployment',
|
||||
body:
|
||||
copy.notFound ??
|
||||
'This API is not routed on this host yet. It appears automatically once the deployment proxies it through the gateway.',
|
||||
}
|
||||
if (err.status === 503)
|
||||
return {
|
||||
title: 'Service unavailable',
|
||||
body: 'The service is starting up or temporarily unavailable. Retry in a moment.',
|
||||
}
|
||||
if (err.status === 401 || err.status === 403 || /sign ?in|login|unauthorized/i.test(err.message))
|
||||
return {
|
||||
title: 'Access required',
|
||||
body:
|
||||
copy.unauthorized ??
|
||||
'This view requires an authorized session, enforced server-side. Sign in with an account that has access.',
|
||||
}
|
||||
return { title: 'Could not load', body: err.message }
|
||||
}
|
||||
|
||||
/** The honest error card — title, explanation, and an optional retry. */
|
||||
export function ErrorState({
|
||||
err,
|
||||
onRetry,
|
||||
copy,
|
||||
}: {
|
||||
err: HonestErrorLike
|
||||
onRetry?: () => void
|
||||
copy?: HonestCopy
|
||||
}) {
|
||||
const { title, body } = honestError(err, copy)
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$2" maxWidth={620}>
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
{title}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{body}
|
||||
</Text>
|
||||
{onRetry ? (
|
||||
<Button size="$2" self="flex-start" onPress={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* StatusTag — maps a resource/cluster lifecycle string to a tone, in one place.
|
||||
*
|
||||
* Provisioned things report free-form lifecycle strings (and platform health
|
||||
* verdicts report green/yellow/red directly); this normalizes them to a single
|
||||
* green/yellow/red/neutral palette so every list reads the same.
|
||||
*/
|
||||
import { Text } from '../Text'
|
||||
|
||||
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
|
||||
|
||||
const toneOf = (status: string): Tone => {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'green') return 'green'
|
||||
if (s === 'yellow') return 'yellow'
|
||||
if (s === 'red') return 'red'
|
||||
if (['ready', 'active', 'running', 'available', 'ok'].includes(s)) return 'green'
|
||||
if (['creating', 'provisioning', 'pending', 'updating', 'attaching'].includes(s)) return 'yellow'
|
||||
if (['error', 'failed', 'degraded', 'down'].includes(s)) return 'red'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
// `as const` keeps the literal token types (not widened to `string`) so they
|
||||
// satisfy the Gui `bg`/`color` token unions.
|
||||
const TONE_BG = {
|
||||
green: '$color5',
|
||||
yellow: '$color4',
|
||||
red: '$color4',
|
||||
neutral: '$color3',
|
||||
} as const
|
||||
const TONE_FG = {
|
||||
green: '$color12',
|
||||
yellow: '$color12',
|
||||
red: '$color12',
|
||||
neutral: '$color11',
|
||||
} as const
|
||||
|
||||
export function StatusTag({ status }: { status?: string }) {
|
||||
const tone = toneOf(status ?? '')
|
||||
return (
|
||||
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
|
||||
{status || 'unknown'}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Cloud-console primitives — shared, brand-agnostic building blocks for
|
||||
* Hanzo-style admin/data UIs (console2 and any cloud surface). Generic by
|
||||
* construction: no app/client coupling. Composed on the Gui sibling primitives.
|
||||
*/
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { StatusTag } from './StatusTag'
|
||||
export { Loader } from './Loader'
|
||||
export { DataTable, type Column } from './DataTable'
|
||||
export {
|
||||
FieldRow,
|
||||
FieldText,
|
||||
FieldTextArea,
|
||||
FieldSwitch,
|
||||
FieldSelect,
|
||||
FieldSlider,
|
||||
} from './Field'
|
||||
export {
|
||||
ErrorState,
|
||||
honestError,
|
||||
type HonestCopy,
|
||||
type HonestErrorLike,
|
||||
} from './States'
|
||||
Reference in New Issue
Block a user