@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.
- package/package.json +8 -2
- package/src/components/dock/BottomDock.tsx +14 -15
- package/src/components/dock/DockContext.tsx +40 -1
- package/src/components/dock/LocalTerminalTab.tsx +241 -0
- package/src/components/dock/NodeTerminalTab.tsx +129 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +18 -67
- package/src/components/logs/LogCore.tsx +30 -4
- package/src/components/logs/StructuredLogLine.tsx +168 -0
- package/src/components/logs/index.ts +1 -1
- package/src/components/logs/useLogBuffer.ts +16 -10
- package/src/components/resources/ResourcesView.tsx +186 -67
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +207 -0
- 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/PodRenderer.tsx +164 -8
- package/src/components/resources/renderers/VPARenderer.tsx +2 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +53 -55
- package/src/components/resources/renderers/contour-cells.tsx +48 -0
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/renderers/trivy-shared.tsx +9 -25
- package/src/components/resources/resource-utils-contour.ts +34 -0
- package/src/components/resources/resource-utils.ts +91 -0
- package/src/components/shared/ResourceActionsBar.tsx +276 -144
- package/src/components/shared/ResourceRendererDispatch.tsx +59 -5
- package/src/components/shared/index.ts +1 -1
- package/src/components/timeline/shared.tsx +8 -10
- package/src/components/topology/K8sResourceNode.tsx +1 -0
- package/src/components/topology/TopologyFilterSidebar.tsx +3 -0
- package/src/components/topology/TopologyGraph.tsx +2 -17
- package/src/components/topology/layout.ts +1 -0
- package/src/components/topology/topology.css +1 -0
- package/src/components/ui/CodeViewer.tsx +1 -2
- package/src/components/ui/ConfirmDialog.tsx +85 -130
- package/src/components/ui/DialogPortal.tsx +82 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +84 -10
- package/src/components/ui/Toast.tsx +5 -5
- package/src/components/ui/drawer-components.tsx +28 -22
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +30 -2
- package/src/theme/index.ts +3 -0
- package/src/theme/tailwind-theme.css +61 -0
- package/src/theme/variables.css +121 -0
- package/src/types/core.ts +13 -2
- package/src/utils/animation.ts +1 -1
- package/src/utils/badge-colors.ts +158 -123
- package/src/utils/log-format.ts +127 -35
- package/src/utils/navigation.test.ts +142 -0
- package/src/utils/navigation.ts +52 -47
- package/src/utils/resource-hierarchy.ts +5 -0
- 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' && <
|
|
281
|
-
{['deployments', 'statefulsets', 'daemonsets'].includes(kind) && <
|
|
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') && <
|
|
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' && <
|
|
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
|
-
|
|
408
|
-
return 'bg-blue-500/60'
|
|
407
|
+
return 'bg-blue-500/60 dark:bg-blue-500/60'
|
|
409
408
|
case 'degraded':
|
|
410
|
-
|
|
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,
|
|
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 &&
|
|
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
|
|
@@ -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
|
|
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 {
|
|
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
|
}
|
|
@@ -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
|
+
}
|