@skyhook-io/k8s-ui 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +8 -2
  2. package/src/components/dock/BottomDock.tsx +14 -15
  3. package/src/components/dock/DockContext.tsx +40 -1
  4. package/src/components/dock/LocalTerminalTab.tsx +241 -0
  5. package/src/components/dock/NodeTerminalTab.tsx +129 -0
  6. package/src/components/dock/TerminalTab.tsx +1 -1
  7. package/src/components/dock/index.ts +2 -0
  8. package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
  9. package/src/components/logs/LogCore.tsx +30 -4
  10. package/src/components/logs/StructuredLogLine.tsx +168 -0
  11. package/src/components/logs/index.ts +1 -1
  12. package/src/components/logs/useLogBuffer.ts +16 -10
  13. package/src/components/resources/ResourcesView.tsx +186 -67
  14. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
  15. package/src/components/resources/renderers/HPARenderer.tsx +2 -1
  16. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
  18. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
  20. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
  23. package/src/components/resources/renderers/PodRenderer.tsx +164 -8
  24. package/src/components/resources/renderers/VPARenderer.tsx +2 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
  26. package/src/components/resources/renderers/contour-cells.tsx +48 -0
  27. package/src/components/resources/renderers/index.ts +2 -0
  28. package/src/components/resources/renderers/trivy-shared.tsx +9 -25
  29. package/src/components/resources/resource-utils-contour.ts +34 -0
  30. package/src/components/resources/resource-utils.ts +91 -0
  31. package/src/components/shared/ResourceActionsBar.tsx +276 -144
  32. package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
  33. package/src/components/shared/index.ts +1 -1
  34. package/src/components/timeline/shared.tsx +8 -10
  35. package/src/components/topology/K8sResourceNode.tsx +1 -0
  36. package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
  37. package/src/components/topology/TopologyGraph.tsx +2 -17
  38. package/src/components/topology/layout.ts +1 -0
  39. package/src/components/topology/topology.css +1 -0
  40. package/src/components/ui/CodeViewer.tsx +1 -2
  41. package/src/components/ui/ConfirmDialog.tsx +85 -130
  42. package/src/components/ui/DialogPortal.tsx +82 -0
  43. package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
  44. package/src/components/ui/Toast.tsx +5 -5
  45. package/src/components/ui/drawer-components.tsx +28 -22
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/workload/WorkloadView.tsx +30 -2
  48. package/src/theme/index.ts +3 -0
  49. package/src/theme/tailwind-theme.css +61 -0
  50. package/src/theme/variables.css +121 -0
  51. package/src/types/core.ts +13 -2
  52. package/src/utils/animation.ts +1 -1
  53. package/src/utils/badge-colors.ts +158 -123
  54. package/src/utils/log-format.ts +127 -35
  55. package/src/utils/navigation.test.ts +142 -0
  56. package/src/utils/navigation.ts +52 -47
  57. package/src/utils/resource-hierarchy.ts +5 -0
  58. package/src/utils/resource-icons.ts +3 -0
@@ -64,6 +64,7 @@ import {
64
64
  getKnativeConditionStatus,
65
65
  getRevisionStatus,
66
66
  } from '../resources/resource-utils-knative'
67
+ import { getHTTPProxyStatus } from '../resources/resource-utils-contour'
67
68
  import {
68
69
  PodRenderer,
69
70
  WorkloadRenderer,
@@ -169,8 +170,37 @@ import {
169
170
  RuntimeClassRenderer,
170
171
  LeaseRenderer,
171
172
  TraefikIngressRouteRenderer,
173
+ ContourHTTPProxyRenderer,
172
174
  } from '../resources/renderers'
173
- import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo } from '../../types'
175
+ import type { SelectedResource, Relationships, ResourceRef, SecretCertificateInfo, ResolvedEnvFrom } from '../../types'
176
+ import type { CopyHandler } from '../ui/drawer-components'
177
+
178
+ /**
179
+ * Override map letting each platform consumer swap in its own renderer components.
180
+ * Each override receives only the props that ResourceRendererDispatch passes at its
181
+ * call site — a subset of the base renderer's full props. The override is responsible
182
+ * for wiring any additional behavior (metrics, exec, port-forward, scale, etc.) internally.
183
+ *
184
+ * When an override is not provided, the base (shared) renderer is used.
185
+ */
186
+ export interface RendererOverrides {
187
+ PodRenderer?: React.ComponentType<{
188
+ data: any; onCopy: CopyHandler; copied: string | null
189
+ onNavigate?: (ref: ResourceRef) => void
190
+ onOpenLogs?: (podName: string, containerName: string) => void
191
+ resolvedEnvFrom?: ResolvedEnvFrom
192
+ }>
193
+ NodeRenderer?: React.ComponentType<{
194
+ data: any; relationships?: Relationships
195
+ }>
196
+ ServiceRenderer?: React.ComponentType<{
197
+ data: any; onCopy: CopyHandler; copied: string | null
198
+ }>
199
+ WorkloadRenderer?: React.ComponentType<{
200
+ kind: string; data: any
201
+ onNavigate?: (ref: ResourceRef) => void
202
+ }>
203
+ }
174
204
 
