@skyhook-io/k8s-ui 1.0.0 → 1.1.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.
@@ -17,8 +17,10 @@ import {
17
17
  MarkerType,
18
18
  } from '@xyflow/react'
19
19
  import '@xyflow/react/dist/style.css'
20
+ import { toCanvas } from 'html-to-image'
20
21
 
21
- import { AlertTriangle, RotateCw, Scissors, Shield } from 'lucide-react'
22
+ import { AlertTriangle, Download, Loader2, RotateCw, Shield } from 'lucide-react'
23
+ import { useToast } from '../ui/Toast'
22
24
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
23
25
 
24
26
  import { K8sResourceNode } from './K8sResourceNode'
@@ -152,6 +154,8 @@ interface TopologyGraphProps {
152
154
  hideGroupHeader?: boolean
153
155
  onNodeClick: (node: TopologyNode) => void
154
156
  selectedNodeId?: string
157
+ /** Show image export button in controls. Default: true */
158
+ showExportButton?: boolean
155
159
  }
156
160
 
157
161
  export function TopologyGraph({
@@ -161,6 +165,7 @@ export function TopologyGraph({
161
165
  hideGroupHeader = false,
162
166
  onNodeClick,
163
167
  selectedNodeId,
168
+ showExportButton = true,
164
169
  }: TopologyGraphProps) {
165
170
  const isTrafficView = viewMode === 'traffic'
166
171
  const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[])
@@ -169,6 +174,7 @@ export function TopologyGraph({
169
174
  const [expandedPodGroups, setExpandedPodGroups] = useState<Set<string>>(new Set())
170
175
  const [layoutError, setLayoutError] = useState<string | null>(null)
171
176
  const [layoutRetryCount, setLayoutRetryCount] = useState(0)
177
+ const [isExporting, setIsExporting] = useState(false)
172
178
  const prevStructureRef = useRef<string>('')
173
179
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
174
180
 
@@ -535,23 +541,8 @@ export function TopologyGraph({
535
541
 
536
542
  return (
537
543
  <ReactFlowProvider>
538
- {/* Truncation banner - shown when topology has too many nodes */}
539
- {topology?.truncated && (
540
- <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">
541
- <div className="flex items-center gap-2">
542
- <Scissors className="w-4 h-4 text-blue-400 shrink-0" />
543
- <div className="text-sm">
544
- <span className="font-medium text-blue-400">Large cluster:</span>
545
- <span className="text-theme-text-secondary ml-1">
546
- Showing {topology.nodes.length} of {topology.totalNodes} nodes.
547
- Select a namespace for better performance.
548
- </span>
549
- </div>
550
- </div>
551
- </div>
552
- )}
553
544
  {/* Warning banner for partial topology data */}
554
- {topology?.warnings && topology.warnings.length > 0 && !topology.truncated && (() => {
545
+ {topology?.warnings && topology.warnings.length > 0 && (() => {
555
546
  const rbacWarnings = topology.warnings.filter(w => w.includes('RBAC not granted'))
556
547
  const otherWarnings = topology.warnings.filter(w => !w.includes('RBAC not granted'))
557
548
  const isAllRbac = otherWarnings.length === 0
@@ -623,19 +614,291 @@ export function TopologyGraph({
623
614
  minZoom={0.1}
624
615
  maxZoom={2}
625
616
  proOptions={{ hideAttribution: true }}
626
- onlyRenderVisibleElements
617
+ onlyRenderVisibleElements={!isExporting}
627
618
  >
628
619
  <Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#334155" />
629
620
  <Controls
630
621
  className="bg-theme-surface border border-theme-border rounded-lg"
631
622
  showInteractive={false}
632
- />
623
+ >
624
+ {showExportButton && <ExportImageButton onExportingChange={setIsExporting} />}
625
+ </Controls>
633
626
  <ViewportController structureKey={structureKey} />
634
627
  </ReactFlow>
635
628
  </ReactFlowProvider>
636
629
  )
637
630
  }
638
631
 
632
+ // Read the effective background color from the topology container
633
+ function getTopologyBgColor(): string {
634
+ const el = document.querySelector('.react-flow')
635
+ if (el) {
636
+ const bg = getComputedStyle(el).backgroundColor
637
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg
638
+ }
639
+ return '#0f172a'
640
+ }
641
+
642
+ // Compute export dimensions for the dialog preview
643
+ function useExportDimensions(captureMode: 'viewport' | 'full', scale: number) {
644
+ const { getNodes, getNodesBounds } = useReactFlow()
645
+ return useMemo(() => {
646
+ if (captureMode === 'viewport') {
647
+ const el = document.querySelector('.react-flow') as HTMLElement
648
+ if (!el) return null
649
+ const { width, height } = el.getBoundingClientRect()
650
+ const w = Math.ceil(width)
651
+ const h = Math.ceil(height)
652
+ return { pw: w * scale, ph: h * scale }
653
+ }
654
+ const nodes = getNodes()
655
+ if (nodes.length === 0) return null
656
+ const bounds = getNodesBounds(nodes)
657
+ const w = Math.ceil(bounds.width + EXPORT_PADDING * 2)
658
+ const h = Math.ceil(bounds.height + EXPORT_PADDING * 2)
659
+ // Full capture uses pixelRatio=1, so dimensions are 1:1 with graph bounds
660
+ return { pw: w, ph: h }
661
+ }, [captureMode, scale, getNodes, getNodesBounds])
662
+ }
663
+
664
+ type ImageFormat = 'image/png' | 'image/webp'
665
+ const FORMAT_LABELS: Record<ImageFormat, string> = { 'image/png': 'PNG', 'image/webp': 'WebP' }
666
+ const FORMAT_EXT: Record<ImageFormat, string> = { 'image/png': 'png', 'image/webp': 'webp' }
667
+
668
+ const EXPORT_PADDING = 16
669
+ const EXPORT_TIMEOUT_MS = 30_000
670
+
671
+ function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
672
+ return Promise.race([
673
+ promise,
674
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
675
+ ])
676
+ }
677
+
678
+ // Export topology as image button + dialog (must be inside ReactFlowProvider)
679
+ function ExportImageButton({ onExportingChange }: { onExportingChange: (v: boolean) => void }) {
680
+ const [showDialog, setShowDialog] = useState(false)
681
+ const [exporting, setExporting] = useState(false)
682
+ const [filename, setFilename] = useState('')
683
+ const [transparent, setTransparent] = useState(false)
684
+ const [scale, setScale] = useState(2)
685
+ const [captureMode, setCaptureMode] = useState<'viewport' | 'full'>('full')
686
+ const [format, setFormat] = useState<ImageFormat>('image/webp')
687
+ const { getNodes, getNodesBounds } = useReactFlow()
688
+ const { showError, showSuccess } = useToast()
689
+ const inputRef = useRef<HTMLInputElement>(null)
690
+ const dims = useExportDimensions(captureMode, scale)
691
+
692
+ const openDialog = useCallback((e: React.MouseEvent) => {
693
+ e.stopPropagation()
694
+ e.preventDefault()
695
+ const nodes = getNodes()
696
+ if (nodes.length === 0) return
697
+ setFilename(`topology-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}`)
698
+ setShowDialog(true)
699
+ setTimeout(() => inputRef.current?.select(), 50)
700
+ }, [getNodes])
701
+
702
+ const doExport = useCallback(async () => {
703
+ const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement
704
+ if (!flowEl) return
705
+
706
+ const nodes = getNodes()
707
+ if (nodes.length === 0) return
708
+
709
+ setExporting(true)
710
+
711
+ const isFullCapture = captureMode === 'full'
712
+ if (isFullCapture) {
713
+ onExportingChange(true)
714
+ // Wait for React to render all off-screen nodes
715
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
716
+ }
717
+
718
+ // Yield to let the UI paint the exporting state before heavy DOM work
719
+ await new Promise(resolve => setTimeout(resolve, 50))
720
+
721
+ try {
722
+ const bgColor = transparent ? 'transparent' : getTopologyBgColor()
723
+
724
+ let canvas: HTMLCanvasElement
725
+ if (isFullCapture) {
726
+ const bounds = getNodesBounds(nodes)
727
+ const w = Math.ceil(bounds.width + EXPORT_PADDING * 2)
728
+ const h = Math.ceil(bounds.height + EXPORT_PADDING * 2)
729
+ const tx = -bounds.x + EXPORT_PADDING
730
+ const ty = -bounds.y + EXPORT_PADDING
731
+ canvas = await withTimeout(toCanvas(flowEl, {
732
+ backgroundColor: bgColor,
733
+ width: w,
734
+ height: h,
735
+ pixelRatio: 1,
736
+ skipFonts: true,
737
+ style: {
738
+ width: `${w}px`,
739
+ height: `${h}px`,
740
+ transform: `translate(${tx}px, ${ty}px) scale(1)`,
741
+ },
742
+ }), EXPORT_TIMEOUT_MS, 'Export timed out — topology may be too large')
743
+ } else {
744
+ const flowContainer = document.querySelector('.react-flow') as HTMLElement
745
+ if (!flowContainer) throw new Error('Topology container not found')
746
+ const { width: vw, height: vh } = flowContainer.getBoundingClientRect()
747
+
748
+ canvas = await withTimeout(toCanvas(flowEl, {
749
+ backgroundColor: bgColor,
750
+ width: Math.ceil(vw),
751
+ height: Math.ceil(vh),
752
+ pixelRatio: scale,
753
+ skipFonts: true,
754
+ }), EXPORT_TIMEOUT_MS, 'Export timed out — topology may be too large')
755
+ }
756
+
757
+ const ext = FORMAT_EXT[format]
758
+ // WebP: quality 1.0 (lossless) when transparent to avoid alpha artifacts, 0.92 for opaque. PNG ignores quality.
759
+ const quality = format === 'image/webp' ? (transparent ? 1.0 : 0.92) : undefined
760
+ const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, format, quality))
761
+ if (!blob) throw new Error('Failed to create image — canvas may be too large or format unsupported')
762
+
763
+ const url = URL.createObjectURL(blob)
764
+ const a = document.createElement('a')
765
+ a.href = url
766
+ a.download = `${filename || 'topology'}.${ext}`
767
+ a.click()
768
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
769
+ const sizeMB = (blob.size / 1024 / 1024).toFixed(1)
770
+ showSuccess(`Exported ${ext.toUpperCase()} (${sizeMB} MB)`)
771
+ } catch (err) {
772
+ console.error('Failed to export topology:', err)
773
+ showError(`Export failed: ${err instanceof Error ? err.message : String(err)}`)
774
+ } finally {
775
+ setExporting(false)
776
+ onExportingChange(false)
777
+ setShowDialog(false)
778
+ }
779
+ }, [getNodes, getNodesBounds, transparent, scale, captureMode, format, filename, showError, showSuccess, onExportingChange])
780
+
781
+ useEffect(() => {
782
+ if (!showDialog) return
783
+ const handleKey = (e: KeyboardEvent) => {
784
+ if (e.key === 'Escape') { e.stopPropagation(); setShowDialog(false) }
785
+ if (e.key === 'Enter' && !exporting) { e.stopPropagation(); doExport() }
786
+ }
787
+ document.addEventListener('keydown', handleKey, true)
788
+ return () => document.removeEventListener('keydown', handleKey, true)
789
+ }, [showDialog, exporting, doExport])
790
+
791
+ return (
792
+ <>
793
+ <button
794
+ className="react-flow__controls-button"
795
+ onClick={openDialog}
796
+ disabled={exporting}
797
+ title="Export as image"
798
+ >
799
+ {exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
800
+ </button>
801
+ {showDialog && (
802
+ <div
803
+ className="absolute bottom-12 left-0 z-50 bg-theme-surface border border-theme-border rounded-lg shadow-2xl p-3 w-72"
804
+ onClick={(e) => e.stopPropagation()}
805
+ >
806
+ <div className="text-sm font-medium text-theme-text-primary mb-3">Export topology</div>
807
+ <label className="block text-xs text-theme-text-secondary mb-1">Filename</label>
808
+ <input
809
+ ref={inputRef}
810
+ type="text"
811
+ value={filename}
812
+ onChange={(e) => setFilename(e.target.value)}
813
+ className="w-full px-2 py-1.5 text-sm bg-theme-base border border-theme-border rounded text-theme-text-primary outline-none focus:border-blue-500 mb-3"
814
+ />
815
+ <label className="block text-xs text-theme-text-secondary mb-1">Capture</label>
816
+ <div className="flex gap-1 mb-3">
817
+ {(['full', 'viewport'] as const).map(mode => (
818
+ <button
819
+ key={mode}
820
+ onClick={() => setCaptureMode(mode)}
821
+ className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${captureMode === mode ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
822
+ >
823
+ {mode === 'full' ? 'Entire graph' : 'Visible area'}
824
+ </button>
825
+ ))}
826
+ </div>
827
+ <div className="flex items-center gap-3 mb-2">
828
+ <div className="flex-1">
829
+ <label className="block text-xs text-theme-text-secondary mb-1">Format</label>
830
+ <div className="flex gap-1">
831
+ {(['image/webp', 'image/png'] as ImageFormat[]).map(f => (
832
+ <button
833
+ key={f}
834
+ onClick={() => setFormat(f)}
835
+ className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${format === f ? 'bg-blue-600 text-white' : 'bg-theme-base text-theme-text-secondary hover:text-theme-text-primary border border-theme-border'}`}
836
+ >
837
+ {FORMAT_LABELS[f]}
838
+ </button>
839
+ ))}
840
+ </div>
841
+ </div>
842
+ {captureMode === 'viewport' && (
843
+ <div className="flex-1">
844
+ <label className="block text-xs text-theme-text-secondary mb-1">Quality</label>
845
+ <select
846
+ value={scale}
847
+ onChange={(e) => setScale(Number(e.target.value))}
848
+ className="w-full px-2 py-1.5 text-sm bg-theme-base border border-theme-border rounded text-theme-text-primary outline-none focus:border-blue-500"
849
+ >
850
+ <option value={1}>Standard</option>
851
+ <option value={2}>High (2x)</option>
852
+ <option value={3}>Ultra (3x)</option>
853
+ </select>
854
+ </div>
855
+ )}
856
+ </div>
857
+ {dims && (
858
+ <div className="text-[10px] text-theme-text-tertiary mb-2">
859
+ Output: {dims.pw} × {dims.ph} px
860
+ </div>
861
+ )}
862
+ <div className="flex items-center mb-3">
863
+ <label className="flex items-center gap-2 cursor-pointer">
864
+ <input
865
+ type="checkbox"
866
+ checked={transparent}
867
+ onChange={(e) => setTransparent(e.target.checked)}
868
+ className="rounded"
869
+ />
870
+ <span className="text-xs text-theme-text-secondary">Transparent background</span>
871
+ </label>
872
+ </div>
873
+ <div className="flex gap-2">
874
+ <button
875
+ onClick={() => setShowDialog(false)}
876
+ className="flex-1 px-3 py-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded transition-colors"
877
+ >
878
+ Cancel
879
+ </button>
880
+ <button
881
+ onClick={doExport}
882
+ disabled={exporting}
883
+ className="flex-1 px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
884
+ >
885
+ {exporting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Download className="w-3 h-3" />}
886
+ Export
887
+ </button>
888
+ </div>
889
+ </div>
890
+ )}
891
+ {exporting && (
892
+ <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40">
893
+ <div className="bg-theme-surface border border-theme-border rounded-lg px-5 py-4 shadow-2xl">
894
+ <div className="text-sm text-theme-text-primary animate-pulse">Exporting topology…</div>
895
+ </div>
896
+ </div>
897
+ )}
898
+ </>
899
+ )
900
+ }
901
+
639
902
  // Animation duration for viewport transitions
640
903
  const VIEWPORT_ANIMATION_DURATION = 400
641
904
 
@@ -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
  }