@tldiagram/core-ui 1.87.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 (272) hide show
  1. package/dist/App.d.ts +1 -0
  2. package/dist/api/client.d.ts +143 -0
  3. package/dist/api/transport-vscode.d.ts +8 -0
  4. package/dist/api/transport.d.ts +1 -0
  5. package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
  6. package/dist/components/CodePreviewPanel.d.ts +9 -0
  7. package/dist/components/ConfirmDialog.d.ts +12 -0
  8. package/dist/components/ConnectorPanel.d.ts +21 -0
  9. package/dist/components/ContextBoundaryElement.d.ts +11 -0
  10. package/dist/components/ContextNeighborElement.d.ts +29 -0
  11. package/dist/components/ContextStraightConnector.d.ts +4 -0
  12. package/dist/components/CrossBranchControls.d.ts +9 -0
  13. package/dist/components/DependenciesOnboarding.d.ts +5 -0
  14. package/dist/components/DrawingCanvas.d.ts +39 -0
  15. package/dist/components/ElementLibrary-vscode.d.ts +7 -0
  16. package/dist/components/ElementLibrary.d.ts +22 -0
  17. package/dist/components/ElementNode.d.ts +36 -0
  18. package/dist/components/ElementPanel.d.ts +25 -0
  19. package/dist/components/ExploreOnboarding.d.ts +5 -0
  20. package/dist/components/ExplorePageOnboarding.d.ts +5 -0
  21. package/dist/components/ExportModal.d.ts +16 -0
  22. package/dist/components/FloatingEdge.d.ts +9 -0
  23. package/dist/components/GitSourceLinker.d.ts +8 -0
  24. package/dist/components/HeaderContext.d.ts +16 -0
  25. package/dist/components/Icons.d.ts +95 -0
  26. package/dist/components/ImportModal.d.ts +10 -0
  27. package/dist/components/InlineElementAdder.d.ts +17 -0
  28. package/dist/components/LayoutSection.d.ts +7 -0
  29. package/dist/components/LocalSourceLinker.d.ts +8 -0
  30. package/dist/components/MiniZoomOnboarding.d.ts +5 -0
  31. package/dist/components/NavBreadcrumb.d.ts +6 -0
  32. package/dist/components/NodeBody.d.ts +12 -0
  33. package/dist/components/NodeContainer.d.ts +8 -0
  34. package/dist/components/NodeHoverCard.d.ts +10 -0
  35. package/dist/components/PanelHeader.d.ts +8 -0
  36. package/dist/components/PanelUI.d.ts +3 -0
  37. package/dist/components/ProxyConnectorEdge.d.ts +4 -0
  38. package/dist/components/ProxyConnectorPanel.d.ts +9 -0
  39. package/dist/components/SafeBackground.d.ts +13 -0
  40. package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
  41. package/dist/components/SetChildModal.d.ts +10 -0
  42. package/dist/components/SetParentModal.d.ts +10 -0
  43. package/dist/components/SlidingPanel.d.ts +16 -0
  44. package/dist/components/TagUpsert.d.ts +8 -0
  45. package/dist/components/TopMenuBar.d.ts +8 -0
  46. package/dist/components/ViewBezierConnector.d.ts +4 -0
  47. package/dist/components/ViewDrawMenu.d.ts +22 -0
  48. package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
  49. package/dist/components/ViewEditorOnboarding.d.ts +5 -0
  50. package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
  51. package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
  52. package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
  53. package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
  54. package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
  55. package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
  56. package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
  57. package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
  58. package/dist/components/ViewExplorer/index.d.ts +31 -0
  59. package/dist/components/ViewExplorer/types.d.ts +11 -0
  60. package/dist/components/ViewExplorer/utils.d.ts +6 -0
  61. package/dist/components/ViewExplorer-vscode.d.ts +6 -0
  62. package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
  63. package/dist/components/ViewFloatingMenu.d.ts +39 -0
  64. package/dist/components/ViewGridNode.d.ts +29 -0
  65. package/dist/components/ViewHeaderButton.d.ts +11 -0
  66. package/dist/components/ViewPanel.d.ts +18 -0
  67. package/dist/components/ViewsGridOnboarding.d.ts +5 -0
  68. package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
  69. package/dist/components/ZUI/index.d.ts +2 -0
  70. package/dist/components/ZUI/layout.d.ts +18 -0
  71. package/dist/components/ZUI/proxy.d.ts +25 -0
  72. package/dist/components/ZUI/renderer.d.ts +30 -0
  73. package/dist/components/ZUI/types.d.ts +140 -0
  74. package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
  75. package/dist/config/runtime-vscode.d.ts +22 -0
  76. package/dist/config/runtime.d.ts +5 -0
  77. package/dist/constants/colors.d.ts +27 -0
  78. package/dist/constants/diagramColors.d.ts +1 -0
  79. package/dist/context/ThemeContext.d.ts +27 -0
  80. package/dist/crossBranch/graph.d.ts +13 -0
  81. package/dist/crossBranch/resolve.d.ts +22 -0
  82. package/dist/crossBranch/settings.d.ts +6 -0
  83. package/dist/crossBranch/store.d.ts +11 -0
  84. package/dist/crossBranch/types.d.ts +96 -0
  85. package/dist/demo/DemoPage.d.ts +9 -0
  86. package/dist/demo/seed.d.ts +9 -0
  87. package/dist/demo/store.d.ts +137 -0
  88. package/dist/demo/viewEditor.d.ts +26 -0
  89. package/dist/favicon.svg +35 -0
  90. package/dist/hooks/useSafeFitView.d.ts +16 -0
  91. package/dist/index.css +1 -0
  92. package/dist/index.d.ts +115 -0
  93. package/dist/index.js +19966 -0
  94. package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
  95. package/dist/lib/vscodeBridge.d.ts +5 -0
  96. package/dist/logo-120.png +0 -0
  97. package/dist/logo-bw.png +0 -0
  98. package/dist/logo-bw.svg +15 -0
  99. package/dist/logo-text.svg +51 -0
  100. package/dist/logo.svg +35 -0
  101. package/dist/pages/AppearanceSettings.d.ts +3 -0
  102. package/dist/pages/Dependencies.d.ts +1 -0
  103. package/dist/pages/InfiniteZoom.d.ts +7 -0
  104. package/dist/pages/Settings.d.ts +7 -0
  105. package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
  106. package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
  107. package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
  108. package/dist/pages/ViewEditor/context.d.ts +13 -0
  109. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
  110. package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
  111. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
  112. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
  113. package/dist/pages/ViewEditor/index.d.ts +8 -0
  114. package/dist/pages/ViewEditor/utils.d.ts +14 -0
  115. package/dist/pages/Views.d.ts +6 -0
  116. package/dist/pages/ViewsGrid.d.ts +6 -0
  117. package/dist/pkg/importer/mermaid.d.ts +7 -0
  118. package/dist/pkg/importer/mermaid.test.d.ts +1 -0
  119. package/dist/platform/PlatformContext.d.ts +6 -0
  120. package/dist/platform/context.d.ts +3 -0
  121. package/dist/platform/local.d.ts +2 -0
  122. package/dist/platform/types.d.ts +17 -0
  123. package/dist/slots.d.ts +67 -0
  124. package/dist/theme.d.ts +2 -0
  125. package/dist/types/index.d.ts +193 -0
  126. package/dist/types/vscode-messages.d.ts +60 -0
  127. package/dist/utils/edgeDistribution.d.ts +34 -0
  128. package/dist/utils/githubApi.d.ts +4 -0
  129. package/dist/utils/githubCache.d.ts +17 -0
  130. package/dist/utils/ids.d.ts +2 -0
  131. package/dist/utils/technologyCatalog.d.ts +15 -0
  132. package/dist/utils/toast.d.ts +15 -0
  133. package/dist/utils/treesitter.d.ts +13 -0
  134. package/dist/utils/url.d.ts +12 -0
  135. package/package.json +159 -0
  136. package/src/App.tsx +141 -0
  137. package/src/api/client.ts +618 -0
  138. package/src/api/transport-vscode.ts +28 -0
  139. package/src/api/transport.ts +7 -0
  140. package/src/assets/logo-mark.svg +31 -0
  141. package/src/assets/logo-wordmark.svg +22 -0
  142. package/src/assets/logo.svg +35 -0
  143. package/src/components/CodePreviewPanel-vscode.tsx +85 -0
  144. package/src/components/CodePreviewPanel.tsx +384 -0
  145. package/src/components/ConfirmDialog.tsx +66 -0
  146. package/src/components/ConnectorPanel.tsx +403 -0
  147. package/src/components/ContextBoundaryElement.tsx +35 -0
  148. package/src/components/ContextNeighborElement.tsx +282 -0
  149. package/src/components/ContextStraightConnector.tsx +144 -0
  150. package/src/components/CrossBranchControls.tsx +105 -0
  151. package/src/components/DependenciesOnboarding.tsx +427 -0
  152. package/src/components/DrawingCanvas.tsx +391 -0
  153. package/src/components/ElementLibrary-vscode.tsx +9 -0
  154. package/src/components/ElementLibrary.tsx +512 -0
  155. package/src/components/ElementNode.tsx +1033 -0
  156. package/src/components/ElementPanel.tsx +928 -0
  157. package/src/components/ExploreOnboarding.tsx +347 -0
  158. package/src/components/ExplorePageOnboarding.tsx +383 -0
  159. package/src/components/ExportModal.tsx +132 -0
  160. package/src/components/FloatingEdge.tsx +115 -0
  161. package/src/components/GitSourceLinker.tsx +1053 -0
  162. package/src/components/HeaderContext.tsx +30 -0
  163. package/src/components/Icons.tsx +245 -0
  164. package/src/components/ImportModal.tsx +219 -0
  165. package/src/components/InlineElementAdder.tsx +216 -0
  166. package/src/components/LayoutSection.tsx +624 -0
  167. package/src/components/LocalSourceLinker.tsx +330 -0
  168. package/src/components/MiniZoomOnboarding.tsx +78 -0
  169. package/src/components/NavBreadcrumb.tsx +24 -0
  170. package/src/components/NodeBody.tsx +89 -0
  171. package/src/components/NodeContainer.tsx +58 -0
  172. package/src/components/NodeHoverCard.tsx +135 -0
  173. package/src/components/PanelHeader.tsx +36 -0
  174. package/src/components/PanelUI.tsx +24 -0
  175. package/src/components/ProxyConnectorEdge.tsx +169 -0
  176. package/src/components/ProxyConnectorPanel.tsx +130 -0
  177. package/src/components/SafeBackground.tsx +19 -0
  178. package/src/components/ScrollIndicatorWrapper.tsx +117 -0
  179. package/src/components/SetChildModal.tsx +191 -0
  180. package/src/components/SetParentModal.tsx +187 -0
  181. package/src/components/SlidingPanel.tsx +114 -0
  182. package/src/components/TagUpsert.tsx +142 -0
  183. package/src/components/TopMenuBar.tsx +380 -0
  184. package/src/components/ViewBezierConnector.tsx +143 -0
  185. package/src/components/ViewDrawMenu.tsx +270 -0
  186. package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
  187. package/src/components/ViewEditorOnboarding.tsx +445 -0
  188. package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
  189. package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
  190. package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
  191. package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
  192. package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
  193. package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
  194. package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
  195. package/src/components/ViewExplorer/ViewTree.tsx +98 -0
  196. package/src/components/ViewExplorer/index.tsx +384 -0
  197. package/src/components/ViewExplorer/types.ts +13 -0
  198. package/src/components/ViewExplorer/utils.ts +56 -0
  199. package/src/components/ViewExplorer-vscode.tsx +8 -0
  200. package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
  201. package/src/components/ViewFloatingMenu.tsx +379 -0
  202. package/src/components/ViewGridNode.tsx +451 -0
  203. package/src/components/ViewHeaderButton.tsx +60 -0
  204. package/src/components/ViewPanel.tsx +162 -0
  205. package/src/components/ViewsGridOnboarding.tsx +400 -0
  206. package/src/components/ZUI/ZUICanvas.tsx +853 -0
  207. package/src/components/ZUI/index.ts +3 -0
  208. package/src/components/ZUI/layout.ts +323 -0
  209. package/src/components/ZUI/proxy.ts +278 -0
  210. package/src/components/ZUI/renderer.ts +1189 -0
  211. package/src/components/ZUI/types.ts +150 -0
  212. package/src/components/ZUI/useZUIInteraction.ts +720 -0
  213. package/src/config/runtime-vscode.ts +46 -0
  214. package/src/config/runtime.ts +30 -0
  215. package/src/constants/colors.ts +80 -0
  216. package/src/constants/diagramColors.ts +9 -0
  217. package/src/context/ThemeContext.tsx +158 -0
  218. package/src/crossBranch/graph.ts +207 -0
  219. package/src/crossBranch/resolve.ts +643 -0
  220. package/src/crossBranch/settings.ts +59 -0
  221. package/src/crossBranch/store.ts +71 -0
  222. package/src/crossBranch/types.ts +102 -0
  223. package/src/demo/DemoPage.tsx +184 -0
  224. package/src/demo/seed.ts +67 -0
  225. package/src/demo/store.ts +536 -0
  226. package/src/demo/viewEditor.ts +110 -0
  227. package/src/hooks/useSafeFitView.ts +60 -0
  228. package/src/index.css +309 -0
  229. package/src/index.ts +184 -0
  230. package/src/kafka-ss.png +0 -0
  231. package/src/lib/vscodeBridge-vscode.ts +27 -0
  232. package/src/lib/vscodeBridge.ts +7 -0
  233. package/src/main.tsx +46 -0
  234. package/src/pages/AppearanceSettings.tsx +135 -0
  235. package/src/pages/Dependencies.tsx +926 -0
  236. package/src/pages/InfiniteZoom.tsx +404 -0
  237. package/src/pages/Settings.tsx +91 -0
  238. package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
  239. package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
  240. package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
  241. package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
  242. package/src/pages/ViewEditor/context.tsx +21 -0
  243. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
  244. package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
  245. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
  246. package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
  247. package/src/pages/ViewEditor/index.tsx +1366 -0
  248. package/src/pages/ViewEditor/utils.ts +88 -0
  249. package/src/pages/Views.tsx +171 -0
  250. package/src/pages/ViewsGrid.tsx +1310 -0
  251. package/src/pkg/importer/mermaid.test.ts +141 -0
  252. package/src/pkg/importer/mermaid.ts +76 -0
  253. package/src/platform/PlatformContext.tsx +17 -0
  254. package/src/platform/context.ts +9 -0
  255. package/src/platform/local.tsx +15 -0
  256. package/src/platform/types.ts +19 -0
  257. package/src/slots.ts +92 -0
  258. package/src/styles/editor-panels.css +66 -0
  259. package/src/styles/theme.css +56 -0
  260. package/src/theme.ts +336 -0
  261. package/src/types/index.ts +234 -0
  262. package/src/types/offline-ambient.d.ts +14 -0
  263. package/src/types/vscode-messages.ts +32 -0
  264. package/src/utils/edgeDistribution.ts +103 -0
  265. package/src/utils/githubApi.ts +121 -0
  266. package/src/utils/githubCache.ts +108 -0
  267. package/src/utils/ids.ts +9 -0
  268. package/src/utils/technologyCatalog.ts +143 -0
  269. package/src/utils/toast.ts +100 -0
  270. package/src/utils/treesitter.ts +147 -0
  271. package/src/utils/url.ts +72 -0
  272. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,928 @@
