@hanzo/dashboard 0.2.0 → 0.2.1

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.
@@ -0,0 +1,178 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Pipeline — the animated deploy-stage line. A horizontal track with N stages:
5
+ * completed stages fill in with a check, the current stage pulses, the active leg
6
+ * shows a flowing gradient, and a failure turns the reached stage red. Purely
7
+ * presentational — driven by a `stages` prop (each `{ name, state }`); the consumer
8
+ * derives those from a real status string with the pure `pipelineModel` and polls
9
+ * to pass fresh stages. Reduced motion → a static, filled line.
10
+ *
11
+ * All geometry derives from the stage states, so it renders whatever pipeline the
12
+ * caller supplies (the default four-stage deploy line, or a custom set).
13
+ */
14
+ import { useContainerWidth } from '../charts/Charts'
15
+ import { useReducedMotion } from '../motion/hooks'
16
+ import type { PipelineStage, StageState } from './stages'
17
+ import { Text, XStack, YStack } from '@hanzo/gui'
18
+
19
+ const ACCENT = '#7c5cff'
20
+ const GREEN = '#23c562'
21
+ const RED = '#ff5d8f'
22
+ const MUTED = 'rgba(148,163,184,0.28)'
23
+
24
+ export interface PipelineProps {
25
+ /** The stages to render, in order (derive via `pipelineModel(status).stages`). */
26
+ stages: PipelineStage[]
27
+ /** A short status label shown above the track (e.g. `pipelineModel(...).label`). */
28
+ label?: string
29
+ /** Accent tone for the filled track + active stage. Defaults by state (green live / red error / purple). */
30
+ tone?: string
31
+ /** Animate the active leg + pulse. Default true; reduced motion still forces static. */
32
+ animate?: boolean
33
+ }
34
+
35
+ export function Pipeline({ stages, label, tone, animate = true }: PipelineProps) {
36
+ const reduced = useReducedMotion()
37
+
38
+ const n = stages.length
39
+ const errorIdx = stages.findIndex((s) => s.state === 'error')
40
+ const activeStateIdx = stages.findIndex((s) => s.state === 'active')
41
+ const doneCount = stages.filter((s) => s.state === 'done').length
42
+ const errored = errorIdx >= 0
43
+ const live = n > 0 && doneCount === n && !errored
44
+ const activeIndex = errored ? errorIdx : activeStateIdx >= 0 ? activeStateIdx : live ? n - 1 : -1
45
+ const inProgress = activeStateIdx >= 0 && !errored && !live
46
+
47
+ const accent = tone ?? (errored ? RED : live ? GREEN : ACCENT)
48
+
49
+ return (
50
+ <YStack gap="$3" aria-label={label ? `Deployment: ${label}` : 'Deployment pipeline'}>
51
+ {label ? (
52
+ <XStack items="center" gap="$2">
53
+ <YStack width={9} height={9} rounded="$10" style={{ backgroundColor: accent }} className={animate && !reduced && inProgress ? 'hz-pipe-dot' : undefined} />
54
+ <Text fontSize="$3" fontWeight="700" color="$color12">
55
+ {label}
56
+ </Text>
57
+ </XStack>
58
+ ) : null}
59
+ <Track stages={stages} activeIndex={activeIndex} inProgress={inProgress} reduced={reduced || !animate} tone={accent} />
60
+ </YStack>
61
+ )
62
+ }
63
+
64
+ // ── The SVG track + aligned labels ───────────────────────────────────────────
65
+
66
+ function Track({ stages, activeIndex, inProgress, reduced, tone }: { stages: PipelineStage[]; activeIndex: number; inProgress: boolean; reduced: boolean; tone: string }) {
67
+ const { ref, w } = useContainerWidth()
68
+ const n = stages.length
69
+ const H = 60
70
+ const padX = 24
71
+ const trackY = 28
72
+ const usable = Math.max(1, w - padX * 2)
73
+ const x = (i: number) => padX + (n <= 1 ? 0 : (i / (n - 1)) * usable)
74
+
75
+ const fillTo = activeIndex < 0 ? x(0) : x(activeIndex)
76
+ const flowing = !reduced && inProgress && activeIndex >= 0 && activeIndex < n - 1
77
+ const flowStart = x(Math.max(0, activeIndex))
78
+ const flowEnd = x(Math.min(activeIndex + 1, n - 1))
79
+
80
+ return (
81
+ <div ref={ref} style={{ width: '100%' }}>
82
+ {w > 0 ? (
83
+ <>
84
+ <svg width={w} height={H} style={{ display: 'block', overflow: 'visible' }} role="presentation">
85
+ <defs>
86
+ <linearGradient id="hz-pipe-grad" x1="0" y1="0" x2="1" y2="0">
87
+ <stop offset="0%" stopColor={tone} stopOpacity="0.15" />
88
+ <stop offset="50%" stopColor={tone} stopOpacity="1" />
89
+ <stop offset="100%" stopColor={tone} stopOpacity="0.15" />
90
+ </linearGradient>
91
+ </defs>
92
+
93
+ {/* base track */}
94
+ <line x1={x(0)} y1={trackY} x2={x(n - 1)} y2={trackY} stroke={MUTED} strokeWidth={4} strokeLinecap="round" />
95
+
96
+ {/* filled (completed) track */}
97
+ {fillTo > x(0) ? <line x1={x(0)} y1={trackY} x2={fillTo} y2={trackY} stroke={tone} strokeWidth={4} strokeLinecap="round" /> : null}
98
+
99
+ {/* flowing gradient on the active leg */}
100
+ {flowing ? (
101
+ <line x1={flowStart} y1={trackY} x2={flowEnd} y2={trackY} stroke="url(#hz-pipe-grad)" strokeWidth={4} strokeLinecap="round" className="hz-pipe-flow" />
102
+ ) : null}
103
+
104
+ {/* stages */}
105
+ {stages.map((st, i) => (
106
+ <Stage key={st.name} cx={x(i)} cy={trackY} state={st.state} tone={tone} reduced={reduced} />
107
+ ))}
108
+ </svg>
109
+
110
+ {/* labels aligned under each stage */}
111
+ <div style={{ position: 'relative', width: w, height: 30, marginTop: 2 }}>
112
+ {stages.map((st, i) => (
113
+ <div
114
+ key={st.name}
115
+ style={{
116
+ position: 'absolute',
117
+ left: x(i),
118
+ transform: i === 0 ? 'translateX(0)' : i === n - 1 ? 'translateX(-100%)' : 'translateX(-50%)',
119
+ textAlign: i === 0 ? 'left' : i === n - 1 ? 'right' : 'center',
120
+ maxWidth: 110,
121
+ }}
122
+ >
123
+ <StageLabel name={st.name} state={st.state} />
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </>
128
+ ) : (
129
+ <div style={{ height: H + 30 }} />
130
+ )}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ function StageLabel({ name, state }: { name: string; state: StageState }) {
136
+ const sub = state === 'active' ? 'in progress' : state === 'error' ? 'failed' : state === 'done' ? 'done' : ''
137
+ return (
138
+ <YStack gap="$0.5">
139
+ <Text fontSize="$1" fontWeight={state === 'active' || state === 'error' ? '800' : '600'} color={state === 'pending' ? '$color9' : '$color12'} numberOfLines={1}>
140
+ {name}
141
+ </Text>
142
+ <Text fontSize="$1" style={state === 'error' ? { color: RED } : undefined} color={state === 'error' ? undefined : '$color10'}>
143
+ {sub || ' '}
144
+ </Text>
145
+ </YStack>
146
+ )
147
+ }
148
+
149
+ function Stage({ cx, cy, state, tone, reduced }: { cx: number; cy: number; state: StageState; tone: string; reduced: boolean }) {
150
+ const r = 11
151
+ if (state === 'done') {
152
+ return (
153
+ <g>
154
+ <circle cx={cx} cy={cy} r={r} fill={tone} />
155
+ <path d={`M${cx - 4.5},${cy + 0.5} L${cx - 1.5},${cy + 3.5} L${cx + 4.5},${cy - 3.5}`} fill="none" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
156
+ </g>
157
+ )
158
+ }
159
+ if (state === 'error') {
160
+ return (
161
+ <g>
162
+ <circle cx={cx} cy={cy} r={r} fill={RED} />
163
+ <path d={`M${cx - 3.5},${cy - 3.5} L${cx + 3.5},${cy + 3.5} M${cx + 3.5},${cy - 3.5} L${cx - 3.5},${cy + 3.5}`} stroke="#fff" strokeWidth={2} strokeLinecap="round" />
164
+ </g>
165
+ )
166
+ }
167
+ if (state === 'active') {
168
+ return (
169
+ <g>
170
+ {!reduced ? <circle cx={cx} cy={cy} r={r} fill={tone} className="hz-pipe-pulse" /> : null}
171
+ <circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={tone} strokeWidth={3} />
172
+ <circle cx={cx} cy={cy} r={4} fill={tone} />
173
+ </g>
174
+ )
175
+ }
176
+ // pending
177
+ return <circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={MUTED} strokeWidth={2} />
178
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Deploy-stage pipeline domain logic — PURE, no UI, no I/O, unit-testable.
3
+ *
4
+ * A deploy moves through four stages: Queued → Building → Deploying → Live. This
5
+ * maps a REAL app/deployment status string (building | deploying | live | error |
6
+ * queued | …) onto that pipeline and decides, per stage, whether it is done /
7
+ * active / pending / errored, plus an overall progress fraction and an honest
8
+ * status label.
9
+ *
10
+ * Honesty: this never fabricates progress. The stage is derived from the real
11
+ * status, and the caller passes the FURTHEST stage it has actually OBSERVED
12
+ * (monotonic across polls) so an `error` marks the stage the deploy truly reached —
13
+ * not a guess.
14
+ */
15
+
16
+ /** The four pipeline stages, in order. */
17
+ export const PIPELINE_STAGES = ['Queued', 'Building', 'Deploying', 'Live'] as const
18
+ export type PipelineStageName = (typeof PIPELINE_STAGES)[number]
19
+
20
+ /** Canonical pipeline phase, normalized from any backend status string. */
21
+ export type PipelinePhase = 'idle' | 'queued' | 'building' | 'deploying' | 'live' | 'error'
22
+
23
+ /** Per-stage render state. */
24
+ export type StageState = 'done' | 'active' | 'error' | 'pending'
25
+
26
+ /** One stage in the rendered pipeline. */
27
+ export type PipelineStage = { name: string; state: StageState }
28
+
29
+ /** The full derived model a `Pipeline` renders from. */
30
+ export type PipelineModel = {
31
+ phase: PipelinePhase
32
+ /** Index (0..3) of the current stage; -1 when idle (nothing deploying). */
33
+ activeIndex: number
34
+ /** Reached the final stage. */
35
+ live: boolean
36
+ /** The deploy failed. */
37
+ errored: boolean
38
+ /** True while actively progressing (has a stage to animate toward). */
39
+ inProgress: boolean
40
+ stages: PipelineStage[]
41
+ /** 0..1 fraction of the line filled (static progress / aria-valuenow). */
42
+ progress: number
43
+ /** A short, honest status label ("Building…", "Live", "Failed while deploying"). */
44
+ label: string
45
+ }
46
+
47
+ const N = PIPELINE_STAGES.length
48
+
49
+ const QUEUED = new Set(['queued', 'queue', 'waiting', 'accepted', 'created', 'pending'])
50
+ const BUILDING = new Set(['building', 'build', 'in_progress', 'inprogress', 'compiling'])
51
+ const DEPLOYING = new Set(['deploying', 'deploy', 'rollout', 'rolling', 'releasing', 'provisioning'])
52
+ const LIVE = new Set(['live', 'ready', 'running', 'available', 'succeeded', 'success', 'active', 'healthy', 'superseded'])
53
+ const ERROR = new Set(['error', 'errored', 'failed', 'failure', 'crashed', 'canceled', 'cancelled', 'timeout'])
54
+ const IDLE = new Set(['', 'draft', 'stopped', 'paused', 'idle', 'unknown', 'none', 'new'])
55
+
56
+ /** Normalize any app/deployment status string to a canonical pipeline phase. */
57
+ export function pipelinePhase(status?: string | null): PipelinePhase {
58
+ const s = (status ?? '').trim().toLowerCase()
59
+ if (ERROR.has(s)) return 'error'
60
+ if (LIVE.has(s)) return 'live'
61
+ if (DEPLOYING.has(s)) return 'deploying'
62
+ if (BUILDING.has(s)) return 'building'
63
+ if (QUEUED.has(s)) return 'queued'
64
+ if (IDLE.has(s)) return 'idle'
65
+ return 'idle'
66
+ }
67
+
68
+ /** The stage index (0..3) a phase sits at; -1 for idle/error (error uses `furthest`). */
69
+ export function stageIndex(phase: PipelinePhase): number {
70
+ switch (phase) {
71
+ case 'queued':
72
+ return 0
73
+ case 'building':
74
+ return 1
75
+ case 'deploying':
76
+ return 2
77
+ case 'live':
78
+ return N - 1
79
+ default:
80
+ return -1
81
+ }
82
+ }
83
+
84
+ /** Terminal phases (live or error) — the poller stops here. */
85
+ export function isTerminalPhase(phase: PipelinePhase): boolean {
86
+ return phase === 'live' || phase === 'error'
87
+ }
88
+
89
+ /** A short, honest status label for the pipeline. */
90
+ function pipelineLabel(phase: PipelinePhase, activeIndex: number, status?: string | null): string {
91
+ switch (phase) {
92
+ case 'live':
93
+ return 'Live'
94
+ case 'queued':
95
+ return 'Queued'
96
+ case 'building':
97
+ return 'Building…'
98
+ case 'deploying':
99
+ return 'Deploying…'
100
+ case 'error': {
101
+ if (activeIndex <= 0) return 'Failed in queue'
102
+ const at = PIPELINE_STAGES[Math.min(activeIndex, N - 1)].toLowerCase()
103
+ return `Failed while ${at}`
104
+ }
105
+ default: {
106
+ const s = (status ?? '').trim()
107
+ if (!s || s.toLowerCase() === 'draft' || s.toLowerCase() === 'new') return 'Not deployed'
108
+ return s.charAt(0).toUpperCase() + s.slice(1)
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Build the pipeline model from a status string and the FURTHEST stage index
115
+ * observed so far (monotonic across polls; default -1 = none observed). The furthest
116
+ * index makes an `error` mark the stage the deploy actually reached, and keeps
117
+ * completed stages filled even if a later poll returns a transient earlier status.
118
+ */
119
+ export function pipelineModel(status?: string | null, furthestReached = -1): PipelineModel {
120
+ const phase = pipelinePhase(status)
121
+ const idx = stageIndex(phase)
122
+ const reached = Math.max(furthestReached, idx)
123
+
124
+ const live = phase === 'live'
125
+ const errored = phase === 'error'
126
+
127
+ let activeIndex: number
128
+ if (live) activeIndex = N - 1
129
+ else if (errored) activeIndex = reached >= 0 ? Math.min(reached, N - 1) : 0
130
+ else if (phase === 'idle') activeIndex = -1 // nothing deploying — a neutral pipeline
131
+ else activeIndex = Math.min(reached, N - 1) // active: monotonic (never backslides)
132
+
133
+ const stages: PipelineStage[] = PIPELINE_STAGES.map((name, i): PipelineStage => {
134
+ if (live) return { name, state: 'done' }
135
+ if (errored) {
136
+ if (i < activeIndex) return { name, state: 'done' }
137
+ if (i === activeIndex) return { name, state: 'error' }
138
+ return { name, state: 'pending' }
139
+ }
140
+ if (activeIndex < 0) return { name, state: 'pending' } // idle: nothing started
141
+ if (i < activeIndex) return { name, state: 'done' }
142
+ if (i === activeIndex) return { name, state: 'active' }
143
+ return { name, state: 'pending' }
144
+ })
145
+
146
+ const progress = live ? 1 : activeIndex < 0 ? 0 : activeIndex / (N - 1)
147
+ const inProgress = !live && !errored && activeIndex >= 0
148
+ const label = pipelineLabel(phase, activeIndex, status)
149
+
150
+ return { phase, activeIndex, live, errored, inProgress, stages, progress, label }
151
+ }
152
+
153
+ /** The accent tone for a model: red on failure, green on live, else the accent. */
154
+ export function pipelineTone(model: Pick<PipelineModel, 'live' | 'errored'>, accent = '#7c5cff'): string {
155
+ return model.errored ? '#ff5d8f' : model.live ? '#23c562' : accent
156
+ }
package/src/types.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared cross-module types for @hanzo/dashboard.
3
+ */
4
+ import type { ComponentType } from 'react'
5
+
6
+ /**
7
+ * A lucide-style icon component — the shape the dashboard passes around for
8
+ * consumer-supplied product/tile/action icons. Structural (not tied to a specific
9
+ * icon package) so the config types stay pure and any `size`/`color` icon fits
10
+ * (e.g. `@hanzogui/lucide-icons-2` or `lucide-react` icons).
11
+ */
12
+ export type IconComponent = ComponentType<{ size?: number | string; color?: string; opacity?: number }>
package/readme.md DELETED
@@ -1,57 +0,0 @@
1
- # SuperBase Dashboard
2
-
3
- Static SPA that the superbase Go binary embeds via `go:embed` and serves
4
- at `/dashboard/*` (with `/_/tenants/*` redirecting here as a legacy
5
- alias).
6
-
7
- - Vite + React 19 + React Router
8
- - `@hanzo/gui` v7 for all UI (no shadcn, no Radix-direct)
9
- - `@hanzo/iam` PKCE OIDC against `hanzo.id`
10
- - Talks to the local Base instance at `/v1/collections/tenants/records`
11
-
12
- No Svelte. No Express. No server-side. Pure static bundle.
13
-
14
- ## Build
15
-
16
- ```sh
17
- bun install
18
- bun run build
19
- ```
20
-
21
- The output lands in `dist/` — that's what the Go binary embeds. Each
22
- asset is content-hashed; `dist/index.html` is the SPA shell. The Go
23
- binary rewrites the `<meta name="hanzo-iam-*">` tags in `index.html`
24
- at serve time to inject the right environment's IAM client id, so the
25
- bundle has no env-specific configuration baked in.
26
-
27
- ## Dev
28
-
29
- ```sh
30
- bun run dev
31
- ```
32
-
33
- Vite serves at `http://127.0.0.1:5180/dashboard/` and proxies `/v1/*`
34
- to `http://127.0.0.1:8090` (a locally-running Base / superbase binary).
35
-
36
- If you don't have a local IAM, the SPA still loads — `useIam()`
37
- surfaces the sign-in screen and clicking it redirects to
38
- `https://hanzo.id`. Override the IAM target with:
39
-
40
- ```sh
41
- VITE_IAM_SERVER_URL=https://iam-local.hanzo.ai \
42
- VITE_IAM_CLIENT_ID=hanzo-superbase-local \
43
- bun run dev
44
- ```
45
-
46
- ## What's where
47
-
48
- - `src/main.tsx` — `HanzoguiProvider` → `IamProvider` → router
49
- - `src/App.tsx` — header + auth gate
50
- - `src/auth.ts` — IAM config (meta-tag → env → defaults)
51
- - `src/api.ts` — typed Base client (list/get/create/update/delete)
52
- - `src/routes/TenantsList.tsx` — table + drawer + 5s polling
53
- - `src/routes/NewTenant.tsx` — create form with kebab-slug validation
54
- - `src/routes/LoginCallback.tsx` — OIDC PKCE callback
55
- - `src/components/` — TenantRow, TenantStatusBadge, ConfirmDialog
56
- - `gui.config.ts` — @hanzogui/config v5 default + dark theme
57
- - `vite.config.ts` — base `/dashboard/`, hanzogui dedupe + optimizeDeps
package/src/lib/api.ts DELETED
@@ -1,155 +0,0 @@
1
- // Typed, host-agnostic Base client for the SuperBase `tenants` collection.
2
- //
3
- // ONE transport, injected by the host. The screens never see a token or a
4
- // fetch — they call the returned `TenantsApi`. Two hosts build it differently:
5
- //
6
- // • standalone (base.hanzo.ai): same-origin, Bearer from @hanzo/iam.
7
- // createTenantsApi({ getToken: () => accessToken })
8
- // • embedded (console.hanzo.ai): calls console2's own `/superbase/*` proxy
9
- // with the session cookie; the proxy mints + forwards the user's IAM
10
- // bearer server-side, so no token reaches this browser.
11
- // createTenantsApi({ baseUrl: '/superbase', credentials: 'include' })
12
- //
13
- // One and only one way: every mutation goes through a method on the api object.
14
- // No raw fetch elsewhere in the screens.
15
-
16
- export type TenantStatus =
17
- | 'Pending'
18
- | 'Creating'
19
- | 'Running'
20
- | 'Degraded'
21
- | 'Deleting'
22
-
23
- export interface TenantSpec {
24
- replicas: number
25
- storage: string
26
- }
27
-
28
- export interface TenantRecord {
29
- id: string
30
- slug: string
31
- name: string
32
- owner_iam_user: string
33
- namespace: string
34
- spec: TenantSpec
35
- status: TenantStatus
36
- subdomain: string
37
- current_writer: string
38
- term: number
39
- created: string
40
- updated: string
41
- }
42
-
43
- export interface CreateTenantInput {
44
- slug: string
45
- name: string
46
- namespace?: string
47
- spec: TenantSpec
48
- }
49
-
50
- export interface UpdateTenantInput {
51
- name?: string
52
- namespace?: string
53
- spec?: Partial<TenantSpec>
54
- }
55
-
56
- export interface ListResponse<T> {
57
- page: number
58
- perPage: number
59
- totalPages: number
60
- totalItems: number
61
- items: T[]
62
- }
63
-
64
- const BASE_PATH = '/v1/collections/tenants/records'
65
-
66
- export class ApiError extends Error {
67
- status: number
68
- body: unknown
69
- constructor(status: number, body: unknown, message?: string) {
70
- super(message ?? `Base API error ${status}`)
71
- this.status = status
72
- this.body = body
73
- }
74
- }
75
-
76
- /**
77
- * Host-supplied transport. `baseUrl` prefixes every request (''=same-origin for
78
- * the standalone app, '/superbase'=console2's proxy). `getToken` provides the
79
- * Bearer when the host holds the token in the browser (standalone); omit it when
80
- * a same-origin proxy injects the credential server-side (console2), and set
81
- * `credentials: 'include'` so the proxy receives the session cookie.
82
- */
83
- export interface TenantsTransport {
84
- baseUrl?: string
85
- getToken?: () => string | null
86
- credentials?: RequestCredentials
87
- }
88
-
89
- /** The complete data surface the screens consume — five methods, no token. */
90
- export interface TenantsApi {
91
- list(params?: { page?: number; perPage?: number; sort?: string }): Promise<ListResponse<TenantRecord>>
92
- get(idOrSlug: string): Promise<TenantRecord>
93
- create(input: CreateTenantInput): Promise<TenantRecord>
94
- update(id: string, patch: UpdateTenantInput): Promise<TenantRecord>
95
- remove(id: string): Promise<void>
96
- }
97
-
98
- export function createTenantsApi(t: TenantsTransport = {}): TenantsApi {
99
- const baseUrl = (t.baseUrl ?? '').replace(/\/+$/, '')
100
- const credentials: RequestCredentials = t.credentials ?? 'same-origin'
101
-
102
- async function request<T>(path: string, init: RequestInit): Promise<T> {
103
- const headers: Record<string, string> = {
104
- 'Content-Type': 'application/json',
105
- ...((init.headers as Record<string, string>) ?? {}),
106
- }
107
- const token = t.getToken?.() ?? null
108
- if (token) headers.Authorization = `Bearer ${token}`
109
-
110
- const res = await fetch(`${baseUrl}${path}`, { ...init, headers, credentials })
111
- if (!res.ok) {
112
- let body: unknown = null
113
- try { body = await res.json() } catch { /* not JSON */ }
114
- throw new ApiError(res.status, body, `Base API ${res.status} ${res.statusText}`)
115
- }
116
- if (res.status === 204) return undefined as T
117
- return (await res.json()) as T
118
- }
119
-
120
- return {
121
- list(params) {
122
- const q = new URLSearchParams()
123
- if (params?.page) q.set('page', String(params.page))
124
- if (params?.perPage) q.set('perPage', String(params.perPage))
125
- // The tenants collection has no `created`/`createdAt` autodate field in
126
- // the deployed Base schema, so `sort=-created` 400s. `-id` is always
127
- // present and id is monotonic, giving newest-first ordering.
128
- q.set('sort', params?.sort ?? '-id')
129
- return request<ListResponse<TenantRecord>>(`${BASE_PATH}?${q}`, { method: 'GET' })
130
- },
131
- get(idOrSlug) {
132
- // Base supports lookup by id or by a unique field via filter; slug is unique.
133
- if (idOrSlug.match(/^[a-z0-9]{15}$/)) {
134
- return request<TenantRecord>(`${BASE_PATH}/${encodeURIComponent(idOrSlug)}`, { method: 'GET' })
135
- }
136
- const q = new URLSearchParams({ filter: `slug='${idOrSlug.replace(/'/g, "\\'")}'`, perPage: '1' })
137
- return request<ListResponse<TenantRecord>>(`${BASE_PATH}?${q}`, { method: 'GET' }).then((r) => {
138
- if (r.items.length === 0) throw new ApiError(404, null, `tenant not found: ${idOrSlug}`)
139
- return r.items[0]
140
- })
141
- },
142
- create(input) {
143
- return request<TenantRecord>(BASE_PATH, { method: 'POST', body: JSON.stringify(input) })
144
- },
145
- update(id, patch) {
146
- return request<TenantRecord>(`${BASE_PATH}/${encodeURIComponent(id)}`, {
147
- method: 'PATCH',
148
- body: JSON.stringify(patch),
149
- })
150
- },
151
- remove(id) {
152
- return request<void>(`${BASE_PATH}/${encodeURIComponent(id)}`, { method: 'DELETE' })
153
- },
154
- }
155
- }
@@ -1,53 +0,0 @@
1
- import { Button, Dialog, Text, XStack, YStack } from '@hanzo/gui'
2
-
3
- export function ConfirmDialog({
4
- open,
5
- title,
6
- message,
7
- confirmLabel = 'Confirm',
8
- destructive = false,
9
- onConfirm,
10
- onCancel,
11
- }: {
12
- open: boolean
13
- title: string
14
- message: string
15
- confirmLabel?: string
16
- destructive?: boolean
17
- onConfirm: () => void
18
- onCancel: () => void
19
- }) {
20
- return (
21
- <Dialog modal open={open} onOpenChange={(v) => { if (!v) onCancel() }}>
22
- <Dialog.Portal>
23
- <Dialog.Overlay bg="rgba(0,0,0,0.6)" />
24
- <Dialog.Content
25
- bg="#0a0a0a"
26
- borderColor="#27272a"
27
- borderWidth={1}
28
- p={20}
29
- width={420}
30
- gap={16}
31
- >
32
- <Dialog.Title>
33
- <Text fontSize={18} fontWeight="700" color="#f4f4f5">{title}</Text>
34
- </Dialog.Title>
35
- <Dialog.Description>
36
- <Text color="#a1a1aa">{message}</Text>
37
- </Dialog.Description>
38
- <YStack gap={12}>
39
- <XStack gap={8} justify="flex-end">
40
- <Button onPress={onCancel}>Cancel</Button>
41
- <Button
42
- theme={destructive ? 'red' : 'blue'}
43
- onPress={onConfirm}
44
- >
45
- {confirmLabel}
46
- </Button>
47
- </XStack>
48
- </YStack>
49
- </Dialog.Content>
50
- </Dialog.Portal>
51
- </Dialog>
52
- )
53
- }
@@ -1,39 +0,0 @@
1
- import { Text, XStack } from '@hanzo/gui'
2
- import { TenantStatusBadge } from './TenantStatusBadge'
3
- import type { TenantRecord } from '../api'
4
-
5
- // One row in the tenants list. Click anywhere on the row to open the
6
- // detail drawer — selection is parent-controlled.
7
- export function TenantRow({
8
- tenant,
9
- onClick,
10
- }: {
11
- tenant: TenantRecord
12
- onClick: (t: TenantRecord) => void
13
- }) {
14
- return (
15
- <XStack
16
- px={16}
17
- py={12}
18
- borderBottomWidth={1}
19
- borderBottomColor="#27272a"
20
- hoverStyle={{ bg: '#18181b' }}
21
- cursor="pointer"
22
- onPress={() => onClick(tenant)}
23
- items="center"
24
- gap={16}
25
- >
26
- <Text flex={1} color="#f4f4f5" fontWeight="600">{tenant.slug}</Text>
27
- <Text flex={2} color="#a1a1aa" numberOfLines={1}>{tenant.subdomain || '—'}</Text>
28
- <XStack width={120} justify="flex-start">
29
- <TenantStatusBadge status={tenant.status} />
30
- </XStack>
31
- <Text flex={2} color="#a1a1aa" numberOfLines={1}>
32
- {tenant.current_writer || '—'}
33
- </Text>
34
- <Text width={80} color="#a1a1aa" style={{ textAlign: 'right' }}>
35
- term {tenant.term}
36
- </Text>
37
- </XStack>
38
- )
39
- }