175
205
  // Known resource types with specific renderers (module-level to avoid re-allocation)
176
206
  const KNOWN_KINDS = new Set([
@@ -206,6 +236,7 @@ const KNOWN_KINDS = new Set([
206
236
  'channels', 'inmemorychannels', 'subscriptions', 'sequences', 'parallels',
207
237
  'knativeingresses', 'knativecertificates', 'serverlessservices', 'domainmappings',
208
238
  'ingressroutes', 'ingressroutetcps', 'ingressrouteudps',
239
+ 'httpproxies',
209
240
  ])
210
241
 
211
242
  // ============================================================================
@@ -228,6 +259,10 @@ interface ResourceRendererDispatchProps {
228
259
  showMetrics?: boolean
229
260
  /** When provided, container-level Logs buttons call this instead of opening the dock */
230
261
  onOpenLogs?: (podName: string, containerName: string) => void
262
+ /** Resolved ConfigMap/Secret data for envFrom expansion in PodRenderer */
263
+ resolvedEnvFrom?: ResolvedEnvFrom
264
+ /** Platform-specific renderer overrides (e.g. with hooks for metrics, exec, port-forward) */
265
+ rendererOverrides?: RendererOverrides
231
266
  /** Optional hint shown in the Events section (e.g. link to Timeline tab) */
232
267
  eventsHint?: React.ReactNode
233
268
  /** When provided, sidebar sections (related resources, events, labels, annotations, metadata) are passed to this render prop instead of being rendered inline */
@@ -258,11 +293,18 @@ export function ResourceRendererDispatch({
258
293
  events,
259
294
  eventsLoading,
260
295
  renderMetrics,
296
+ resolvedEnvFrom,
297
+ rendererOverrides,
261
298
  }: ResourceRendererDispatchProps) {
262
299
  const kind = resource.kind.toLowerCase()
263
300
 
264
301
  const isKnownKind = KNOWN_KINDS.has(kind)
265
302
 
303
+ const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
304
+ const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
305
+ const NodeComp = rendererOverrides?.NodeRenderer ?? NodeRenderer
306
+ const ServiceComp = rendererOverrides?.ServiceRenderer ?? ServiceRenderer
307
+
266
308
  const sidebarContent = showCommonSections && (
267
309
  <>
268
310
  <RelatedResourcesSection relationships={relationships} onNavigate={onNavigate} />
@@ -277,17 +319,17 @@ export function ResourceRendererDispatch({
277
319
  <div className={renderSidebar ? 'lg:flex' : ''}>
278
320
  <div className={clsx('p-4 space-y-4', renderSidebar && 'lg:flex-1 lg:min-w-0')}>
279
321
  {/* Kind-specific content - delegates to modular renderers */}
280
- {kind === 'pods' && <PodRenderer data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} onOpenLogs={onOpenLogs} />}
281
- {['deployments', 'statefulsets', 'daemonsets'].includes(kind) && <WorkloadRenderer kind={kind} data={data} onNavigate={onNavigate} />}
322
+ {kind === 'pods' && <PodComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} onOpenLogs={onOpenLogs} resolvedEnvFrom={resolvedEnvFrom} />}
323
+ {['deployments', 'statefulsets', 'daemonsets'].includes(kind) && <WorkloadComp kind={kind} data={data} onNavigate={onNavigate} />}
282
324
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
283
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceRenderer data={data} onCopy={onCopy} copied={copied} />}
325
+ {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} />}
284
326
  {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
285
327
  {kind === 'configmaps' && <ConfigMapRenderer data={data} />}
286
328
  {kind === 'secrets' && <SecretRenderer data={data} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
287
329
  {kind === 'jobs' && <JobRenderer data={data} />}
288
330
  {kind === 'cronjobs' && <CronJobRenderer data={data} onNavigate={onNavigate} />}
289
331
  {(kind === 'hpas' || kind === 'horizontalpodautoscalers') && <HPARenderer data={data} onNavigate={onNavigate} />}
290
- {kind === 'nodes' && <NodeRenderer data={data} relationships={relationships} />}
332
+ {kind === 'nodes' && <NodeComp data={data} relationships={relationships} />}
291
333
  {kind === 'persistentvolumeclaims' && <PVCRenderer data={data} onNavigate={onNavigate} />}
292
334
  {kind === 'rollouts' && <RolloutRenderer data={data} />}
293
335
  {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
@@ -390,6 +432,9 @@ export function ResourceRendererDispatch({
390
432
  {/* Traefik */}
391
433
  {(kind === 'ingressroutes' || kind === 'ingressroutetcps' || kind === 'ingressrouteudps') && <TraefikIngressRouteRenderer data={data} onNavigate={onNavigate} />}
392
434
 
435
+ {/* Contour */}
436
+ {kind === 'httpproxies' && <ContourHTTPProxyRenderer data={data} onNavigate={onNavigate} />}
437
+
393
438
  {/* Generic renderer for CRDs and unknown resource types */}
394
439
  {!isKnownKind && <GenericRenderer data={data} />}
395
440
 
@@ -502,6 +547,15 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
502
547
  if (k === 'peerauthentications') return getPeerAuthenticationStatus(data)
503
548
  if (k === 'authorizationpolicies') return getAuthorizationPolicyStatus(data)
504
549
 
550
+ // Contour HTTPProxy
551
+ if (k === 'httpproxies') {
552
+ const s = getHTTPProxyStatus(data)
553
+ if (s.status === 'healthy') return { text: s.label, color: 'bg-green-500/20 text-green-400' }
554
+ if (s.status === 'unhealthy') return { text: s.label, color: 'bg-red-500/20 text-red-400' }
555
+ if (s.status === 'degraded') return { text: s.label, color: 'bg-yellow-500/20 text-yellow-400' }
556
+ return null
557
+ }
558
+
505
559
  // Knative Revisions have custom status logic (scaled-to-zero, activating)
506
560
  if (k === 'knativerevisions') {
507
561
  const status = getRevisionStatus(data)
@@ -1,3 +1,3 @@
1
- export { ResourceRendererDispatch, getResourceStatus } from './ResourceRendererDispatch'
1
+ export { ResourceRendererDispatch, getResourceStatus, type RendererOverrides } from './ResourceRendererDispatch'
2
2
  export { EditableYamlView, SaveSuccessAnimation } from './EditableYamlView'
3
3
  export { ResourceActionsBar, RevisionHistoryDialog } from './ResourceActionsBar'
@@ -123,19 +123,19 @@ export function HealthSpanLegend() {
123
123
  return (
124
124
  <div className="flex items-center gap-4 text-xs text-theme-text-tertiary">
125
125
  <span className="flex items-center gap-1">
126
- <span className="w-4 h-2 rounded-sm bg-green-500/60" />
126
+ <span className="w-4 h-2 rounded-sm bg-green-500/60 dark:bg-green-600/60" />
127
127
  <span>Healthy</span>
128
128
  </span>
129
129
  <span className="flex items-center gap-1">
130
- <span className="w-4 h-2 rounded-sm bg-blue-500/60" />
130
+ <span className="w-4 h-2 rounded-sm bg-blue-500/60 dark:bg-blue-500/60" />
131
131
  <span>Rolling</span>
132
132
  </span>
133
133
  <span className="flex items-center gap-1">
134
- <span className="w-4 h-2 rounded-sm bg-amber-500/60" />
134
+ <span className="w-4 h-2 rounded-sm bg-amber-500/60 dark:bg-[#b8861e]" />
135
135
  <span>Degraded</span>
136
136
  </span>
137
137
  <span className="flex items-center gap-1">
138
- <span className="w-4 h-2 rounded-sm bg-red-500/60" />
138
+ <span className="w-4 h-2 rounded-sm bg-red-500/60 dark:bg-red-500/60" />
139
139
  <span>Unhealthy</span>
140
140
  </span>
141
141
  </div>
@@ -402,15 +402,13 @@ export function HealthSpan({ health, left, width, title, createdBefore }: Health
402
402
  const getHealthColor = () => {
403
403
  switch (health) {
404
404
  case 'healthy':
405
- return 'bg-green-500/60'
405
+ return 'bg-green-500/60 dark:bg-green-600/60'
406
406
  case 'rolling':
407
- // Blue/purple for expected degradation during rollout
408
- return 'bg-blue-500/60'
407
+ return 'bg-blue-500/60 dark:bg-blue-500/60'
409
408
  case 'degraded':
410
- // Amber for unexpected degradation
411
- return 'bg-amber-500/60'
409
+ return 'bg-amber-500/60 dark:bg-[#b8861e]'
412
410
  case 'unhealthy':
413
- return 'bg-red-500/60'
411
+ return 'bg-red-500/60 dark:bg-red-500/60'
414
412
  default:
415
413
  // Unknown or other states
416
414
  return 'bg-gray-400/40'
@@ -134,6 +134,7 @@ export const NODE_DIMENSIONS: Record<NodeKind, { width: number; height: number }
134
134
  ServersTransportTCP: { width: 300, height: 56 },
135
135
  TLSOption: { width: 280, height: 56 },
136
136
  TLSStore: { width: 280, height: 56 },
137
+ HTTPProxy: { width: 280, height: 56 }, // Contour
137
138
  }
138
139
 
139
140
 
@@ -77,6 +77,9 @@ const RESOURCE_KINDS: {
77
77
  { kind: 'ServersTransportTCP', label: 'Transport TCP', icon: getTopologyIcon('ServersTransportTCP'), color: 'text-cyan-400', category: 'custom' },
78
78
  { kind: 'TLSOption', label: 'TLS Option', icon: getTopologyIcon('TLSOption'), color: 'text-cyan-400', category: 'custom' },
79
79
  { kind: 'TLSStore', label: 'TLS Store', icon: getTopologyIcon('TLSStore'), color: 'text-cyan-400', category: 'custom' },
80
+
81
+ // Contour
82
+ { kind: 'HTTPProxy', label: 'HTTPProxy', icon: getTopologyIcon('HTTPProxy'), color: 'text-violet-400', category: 'networking' },
80
83
  ]
81
84
 
82
85
  const CATEGORIES = [
@@ -19,7 +19,7 @@ import {
19
19
  import '@xyflow/react/dist/style.css'
20
20
  import { toCanvas } from 'html-to-image'
21
21
 
22
- import { AlertTriangle, Download, Loader2, RotateCw, Scissors, Shield } from 'lucide-react'
22
+ import { AlertTriangle, Download, Loader2, RotateCw, Shield } from 'lucide-react'
23
23
  import { useToast } from '../ui/Toast'
24
24
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
25
25
 
@@ -541,23 +541,8 @@ export function TopologyGraph({
541
541
 
542
542
  return (
543
543
  <ReactFlowProvider>
544
- {/* Truncation banner - shown when topology has too many nodes */}
545
- {topology?.truncated && (
546
- <div className="absolute top-2 left-2 right-2 z-10 bg-blue-500/10 border border-blue-500/30 rounded-lg p-2 backdrop-blur-sm">
547
- <div className="flex items-center gap-2">
548
- <Scissors className="w-4 h-4 text-blue-400 shrink-0" />
549
- <div className="text-sm">
550
- <span className="font-medium text-blue-400">Large cluster:</span>
551
- <span className="text-theme-text-secondary ml-1">
552
- Showing {topology.nodes.length} of {topology.totalNodes} nodes.
553
- Select a namespace for better performance.
554
- </span>
555
- </div>
556
- </div>
557
- </div>
558
- )}
559
544
  {/* Warning banner for partial topology data */}
560
- {topology?.warnings && topology.warnings.length > 0 && !topology.truncated && (() => {
545
+ {topology?.warnings && topology.warnings.length > 0 && (() => {
561
546
  const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
562
547
  const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted'))
563
548
  const isAllRbac = otherWarnings.length === 0
@@ -444,6 +444,7 @@ function pickGroupName(nodes: TopologyNode[]): string {
444
444
  'ServersTransportTCP': 4,
445
445
  'TLSOption': 4,
446
446
  'TLSStore': 4,
447
+ 'HTTPProxy': 1, // Contour
447
448
  }
448
449
 
449
450
  // Sort by priority and pick the first
@@ -96,6 +96,7 @@
96
96
  .topology-icon-nodepool { background: #6366f1; }
97
97
  .topology-icon-nodeclaim { background: #6366f1; }
98
98
  .topology-icon-nodeclass { background: #6366f1; }
99
+ .topology-icon-httpproxy { background: #7c3aed; }
99
100
 
100
101
  /* Background dots - uses CSS variable for theme awareness */
101
102
  .react-flow__background pattern circle {
@@ -401,8 +401,7 @@ export function CodeViewer({
401
401
  </div>
402
402
 
403
403
  <style>{`
404
- :root[data-theme="light"] { --code-line-number: #8b949e; }
405
- :root[data-theme="dark"] { --code-line-number: #484f58; }
404
+ :root { --code-line-number: light-dark(#8b949e, #484f58); }
406
405
  .shiki-viewer pre {
407
406
  margin: 0;
408
407
  padding: 12px;
@@ -1,10 +1,8 @@
1
- import { useEffect, useRef, ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { ReactNode } from 'react'
3
2
  import { AlertTriangle, X } from 'lucide-react'
4
3
  import { clsx } from 'clsx'
5
4
  import { SEVERITY_TEXT, SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
6
- import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
7
- import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
5
+ import { DialogPortal } from './DialogPortal'
8
6
 
9
7
 
10
8
  interface ConfirmDialogProps {
@@ -18,6 +16,7 @@ interface ConfirmDialogProps {
18
16
  cancelLabel?: string
19
17
  variant?: 'danger' | 'warning'
20
18
  isLoading?: boolean
19
+ isClosable?: boolean // Allow closing even when isLoading (e.g., for long-running ops the user can dismiss)
21
20
  children?: ReactNode // Optional custom content (e.g., checkboxes)
22
21
  }
23
22
 
@@ -32,99 +31,56 @@ export function ConfirmDialog({
32
31
  cancelLabel = 'Cancel',
33
32
  variant = 'danger',
34
33
  isLoading = false,
34
+ isClosable = false,
35
35
  children,
36
36
  }: ConfirmDialogProps) {
37
- const dialogRef = useRef<HTMLDivElement>(null)
38
- const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
39
-
40
- // Handle ESC key
41
- useEffect(() => {
42
- if (!open) return
43
-
44
- const handleKeyDown = (e: KeyboardEvent) => {
45
- if (e.key === 'Escape' && !isLoading) {
46
- e.stopPropagation()
47
- onClose()
48
- }
49
- }
50
- document.addEventListener('keydown', handleKeyDown, true)
51
- return () => document.removeEventListener('keydown', handleKeyDown, true)
52
- }, [open, onClose, isLoading])
53
-
54
- // Focus trap
55
- useEffect(() => {
56
- if (open && dialogRef.current) {
57
- dialogRef.current.focus()
58
- }
59
- }, [open])
60
-
61
- if (!shouldRender) return null
62
-
37
+ const canClose = !isLoading || isClosable
63
38
  const isDanger = variant === 'danger'
64
39
  const severity = isDanger ? 'error' : 'warning'
65
40
 
66
- return createPortal(
67
- <div className="fixed inset-0 z-50 flex items-center justify-center">
68
- {/* Backdrop */}
69
- <div
70
- className={clsx(
71
- 'absolute inset-0 bg-black/60 backdrop-blur-sm',
72
- TRANSITION_BACKDROP,
73
- isOpen ? 'opacity-100' : 'opacity-0'
74
- )}
75
- onClick={isLoading ? undefined : onClose}
76
- />
77
-
78
- {/* Dialog */}
79
- <div
80
- ref={dialogRef}
81
- tabIndex={-1}
82
- className={clsx(
83
- 'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl max-w-md w-full mx-4 outline-none',
84
- TRANSITION_PANEL,
85
- isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
86
- )}
87
- >
88
- {/* Header */}
89
- <div className="flex items-start gap-3 p-4 border-b border-theme-border">
90
- <div
91
- className={clsx(
92
- 'flex items-center justify-center w-10 h-10 rounded-full shrink-0',
93
- isDanger ? 'bg-red-500/20' : 'bg-amber-500/20'
94
- )}
95
- >
96
- <AlertTriangle className={clsx('w-5 h-5', SEVERITY_TEXT[severity])} />
97
- </div>
98
- <div className="flex-1 min-w-0">
99
- <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
100
- <p className="text-sm text-theme-text-secondary mt-1">{message}</p>
101
- </div>
102
- <button
103
- onClick={onClose}
104
- disabled={isLoading}
105
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
106
- >
107
- <X className="w-5 h-5" />
108
- </button>
41
+ return (
42
+ <DialogPortal open={open} onClose={onClose} closable={canClose} className="max-w-md w-full">
43
+ {/* Header */}
44
+ <div className="flex items-start gap-3 p-4 border-b border-theme-border">
45
+ <div
46
+ className={clsx(
47
+ 'flex items-center justify-center w-10 h-10 rounded-full shrink-0',
48
+ isDanger ? 'bg-red-500/20' : 'bg-amber-500/20'
49
+ )}
50
+ >
51
+ <AlertTriangle className={clsx('w-5 h-5', SEVERITY_TEXT[severity])} />
109
52
  </div>
53
+ <div className="flex-1 min-w-0">
54
+ <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
55
+ <p className="text-sm text-theme-text-secondary mt-1">{message}</p>
56
+ </div>
57
+ <button
58
+ onClick={onClose}
59
+ disabled={!canClose}
60
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
61
+ >
62
+ <X className="w-5 h-5" />
63
+ </button>
64
+ </div>
110
65
 
111
- {/* Details */}
112
- {details && (
113
- <div className="p-4 border-b border-theme-border">
114
- <pre className="text-xs text-theme-text-secondary bg-theme-base/50 rounded p-3 overflow-auto max-h-32 whitespace-pre-wrap font-mono">
115
- {details}
116
- </pre>
117
- </div>
118
- )}
66
+ {/* Details */}
67
+ {details && (
68
+ <div className="p-4 border-b border-theme-border">
69
+ <pre className="text-xs text-theme-text-secondary bg-theme-base/50 rounded p-3 overflow-auto max-h-32 whitespace-pre-wrap font-mono">
70
+ {details}
71
+ </pre>
72
+ </div>
73
+ )}
119
74
 
120
- {/* Custom content */}
121
- {children && (
122
- <div className="px-4 pt-4">
123
- {children}
124
- </div>
125
- )}
75
+ {/* Custom content */}
76
+ {children && (
77
+ <div className="px-4 pt-4">
78
+ {children}
79
+ </div>
80
+ )}
126
81
 
127
- {/* Warning message */}
82
+ {/* Warning message — hidden once the action is in progress */}
83
+ {!isLoading && !children && (
128
84
  <div className="p-4">
129
85
  <div
130
86
  className={clsx(
@@ -140,49 +96,48 @@ export function ConfirmDialog({
140
96
  </span>
141
97
  </div>
142
98
  </div>
99
+ )}
143
100
 
144
- {/* Actions */}
145
- <div className="flex items-center justify-end gap-3 p-4 border-t border-theme-border">
146
- <button
147
- onClick={onClose}
148
- disabled={isLoading}
149
- className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
150
- >
151
- {cancelLabel}
152
- </button>
153
- <button
154
- onClick={onConfirm}
155
- disabled={isLoading}
156
- className={clsx(
157
- 'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2',
158
- isDanger
159
- ? 'bg-red-600 hover:bg-red-700 text-theme-text-primary'
160
- : 'bg-amber-600 hover:bg-amber-700 text-theme-text-primary'
161
- )}
162
- >
163
- {isLoading && (
164
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
165
- <circle
166
- className="opacity-25"
167
- cx="12"
168
- cy="12"
169
- r="10"
170
- stroke="currentColor"
171
- strokeWidth="4"
172
- fill="none"
173
- />
174
- <path
175
- className="opacity-75"
176
- fill="currentColor"
177
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
178
- />
179
- </svg>
180
- )}
181
- {confirmLabel}
182
- </button>
183
- </div>
101
+ {/* Actions */}
102
+ <div className="flex items-center justify-end gap-3 p-4 border-t border-theme-border">
103
+ <button
104
+ onClick={onClose}
105
+ disabled={!canClose}
106
+ className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
107
+ >
108
+ {cancelLabel}
109
+ </button>
110
+ <button
111
+ onClick={onConfirm}
112
+ disabled={isLoading}
113
+ className={clsx(
114
+ 'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2',
115
+ isDanger
116
+ ? 'bg-red-600 hover:bg-red-700 text-theme-text-primary'
117
+ : 'bg-amber-600 hover:bg-amber-700 text-theme-text-primary'
118
+ )}
119
+ >
120
+ {isLoading && (
121
+ <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
122
+ <circle
123
+ className="opacity-25"
124
+ cx="12"
125
+ cy="12"
126
+ r="10"
127
+ stroke="currentColor"
128
+ strokeWidth="4"
129
+ fill="none"
130
+ />
131
+ <path
132
+ className="opacity-75"
133
+ fill="currentColor"
134
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
135
+ />
136
+ </svg>
137
+ )}
138
+ {confirmLabel}
139
+ </button>
184
140
  </div>
185
- </div>,
186
- document.body
141
+ </DialogPortal>
187
142
  )
188
143
  }
@@ -0,0 +1,82 @@
1
+ import { useEffect, useRef, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { clsx } from 'clsx'
4
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
5
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
6
+
7
+ interface DialogPortalProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ children: ReactNode
11
+ /** Extra classes on the panel container (width, max-height, etc.) */
12
+ className?: string
13
+ /** Prevent closing via Escape / backdrop click (e.g. during async operation) */
14
+ closable?: boolean
15
+ }
16
+
17
+ /**
18
+ * Minimal dialog primitive — handles portal, backdrop, escape, focus, animation.
19
+ * Renders children inside a centered panel portaled to document.body, so it works
20
+ * correctly even inside CSS-transformed containers (drawers, slide panels).
21
+ *
22
+ * Usage:
23
+ * <DialogPortal open={showDialog} onClose={() => setShowDialog(false)} className="w-80">
24
+ * <h3>Title</h3>
25
+ * <p>Content</p>
26
+ * </DialogPortal>
27
+ */
28
+ export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
29
+ const dialogRef = useRef<HTMLDivElement>(null)
30
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
31
+
32
+ // Capture-phase ESC handler — stops event before it reaches document listeners (e.g. drawer shortcuts)
33
+ useEffect(() => {
34
+ if (!open) return
35
+ const handleKeyDown = (e: KeyboardEvent) => {
36
+ if (e.key === 'Escape' && closable) {
37
+ e.stopPropagation()
38
+ e.preventDefault()
39
+ onClose()
40
+ }
41
+ }
42
+ document.addEventListener('keydown', handleKeyDown, true)
43
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
44
+ }, [open, onClose, closable])
45
+
46
+ // Move focus into the dialog for accessibility and tab navigation
47
+ useEffect(() => {
48
+ if (open && dialogRef.current) {
49
+ dialogRef.current.focus()
50
+ }
51
+ }, [open])
52
+
53
+ if (!shouldRender) return null
54
+
55
+ return createPortal(
56
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
57
+ <div
58
+ className={clsx(
59
+ 'absolute inset-0 bg-black/60 backdrop-blur-sm',
60
+ TRANSITION_BACKDROP,
61
+ isOpen ? 'opacity-100' : 'opacity-0',
62
+ )}
63
+ onClick={closable ? onClose : undefined}
64
+ />
65
+ <div
66
+ ref={dialogRef}
67
+ role="dialog"
68
+ aria-modal="true"
69
+ tabIndex={-1}
70
+ className={clsx(
71
+ 'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
72
+ TRANSITION_PANEL,
73
+ isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
74
+ className,
75
+ )}
76
+ >
77
+ {children}
78
+ </div>
79
+ </div>,
80
+ document.body,
81
+ )
82
+ }