1
+ import { memo, useEffect, useRef, useState, useCallback } from 'react'
2
+ import type { ElementPanelSlots } from '../slots'
3
+ import { useNavigate } from 'react-router-dom'
4
+ import {
5
+ Badge,
6
+ Box,
7
+ Button,
8
+ CloseButton,
9
+ Divider,
10
+ FormControl,
11
+ FormLabel,
12
+ HStack,
13
+ Input,
14
+ InputGroup,
15
+ InputRightElement,
16
+ Popover,
17
+ PopoverArrow,
18
+ PopoverBody,
19
+ PopoverContent,
20
+ PopoverTrigger,
21
+ Radio,
22
+ RadioGroup,
23
+ Tag,
24
+ TagCloseButton,
25
+ TagLabel,
26
+ Text,
27
+ Textarea,
28
+ useBreakpointValue,
29
+ useDisclosure,
30
+ VStack,
31
+ Wrap,
32
+ WrapItem,
33
+ } from '@chakra-ui/react'
34
+
35
+ import { api } from '../api/client'
36
+ import { ELEMENT_TYPES, type LibraryElement, type ViewConnector, type TechnologyCatalogItem, type TechnologyConnector } from '../types'
37
+ import ConfirmDialog from './ConfirmDialog'
38
+ import SlidingPanel from './SlidingPanel'
39
+ import PanelHeader from './PanelHeader'
40
+ import GitSourceLinker from './GitSourceLinker'
41
+ import { getTechnologyCatalogIndex, getTechnologyCatalogItemBySlug, resolveWithBase, searchTechnologyCatalog } from '../utils/technologyCatalog'
42
+ import { ZoomInIcon, ZoomOutIcon } from './Icons'
43
+ import ScrollIndicatorWrapper from './ScrollIndicatorWrapper'
44
+ import TagUpsert from './TagUpsert'
45
+
46
+ import { useViewEditorContext } from '../pages/ViewEditor/context'
47
+
48
+ export interface ElementPanelProps extends ElementPanelSlots {
49
+ isOpen: boolean
50
+ onClose: () => void
51
+ element?: LibraryElement | null
52
+ onSave: (obj: LibraryElement) => void
53
+ autoSave?: boolean
54
+ onDelete?: (id: number) => void
55
+ onPermanentDelete?: (id: number) => void
56
+ orgId?: string
57
+ links?: ViewConnector[]
58
+ parentLinks?: ViewConnector[]
59
+ hasBackdrop?: boolean
60
+ availableTags?: string[]
61
+ }
62
+
63
+ /**
64
+ * Name: Edit Element Panel
65
+ * Role: Opens when clicked on an element and displays its fields, allowing for editing.
66
+ * Location: Right side of the screen on desktop. Overlays screen on mobile.
67
+ * Aliases: Element Properties, Element Details.
68
+ */
69
+ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDelete, onPermanentDelete, orgId, links = [], parentLinks = [], hasBackdrop = true, availableTags = [], elementPanelAfterContentSlot }: ElementPanelProps) {
70
+ const { canEdit, viewId } = useViewEditorContext()
71
+ const isEdit = !!element
72
+ const isReadOnly = !canEdit
73
+ const autoSaveEdit = autoSave && isEdit && !isReadOnly
74
+ const navigate = useNavigate()
75
+ const [name, setName] = useState('')
76
+ const [description, setDescription] = useState('')
77
+ const [type, setType] = useState('')
78
+ const [typeQuery, setTypeQuery] = useState('')
79
+ const [typeResults, setTypeResults] = useState<string[]>([])
80
+ const [url, setUrl] = useState('')
81
+ const [technologyLinks, setTechnologyConnectors] = useState<TechnologyConnector[]>([])
82
+ const [technologyQuery, setTechnologyQuery] = useState('')
83
+ const [technologyResults, setTechnologyResults] = useState<TechnologyCatalogItem[]>([])
84
+ const [technologyMeta, setTechnologyMeta] = useState<Record<string, TechnologyCatalogItem>>({})
85
+ const [technologySearchLoading, setTechnologySearchLoading] = useState(false)
86
+ const [tags, setTags] = useState<string[]>([])
87
+ const [loading, setLoading] = useState(false)
88
+ const [explicitLogoClear, setExplicitLogoClear] = useState(false)
89
+ const typeInputRef = useRef<HTMLInputElement>(null)
90
+ const techInputRef = useRef<HTMLInputElement>(null)
91
+ const suppressTypeBlurRef = useRef(false)
92
+ const lastSavedFingerprintRef = useRef<string>('')
93
+ const savingRef = useRef(false)
94
+ const pendingSaveRef = useRef(false)
95
+ const [techResultIndex, setTechResultIndex] = useState(-1)
96
+ const confirmPermanentDelete = useDisclosure()
97
+ const isMobile = useBreakpointValue({ base: true, md: false }) ?? false
98
+
99
+ useEffect(() => {
100
+ setTechResultIndex(-1)
101
+ }, [technologyQuery])
102
+
103
+ useEffect(() => {
104
+ if (element) {
105
+ setName(element.name)
106
+ setDescription(element.description ?? '')
107
+ setType(element.kind ?? '')
108
+ setTypeQuery('')
109
+ setTypeResults([])
110
+ setUrl(element.url ?? '')
111
+ const linksFromElement = element.technology_connectors ?? []
112
+ if (linksFromElement.length > 0) {
113
+ setTechnologyConnectors(linksFromElement)
114
+ } else if (element.technology) {
115
+ setTechnologyConnectors([{ type: 'custom', label: element.technology }])
116
+ } else {
117
+ setTechnologyConnectors([])
118
+ }
119
+ setTags(element.tags ?? [])
120
+ setExplicitLogoClear(false)
121
+
122
+ // Initialize autosave fingerprint based on a payload normalized the same way as saves.
123
+ const initialLinks: TechnologyConnector[] = linksFromElement.length > 0
124
+ ? linksFromElement
125
+ : (element.technology ? [{ type: 'custom', label: element.technology }] : [])
126
+ const normalizedLinks = initialLinks.map((link) => ({
127
+ type: link.type,
128
+ slug: link.type === 'catalog' ? link.slug : undefined,
129
+ label: link.label,
130
+ is_primary_icon: !!link.is_primary_icon,
131
+ }))
132
+ const normalizedType = (element.kind ?? '').trim().toLowerCase()
133
+ const technology = initialLinks.map((link) => link.label).join(', ')
134
+ lastSavedFingerprintRef.current = JSON.stringify({
135
+ name: element.name,
136
+ description: element.description ?? '',
137
+ kind: normalizedType,
138
+ technology,
139
+ url: element.url ?? '',
140
+ logo_url: element.logo_url ?? '',
141
+ technology_connectors: normalizedLinks,
142
+ tags: element.tags ?? [],
143
+ repo: element.repo,
144
+ branch: element.branch,
145
+ file_path: element.file_path,
146
+ language: element.language,
147
+ })
148
+ } else {
149
+ setName('')
150
+ setDescription('')
151
+ setType('')
152
+ setTypeQuery('')
153
+ setTypeResults([])
154
+ setUrl('')
155
+ setTechnologyConnectors([])
156
+ setTechnologyQuery('')
157
+ setTechnologyResults([])
158
+ setTechnologyMeta({})
159
+ setTags([])
160
+ setExplicitLogoClear(false)
161
+ lastSavedFingerprintRef.current = ''
162
+ }
163
+ }, [element, isOpen])
164
+
165
+ const buildPayloadAndFingerprint = useCallback(async () => {
166
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
167
+ const primarySlug = primaryLink?.slug
168
+
169
+ const normalizedLinks = technologyLinks.map((link) => ({
170
+ type: link.type,
171
+ slug: link.type === 'catalog' ? link.slug : undefined,
172
+ label: link.label,
173
+ is_primary_icon: !!link.is_primary_icon,
174
+ }))
175
+
176
+ const normalizedType = type.trim().toLowerCase()
177
+
178
+ let logoUrl = element?.logo_url ?? ''
179
+ if (explicitLogoClear) {
180
+ logoUrl = ''
181
+ }
182
+ if (primarySlug) {
183
+ const cached = technologyMeta[primarySlug]
184
+ if (cached?.iconUrl) {
185
+ logoUrl = cached.iconUrl
186
+ } else {
187
+ try {
188
+ const item = await getTechnologyCatalogItemBySlug(primarySlug)
189
+ if (item) {
190
+ setTechnologyMeta((prev) => ({ ...prev, [primarySlug]: item }))
191
+ if (item.iconUrl) logoUrl = item.iconUrl
192
+ }
193
+ } catch {
194
+ // ignore
195
+ }
196
+ }
197
+ }
198
+
199
+ const payload = {
200
+ name,
201
+ description,
202
+ kind: normalizedType,
203
+ technology: technologyLinks.map((link) => link.label).join(', '),
204
+ url,
205
+ logo_url: logoUrl,
206
+ technology_connectors: normalizedLinks,
207
+ tags,
208
+ repo: element?.repo,
209
+ branch: element?.branch,
210
+ file_path: element?.file_path,
211
+ language: element?.language,
212
+ }
213
+ return { payload, fingerprint: JSON.stringify(payload) }
214
+ }, [technologyLinks, technologyMeta, explicitLogoClear, type, element, name, description, url, tags])
215
+
216
+ const saveIfDirty = useCallback(async () => {
217
+ if (!autoSaveEdit || !element) return
218
+ if (!name.trim()) return
219
+
220
+ if (savingRef.current) {
221
+ pendingSaveRef.current = true
222
+ return
223
+ }
224
+
225
+ savingRef.current = true
226
+ try {
227
+ const { payload, fingerprint } = await buildPayloadAndFingerprint()
228
+ if (fingerprint === lastSavedFingerprintRef.current) return
229
+ const saved = await api.elements.update(element.id, payload)
230
+ lastSavedFingerprintRef.current = fingerprint
231
+ onSave(saved)
232
+ } catch {
233
+ // ignore
234
+ } finally {
235
+ savingRef.current = false
236
+ if (pendingSaveRef.current) {
237
+ pendingSaveRef.current = false
238
+ void saveIfDirty()
239
+ }
240
+ }
241
+ }, [autoSaveEdit, element, name, buildPayloadAndFingerprint, onSave])
242
+
243
+ const saveIfDirtyRef = useRef<(() => Promise<void>) | null>(null)
244
+ useEffect(() => { saveIfDirtyRef.current = saveIfDirty }, [saveIfDirty])
245
+
246
+ const scheduleAutoSave = () => {
247
+ if (!autoSaveEdit) return
248
+ requestAnimationFrame(() => {
249
+ void saveIfDirtyRef.current?.()
250
+ })
251
+ }
252
+
253
+ const handleClose = useCallback(() => {
254
+ if (autoSaveEdit) {
255
+ void saveIfDirtyRef.current?.()
256
+ }
257
+ onClose()
258
+ }, [autoSaveEdit, onClose])
259
+
260
+ useEffect(() => {
261
+ if (!isOpen) return
262
+ const query = typeQuery.trim()
263
+ if (!query) {
264
+ setTypeResults([])
265
+ return
266
+ }
267
+
268
+ const allTypes = Array.from(new Set([
269
+ ...ELEMENT_TYPES,
270
+ ...(type ? [type] : []),
271
+ ]))
272
+
273
+ try {
274
+ const regex = new RegExp(query, 'i')
275
+ setTypeResults(allTypes.filter((t) => regex.test(t)).slice(0, 12))
276
+ } catch {
277
+ const needle = query.toLowerCase()
278
+ setTypeResults(allTypes.filter((t) => t.toLowerCase().includes(needle)).slice(0, 12))
279
+ }
280
+ }, [isOpen, typeQuery, type])
281
+
282
+ useEffect(() => {
283
+ if (!isOpen) return
284
+ const slugs = technologyLinks
285
+ .filter((link) => link.type === 'catalog' && !!link.slug)
286
+ .map((link) => link.slug as string)
287
+
288
+ if (slugs.length === 0) return
289
+
290
+ getTechnologyCatalogIndex().then((index) => {
291
+ setTechnologyMeta((prev) => {
292
+ const next = { ...prev }
293
+ for (const slug of slugs) {
294
+ const item = index.bySlug.get(slug)
295
+ if (item) next[slug] = item
296
+ }
297
+ return next
298
+ })
299
+ }).catch(() => { /* intentionally empty */ })
300
+ }, [isOpen, technologyLinks])
301
+
302
+ useEffect(() => {
303
+ if (!isOpen) return
304
+ const query = technologyQuery.trim()
305
+ if (!query) {
306
+ setTechnologyResults([])
307
+ return
308
+ }
309
+
310
+ const timer = setTimeout(() => {
311
+ setTechnologySearchLoading(true)
312
+ searchTechnologyCatalog(query)
313
+ .then((results) => {
314
+ setTechnologyResults(results)
315
+ setTechnologyMeta((prev) => {
316
+ const next = { ...prev }
317
+ for (const item of results) {
318
+ next[item.defaultSlug] = item
319
+ }
320
+ return next
321
+ })
322
+ })
323
+ .catch(() => setTechnologyResults([]))
324
+ .finally(() => setTechnologySearchLoading(false))
325
+ }, 140)
326
+
327
+ return () => clearTimeout(timer)
328
+ }, [isOpen, technologyQuery])
329
+
330
+ useEffect(() => {
331
+ if (!isOpen) return
332
+ const handler = (e: KeyboardEvent) => {
333
+ const target = e.target as HTMLElement
334
+ const isInput = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target.isContentEditable
335
+
336
+ if (e.key === 'Escape' && !isInput) handleClose()
337
+
338
+ if (e.key.toLowerCase() === 't' && !isInput && !e.ctrlKey && !e.metaKey && !e.altKey) {
339
+ e.preventDefault()
340
+ techInputRef.current?.focus()
341
+ }
342
+ }
343
+ window.addEventListener('keydown', handler)
344
+ return () => window.removeEventListener('keydown', handler)
345
+ }, [isOpen, handleClose])
346
+
347
+ const addCatalogTechnology = (item: TechnologyCatalogItem) => {
348
+ if (technologyLinks.length >= 3) return
349
+ if (technologyLinks.some((link) => link.type === 'catalog' && link.slug === item.defaultSlug)) return
350
+
351
+ const hasPrimaryCatalog = technologyLinks.some((link) => link.type === 'catalog' && !!link.is_primary_icon)
352
+
353
+ setTechnologyConnectors((prev) => ([
354
+ ...prev,
355
+ {
356
+ type: 'catalog',
357
+ slug: item.defaultSlug,
358
+ label: item.name,
359
+ is_primary_icon: !hasPrimaryCatalog,
360
+ },
361
+ ]))
362
+ setTechnologyQuery('')
363
+ setTechnologyResults([])
364
+ setTechnologyMeta((prev) => ({ ...prev, [item.defaultSlug]: item }))
365
+ setExplicitLogoClear(false)
366
+ scheduleAutoSave()
367
+ }
368
+
369
+ const addCustomTechnology = () => {
370
+ const value = technologyQuery.trim()
371
+ if (!value || technologyLinks.length >= 3) return
372
+ if (technologyLinks.some((link) => link.type === 'custom' && link.label.toLowerCase() === value.toLowerCase())) return
373
+
374
+ setTechnologyConnectors((prev) => ([...prev, { type: 'custom', label: value }]))
375
+ setTechnologyQuery('')
376
+ setTechnologyResults([])
377
+ scheduleAutoSave()
378
+ }
379
+
380
+ const removeTechnology = (linkToRemove: TechnologyConnector) => {
381
+ setTechnologyConnectors((prev) => {
382
+ const next = prev.filter((link) => !(link.type === linkToRemove.type && link.slug === linkToRemove.slug && link.label === linkToRemove.label))
383
+ const hasPrimaryCatalog = next.some((link) => link.type === 'catalog' && !!link.is_primary_icon)
384
+ if (hasPrimaryCatalog) return next
385
+
386
+ const firstCatalogIndex = next.findIndex((link) => link.type === 'catalog' && !!link.slug)
387
+ if (firstCatalogIndex === -1) return next
388
+
389
+ return next.map((link, index) => ({
390
+ ...link,
391
+ is_primary_icon: index === firstCatalogIndex,
392
+ }))
393
+ })
394
+ scheduleAutoSave()
395
+ }
396
+
397
+ const markPrimaryIcon = (selectedSlug: string) => {
398
+ setTechnologyConnectors((prev) => prev.map((link) => {
399
+ if (link.type !== 'catalog') {
400
+ return { ...link, is_primary_icon: false }
401
+ }
402
+ return {
403
+ ...link,
404
+ is_primary_icon: link.slug === selectedSlug,
405
+ }
406
+ }))
407
+ setExplicitLogoClear(false)
408
+ scheduleAutoSave()
409
+ }
410
+
411
+ const clearPrimaryIcon = () => {
412
+ setTechnologyConnectors((prev) => prev.map((link) => ({ ...link, is_primary_icon: false })))
413
+ setExplicitLogoClear(true)
414
+ scheduleAutoSave()
415
+ }
416
+
417
+ const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)?.slug ?? ''
418
+
419
+ const commitTypeFromQuery = () => {
420
+ if (isReadOnly) return
421
+ const value = typeQuery.trim().toLowerCase()
422
+ if (!value) return
423
+ setType(value)
424
+ setTypeQuery('')
425
+ setTypeResults([])
426
+ }
427
+
428
+ const clearTypeAndFocus = () => {
429
+ if (isReadOnly) return
430
+ setType('')
431
+ setTypeQuery('')
432
+ setTypeResults([])
433
+ requestAnimationFrame(() => typeInputRef.current?.focus())
434
+ }
435
+
436
+ const handleSave = async () => {
437
+ if (isReadOnly || !name.trim()) return
438
+ setLoading(true)
439
+ try {
440
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
441
+ const primaryMetadata = primaryLink?.slug
442
+ ? (technologyMeta[primaryLink.slug] ?? await getTechnologyCatalogItemBySlug(primaryLink.slug))
443
+ : null
444
+
445
+ const normalizedLinks = technologyLinks.map((link) => ({
446
+ type: link.type,
447
+ slug: link.type === 'catalog' ? link.slug : undefined,
448
+ label: link.label,
449
+ is_primary_icon: !!link.is_primary_icon,
450
+ }))
451
+
452
+ const normalizedType = type.trim().toLowerCase()
453
+
454
+ const payload = {
455
+ name,
456
+ description,
457
+ kind: normalizedType,
458
+ technology: technologyLinks.map((link) => link.label).join(', '),
459
+ url,
460
+ logo_url: primaryMetadata?.iconUrl ?? '',
461
+ technology_connectors: normalizedLinks,
462
+ tags,
463
+ repo: element?.repo,
464
+ branch: element?.branch,
465
+ file_path: element?.file_path,
466
+ language: element?.language,
467
+ }
468
+ const saved = isEdit
469
+ ? await api.elements.update(element!.id, payload)
470
+ : await api.elements.create(payload)
471
+ onSave(saved)
472
+ onClose()
473
+ } catch { /* intentionally empty */ } finally {
474
+ setLoading(false)
475
+ }
476
+ }
477
+
478
+ const handleDelete = async () => {
479
+ if (isReadOnly || !element) return
480
+ try {
481
+ if (viewId != null) {
482
+ await api.workspace.views.placements.remove(viewId, element.id)
483
+ } else if (orgId) {
484
+ await api.elements.delete(orgId, element.id)
485
+ }
486
+ onDelete?.(element.id)
487
+ onClose()
488
+ } catch { /* intentionally empty */ }
489
+ }
490
+
491
+ const handlePermanentDelete = async () => {
492
+ if (isReadOnly || !element) return
493
+ try {
494
+ if (!orgId) return
495
+ await api.elements.delete(orgId, element.id)
496
+ onPermanentDelete?.(element.id)
497
+ confirmPermanentDelete.onClose()
498
+ onClose()
499
+ } catch { /* intentionally empty */ }
500
+ }
501
+
502
+ return (
503
+ <>
504
+ <SlidingPanel isOpen={isOpen} onClose={handleClose} panelKey="element" side={isMobile ? 'left' : 'right'} width="300px" hasBackdrop={hasBackdrop}>
505
+ <PanelHeader title={isEdit ? 'Edit Element' : 'New Element'} onClose={handleClose} />
506
+
507
+ {/* Body */}
508
+ <ScrollIndicatorWrapper px={4} py={4}>
509
+ <VStack spacing={4} align="stretch">
510
+ <FormControl isRequired isDisabled={isReadOnly}>
511
+ <FormLabel>Name</FormLabel>
512
+ <Input
513
+ size="sm"
514
+ value={name}
515
+ onChange={(e) => setName(e.target.value)}
516
+ onBlur={scheduleAutoSave}
517
+ placeholder="Payment Service"
518
+ />
519
+ </FormControl>
520
+ <FormControl isDisabled={isReadOnly}>
521
+ <FormLabel>Type</FormLabel>
522
+ <VStack align="stretch" spacing={2}>
523
+ <HStack align="flex-start">
524
+ <InputGroup>
525
+ <Input
526
+ ref={typeInputRef}
527
+ size="sm"
528
+ value={typeQuery || type}
529
+ onFocus={() => {
530
+ if (isReadOnly) return
531
+ if (type && !typeQuery) setTypeQuery(type)
532
+ }}
533
+ onChange={(e) => setTypeQuery(e.target.value)}
534
+ onBlur={() => {
535
+ if (isReadOnly) return
536
+ // If the user is clicking a result, the mousedown handler will
537
+ // set suppression so we don't prematurely commit the typed query
538
+ // (which would happen before the click handler runs).
539
+ if (suppressTypeBlurRef.current) {
540
+ suppressTypeBlurRef.current = false
541
+ return
542
+ }
543
+ if (typeQuery.trim()) commitTypeFromQuery()
544
+ scheduleAutoSave()
545
+ }}
546
+ onKeyDown={(e) => {
547
+ if (e.key === 'Enter') {
548
+ e.preventDefault()
549
+ commitTypeFromQuery()
550
+ }
551
+ }}
552
+ placeholder="type to search or create"
553
+ isDisabled={isReadOnly}
554
+ />
555
+ {!!type && (
556
+ <InputRightElement h="full">
557
+ <CloseButton
558
+ size="sm"
559
+ onClick={(e) => {
560
+ e.preventDefault()
561
+ e.stopPropagation()
562
+ clearTypeAndFocus()
563
+ }}
564
+ />
565
+ </InputRightElement>
566
+ )}
567
+ </InputGroup>
568
+ </HStack>
569
+
570
+ {!isReadOnly && typeQuery.trim() && typeQuery.trim().toLowerCase() !== (type || '').trim().toLowerCase() && (
571
+ <Box border="1px solid" borderColor="whiteAlpha.200" rounded="md" bg="blackAlpha.300" maxH="140px" overflowY="auto">
572
+ <VStack spacing={0} align="stretch">
573
+ {typeResults.map((t) => (
574
+ <Box
575
+ key={t}
576
+ px={2}
577
+ py={2}
578
+ cursor="pointer"
579
+ _hover={{ bg: 'whiteAlpha.100' }}
580
+ onMouseDown={() => { suppressTypeBlurRef.current = true }}
581
+ onClick={() => {
582
+ setType(t)
583
+ setTypeQuery('')
584
+ setTypeResults([])
585
+ // release suppression after handling click
586
+ setTimeout(() => { suppressTypeBlurRef.current = false }, 0)
587
+ scheduleAutoSave()
588
+ }}
589
+ >
590
+ <Text fontSize="sm" color="white" letterSpacing="0.05em">{t}</Text>
591
+ </Box>
592
+ ))}
593
+ {typeResults.length === 0 && (
594
+ <Box
595
+ px={2}
596
+ py={2}
597
+ cursor="pointer"
598
+ _hover={{ bg: 'whiteAlpha.100' }}
599
+ onMouseDown={() => { suppressTypeBlurRef.current = true }}
600
+ onClick={() => {
601
+ commitTypeFromQuery()
602
+ setTimeout(() => { suppressTypeBlurRef.current = false }, 0)
603
+ scheduleAutoSave()
604
+ }}
605
+ >
606
+ <Text fontSize="xs" color="gray.300">No match. Press Enter to set “{typeQuery.trim()}”.</Text>
607
+ </Box>
608
+ )}
609
+ </VStack>
610
+ </Box>
611
+ )}
612
+ </VStack>
613
+ </FormControl>
614
+ <FormControl isDisabled={isReadOnly}>
615
+ <FormLabel>Description</FormLabel>
616
+ <Textarea
617
+ size="sm"
618
+ value={description}
619
+ onChange={(e) => setDescription(e.target.value)}
620
+ onBlur={scheduleAutoSave}
621
+ placeholder="What does this element do?"
622
+ rows={3}
623
+ />
624
+ </FormControl>
625
+ <FormControl isDisabled={isReadOnly}>
626
+ <FormLabel>Technology</FormLabel>
627
+ <VStack align="stretch" spacing={2}>
628
+ <HStack align="flex-start">
629
+ <Input
630
+ ref={techInputRef}
631
+ size="sm"
632
+ value={technologyQuery}
633
+ onChange={(e) => setTechnologyQuery(e.target.value)}
634
+ onKeyDown={(e) => {
635
+ if (e.key === 'ArrowDown') {
636
+ e.preventDefault()
637
+ setTechResultIndex((prev) => Math.min(prev + 1, technologyResults.length - 1))
638
+ } else if (e.key === 'ArrowUp') {
639
+ e.preventDefault()
640
+ setTechResultIndex((prev) => Math.max(prev - 1, -1))
641
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
642
+ if (techResultIndex >= 0 && technologyResults[techResultIndex]) {
643
+ e.preventDefault()
644
+ addCatalogTechnology(technologyResults[techResultIndex])
645
+ } else if (e.key === 'Enter' && technologyQuery.trim()) {
646
+ e.preventDefault()
647
+ addCustomTechnology()
648
+ }
649
+ } else if (e.key === 'Escape') {
650
+ e.preventDefault()
651
+ e.stopPropagation()
652
+ setTechnologyQuery('')
653
+ setTechResultIndex(-1)
654
+ techInputRef.current?.blur()
655
+ }
656
+ }}
657
+ placeholder="Regex or text (e.g. kafka|rabbitmq)"
658
+ isDisabled={isReadOnly || technologyLinks.length >= 3}
659
+ />
660
+ <Button
661
+ size="sm"
662
+ onClick={addCustomTechnology}
663
+ isDisabled={isReadOnly || technologyLinks.length >= 3 || !technologyQuery.trim()}
664
+ >
665
+ Add
666
+ </Button>
667
+ </HStack>
668
+
669
+ {!isReadOnly && technologyQuery.trim() && technologyLinks.length < 3 && (
670
+ <Box border="1px solid" borderColor="whiteAlpha.200" rounded="md" bg="blackAlpha.300" maxH="190px" overflowY="auto">
671
+ <VStack spacing={0} align="stretch">
672
+ {technologyResults.map((item, idx) => (
673
+ <Box
674
+ key={item.defaultSlug}
675
+ px={2}
676
+ py={2}
677
+ cursor="pointer"
678
+ bg={idx === techResultIndex ? 'whiteAlpha.200' : 'transparent'}
679
+ _hover={{ bg: 'whiteAlpha.100' }}
680
+ onClick={() => addCatalogTechnology(item)}
681
+ >
682
+ <HStack justify="space-between" align="center">
683
+ <HStack spacing={2} minW={0}>
684
+ <Box as="img" src={resolveWithBase(item.iconUrl)} alt={item.name} boxSize="18px" objectFit="contain" />
685
+ <Text fontSize="sm" color="white" noOfLines={1}>{item.name}</Text>
686
+ </HStack>
687
+ {item.provider && (
688
+ <Badge variant="subtle" colorScheme="blue" fontSize="8px">{item.provider}</Badge>
689
+ )}
690
+ </HStack>
691
+ </Box>
692
+ ))}
693
+ {technologySearchLoading && (
694
+ <Text px={2} py={2} fontSize="xs" color="gray.400">Searching...</Text>
695
+ )}
696
+ {!technologySearchLoading && technologyResults.length === 0 && (
697
+ <Text px={2} py={2} fontSize="xs" color="gray.400">No match in catalog. Use Add Custom.</Text>
698
+ )}
699
+ </VStack>
700
+ </Box>
701
+ )}
702
+
703
+ <Wrap>
704
+ {technologyLinks.map((link) => {
705
+ const meta = link.slug ? technologyMeta[link.slug] : undefined
706
+ const sourceUrl = meta?.websiteUrl || meta?.docsUrl
707
+ return (
708
+ <WrapItem key={`${link.type}:${link.slug ?? link.label}`}>
709
+ <Popover trigger={isMobile ? 'click' : 'hover'} placement="top" closeOnBlur>
710
+ <PopoverTrigger>
711
+ <Tag size="sm" variant="subtle" bg="whiteAlpha.100" border="1px solid" borderColor="whiteAlpha.200" cursor="pointer">
712
+ <TagLabel color="white">
713
+ {link.type === 'catalog' && meta && (
714
+ <Box as="img" src={resolveWithBase(meta.iconUrl)} alt={link.label} boxSize="12px" objectFit="contain" display="inline-block" mr={1.5} verticalAlign="middle" />
715
+ )}
716
+ {link.label}
717
+ </TagLabel>
718
+ {!isReadOnly && (
719
+ <TagCloseButton onClick={() => removeTechnology(link)} />
720
+ )}
721
+ </Tag>
722
+ </PopoverTrigger>
723
+ <PopoverContent bg="var(--bg-panel)" borderColor="whiteAlpha.300" maxW="260px">
724
+ <PopoverArrow bg="var(--bg-panel)" />
725
+ <PopoverBody>
726
+ <VStack align="stretch" spacing={1}>
727
+ <Text fontSize="sm" color="white" fontWeight="semibold">{meta?.name || link.label}</Text>
728
+ <Text fontSize="xs" color="gray.400">{link.type === 'custom' ? 'Custom technology' : (meta?.provider || 'General')}</Text>
729
+ {sourceUrl && (
730
+ <Text as="a" href={sourceUrl} target="_blank" rel="noreferrer" fontSize="xs" color="blue.300" textDecoration="underline" pointerEvents="auto">
731
+ {sourceUrl}
732
+ </Text>
733
+ )}
734
+ </VStack>
735
+ </PopoverBody>
736
+ </PopoverContent>
737
+ </Popover>
738
+ </WrapItem>
739
+ )
740
+ })}
741
+ </Wrap>
742
+
743
+ <VStack align="stretch" spacing={1}>
744
+ <Text fontSize="xs" color="gray.400">Canvas icon (optional)</Text>
745
+ <Button size="xs" variant="ghost" w="fit-content" onClick={clearPrimaryIcon} isDisabled={isReadOnly}>
746
+ None
747
+ </Button>
748
+ <RadioGroup value={selectedPrimarySlug} onChange={markPrimaryIcon}>
749
+ <VStack align="stretch" spacing={1}>
750
+ {technologyLinks.filter((link) => link.type === 'catalog' && !!link.slug).map((link) => (
751
+ <Radio
752
+ key={`primary-${link.slug}`}
753
+ value={link.slug}
754
+ isDisabled={isReadOnly}
755
+ size="sm"
756
+ colorScheme="blue"
757
+ >
758
+ <Text fontSize="xs" color="gray.200">{link.label}</Text>
759
+ </Radio>
760
+ ))}
761
+ </VStack>
762
+ </RadioGroup>
763
+ </VStack>
764
+
765
+ <Text fontSize="10px" color="gray.500">Maximum 3 linked technologies.</Text>
766
+ </VStack>
767
+ </FormControl>
768
+ <FormControl isDisabled={isReadOnly}>
769
+ <FormLabel>URL</FormLabel>
770
+ <Input
771
+ size="sm"
772
+ value={url}
773
+ onChange={(e) => setUrl(e.target.value)}
774
+ onBlur={scheduleAutoSave}
775
+ placeholder="https://…"
776
+ />
777
+ </FormControl>
778
+ <FormControl isDisabled={isReadOnly}>
779
+ <FormLabel>Tags</FormLabel>
780
+ <TagUpsert
781
+ currentTags={tags}
782
+ availableTags={availableTags}
783
+ onAddTag={(tag) => {
784
+ if (!tags.includes(tag)) {
785
+ setTags((prev) => [...prev, tag])
786
+ scheduleAutoSave()
787
+ }
788
+ }}
789
+ isReadOnly={isReadOnly}
790
+ />
791
+ <Wrap mt={3}>
792
+ {tags.map((tag) => (
793
+ <WrapItem key={tag}>
794
+ <Tag size="sm" variant="subtle" bg="whiteAlpha.100" border="1px solid" borderColor="whiteAlpha.200">
795
+ <TagLabel color="white">{tag}</TagLabel>
796
+ {!isReadOnly && (
797
+ <TagCloseButton onClick={() => {
798
+ setTags((prev) => prev.filter((t) => t !== tag))
799
+ scheduleAutoSave()
800
+ }} />
801
+ )}
802
+ </Tag>
803
+ </WrapItem>
804
+ ))}
805
+ </Wrap>
806
+ </FormControl>
807
+
808
+ {isEdit && element && (
809
+ <GitSourceLinker
810
+ element={element}
811
+ isReadOnly={isReadOnly}
812
+ onUpdate={(updates) => {
813
+ Object.assign(element, updates)
814
+ // Trigger a save with new updates by rebuilding payload in saveIfDirty
815
+ if (!isReadOnly) {
816
+ scheduleAutoSave()
817
+ }
818
+ }}
819
+ />
820
+ )}
821
+
822
+ {isEdit && (links.length > 0 || parentLinks.length > 0) && (
823
+ <Box borderTop="1px solid" borderColor="whiteAlpha.100" pt={3}>
824
+ <FormLabel fontSize="xs" fontWeight="bold" color="gray.400" mb={2}>DRILL DOWN</FormLabel>
825
+ <VStack align="stretch" spacing={2}>
826
+ {parentLinks.map((link: ViewConnector) => (
827
+ <HStack
828
+ key={link.id}
829
+ as="button"
830
+ w="full"
831
+ px={2}
832
+ py={1.5}
833
+ rounded="md"
834
+ bg="whiteAlpha.50"
835
+ _hover={{ bg: 'whiteAlpha.100' }}
836
+ onClick={() => {
837
+ navigate(`/views/${link.from_view_id}`)
838
+ onClose()
839
+ }}
840
+ align="center"
841
+ >
842
+ <Box color="blue.400" flexShrink={0}>
843
+ <ZoomOutIcon size={12} />
844
+ </Box>
845
+ <HStack align="baseline" spacing={2} flex={1} overflow="hidden">
846
+ <Text fontSize="xs" color="gray.400" whiteSpace="nowrap">Parent View</Text>
847
+ <Text fontSize="sm" color="white" isTruncated>{link.to_view_name}</Text>
848
+ </HStack>
849
+ </HStack>
850
+ ))}
851
+
852
+ {links.map((link: ViewConnector) => (
853
+ <HStack
854
+ key={link.id}
855
+ as="button"
856
+ w="full"
857
+ px={2}
858
+ py={1.5}
859
+ rounded="md"
860
+ bg="whiteAlpha.50"
861
+ _hover={{ bg: 'whiteAlpha.100' }}
862
+ onClick={() => {
863
+ navigate(`/views/${link.to_view_id}`)
864
+ onClose()
865
+ }}
866
+ align="center"
867
+ >
868
+ <Box color="teal.400" flexShrink={0}>
869
+ <ZoomInIcon size={12} />
870
+ </Box>
871
+ <HStack align="baseline" spacing={2} flex={1} overflow="hidden">
872
+ <Text fontSize="xs" color="gray.400" whiteSpace="nowrap">Sub-view</Text>
873
+ <Text fontSize="sm" color="white" isTruncated>{link.to_view_name}</Text>
874
+ </HStack>
875
+ </HStack>
876
+ ))}
877
+ </VStack>
878
+ </Box>
879
+ )}
880
+
881
+ {elementPanelAfterContentSlot}
882
+
883
+ {isEdit && canEdit && (
884
+ <HStack borderTop="1px solid" borderColor="whiteAlpha.100" pt={2} spacing={2}>
885
+ <Button variant="subtle" size="sm" color="white" _hover={{ bg: 'whiteAlpha.100' }} onClick={handleDelete} flex={1}>
886
+ Remove
887
+ </Button>
888
+ <Button variant="subtle" size="sm" color="red.300" _hover={{ bg: 'red.900', color: 'red.100' }} onClick={confirmPermanentDelete.onOpen} flex={1}>
889
+ Delete Element
890
+ </Button>
891
+ </HStack>
892
+ )}
893
+ </VStack>
894
+ </ScrollIndicatorWrapper>
895
+
896
+ <Divider borderColor="whiteAlpha.100" />
897
+
898
+ {/* Footer */}
899
+ <HStack px={4} py={3} justify="space-between" flexShrink={0}>
900
+
901
+ {!autoSaveEdit && (
902
+ <HStack ml="auto">
903
+ <Button variant="ghost" size="sm" onClick={handleClose}>
904
+ Cancel
905
+ </Button>
906
+ {canEdit && (
907
+ <Button size="sm" px={5} colorScheme="blue" onClick={handleSave} isLoading={loading}>
908
+ Save
909
+ </Button>
910
+ )}
911
+ </HStack>
912
+ )}
913
+ </HStack>
914
+ </SlidingPanel>
915
+
916
+ <ConfirmDialog
917
+ isOpen={confirmPermanentDelete.isOpen}
918
+ onClose={confirmPermanentDelete.onClose}
919
+ onConfirm={handlePermanentDelete}
920
+ title="Delete Element"
921
+ body="Permanently delete this element? It will be removed from all views and cannot be recovered."
922
+ confirmLabel="Delete Permanently"
923
+ />
924
+ </>
925
+ )
926
+ }
927
+
928
+ export default memo(ElementPanel)