@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.
- package/package.json +4 -1
- package/src/components/resources/ResourcesView.tsx +50 -43
- package/src/components/resources/renderers/HPARenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +2 -1
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +7 -6
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +2 -1
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +3 -2
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +3 -2
- package/src/components/resources/renderers/VPARenderer.tsx +2 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
- package/src/components/shared/ResourceActionsBar.tsx +141 -142
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -4
- package/src/components/shared/index.ts +1 -1
- package/src/components/topology/TopologyGraph.tsx +282 -19
- package/src/components/ui/ConfirmDialog.tsx +85 -130
- package/src/components/ui/DialogPortal.tsx +82 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +21 -1
- package/src/types/core.ts +1 -2
- package/src/utils/navigation.test.ts +142 -0
- package/src/utils/navigation.ts +52 -47
|
@@ -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,
|
|
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 &&
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
67
|
-
<
|
|
68
|
-
{/*
|
|
69
|
-
<div
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
75
|
+
{/* Custom content */}
|
|
76
|
+
{children && (
|
|
77
|
+
<div className="px-4 pt-4">
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
126
81
|
|
|
127
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
</
|
|
186
|
-
document.body
|
|
141
|
+
</DialogPortal>
|
|
187
142
|
)
|
|
188
143
|
}
|