@tldiagram/core-ui 1.94.0 → 1.94.2

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.
@@ -56,8 +56,6 @@ export declare function useViewData({ viewId, interactionSourceId, clickConnectM
56
56
  incomingLinks: import("../../..").IncomingViewConnector[];
57
57
  treeData: ViewTreeNode[];
58
58
  allElements: LibraryElement[];
59
- libraryRefresh: number;
60
- setLibraryRefresh: (next: import("../../../store/useStore").StoreSetter<number>) => void;
61
59
  existingElementIds: Set<number>;
62
60
  viewElementsRef: import("react").MutableRefObject<PlacedElement[]>;
63
61
  linksMapRef: import("react").MutableRefObject<Record<number, import("../../..").ViewConnector[]>>;
@@ -32,7 +32,6 @@ export type CanvasStoreState = ViewEditorUiState & {
32
32
  incomingLinks: IncomingViewConnector[];
33
33
  treeData: ViewTreeNode[];
34
34
  allElements: LibraryElement[];
35
- libraryRefresh: number;
36
35
  setViewEditorUi: (patch: Partial<ViewEditorUiState>) => void;
37
36
  setSnapToGrid: (snapToGrid: boolean) => void;
38
37
  setSelectedElement: (selectedElement: LibraryElement | null) => void;
@@ -47,7 +46,6 @@ export type CanvasStoreState = ViewEditorUiState & {
47
46
  setIncomingLinks: (next: StoreSetter<IncomingViewConnector[]>) => void;
48
47
  setTreeData: (next: StoreSetter<ViewTreeNode[]>) => void;
49
48
  setAllElements: (next: StoreSetter<LibraryElement[]>) => void;
50
- setLibraryRefresh: (next: StoreSetter<number>) => void;
51
49
  resetCanvas: () => void;
52
50
  hydrateViewContent: (payload: ViewContentPayload) => void;
53
51
  updateElementPosition: (elementId: number, x: number, y: number) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tldiagram/core-ui",
3
- "version": "1.94.0",
3
+ "version": "1.94.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,6 @@
38
38
  "dependencies": {
39
39
  "@buf/tldiagramcom_diagram.bufbuild_es": "^2.11.0-20260419172603-8192c519478e.1",
40
40
  "@bufbuild/protobuf": "^2.11.0",
41
- "@tanstack/react-query": "^5.100.1",
42
41
  "esbuild": "^0.25.12",
43
42
  "zustand": "^5.0.12"
44
43
  },
@@ -59,6 +58,7 @@
59
58
  "@connectrpc/connect-web": "^2.1.1",
60
59
  "@emotion/react": "^11.14.0",
61
60
  "@emotion/styled": "^11.14.0",
61
+ "@tanstack/react-query": "^5.100.1",
62
62
  "@uiw/react-codemirror": "^4.25.8",
63
63
  "d3-force": "^3.0.0",
64
64
  "dagre": "^0.8.5",
@@ -126,6 +126,7 @@
126
126
  "@connectrpc/connect-web": "^2.1.1",
127
127
  "@emotion/react": "^11.14.0",
128
128
  "@emotion/styled": "^11.14.0",
129
+ "@tanstack/react-query": "^5.100.1",
129
130
  "@eslint/js": "^9.0.0",
130
131
  "@types/dagre": "^0.7.54",
131
132
  "@types/react": "^18.3.12",
package/src/api/client.ts CHANGED
@@ -134,41 +134,51 @@ function protoElementToLibrary(e: Record<string, unknown>): LibraryElement {
134
134
  description: (e.description ?? null) as string | null,
135
135
  technology: (e.technology ?? null) as string | null,
136
136
  url: (e.url ?? null) as string | null,
137
- logo_url: (e.logo_url ?? null) as string | null,
138
- technology_connectors: (e.technology_connectors ?? []) as LibraryElement['technology_connectors'],
137
+ logo_url: (e.logo_url ?? e.logoUrl ?? null) as string | null,
138
+ technology_connectors: ((e.technology_connectors ?? e.technologyLinks ?? []) as any[]).map(tl => ({
139
+ type: tl.type,
140
+ slug: tl.slug,
141
+ label: tl.label,
142
+ is_primary_icon: !!(tl.is_primary_icon ?? tl.isPrimaryIcon),
143
+ })),
139
144
  tags: (e.tags ?? []) as string[],
140
145
  repo: (e.repo ?? null) as string | null,
141
146
  branch: (e.branch ?? null) as string | null,
142
147
  file_path: (e.file_path ?? null) as string | null,
143
148
  language: (e.language ?? null) as string | null,
144
- created_at: String(e.created_at ?? new Date().toISOString()),
145
- updated_at: String(e.updated_at ?? new Date().toISOString()),
146
- has_view: Boolean(e.has_view ?? false),
147
- view_label: (e.view_label ?? null) as string | null,
149
+ created_at: String(e.created_at ?? e.createdAt ?? new Date().toISOString()),
150
+ updated_at: String(e.updated_at ?? e.updatedAt ?? new Date().toISOString()),
151
+ has_view: Boolean(e.has_view ?? e.hasView ?? false),
152
+ view_label: (e.view_label ?? e.viewLabel ?? null) as string | null,
148
153
  }
149
154
  }
150
155
 
151
156
  function protoPlacedElement(p: Record<string, unknown>): PlacedElement {
152
157
  return {
153
158
  id: Number(p.id ?? 0),
154
- view_id: Number(p.view_id ?? 0),
155
- element_id: Number(p.element_id ?? 0),
156
- position_x: Number(p.position_x ?? 0),
157
- position_y: Number(p.position_y ?? 0),
159
+ view_id: Number(p.view_id ?? p.viewId ?? 0),
160
+ element_id: Number(p.element_id ?? p.elementId ?? 0),
161
+ position_x: Number(p.position_x ?? p.positionX ?? 0),
162
+ position_y: Number(p.position_y ?? p.positionY ?? 0),
158
163
  name: String(p.name ?? ''),
159
164
  description: (p.description ?? null) as string | null,
160
165
  kind: (p.kind ?? null) as string | null,
161
166
  technology: (p.technology ?? null) as string | null,
162
167
  url: (p.url ?? null) as string | null,
163
- logo_url: (p.logo_url ?? null) as string | null,
164
- technology_connectors: (p.technology_connectors ?? []) as PlacedElement['technology_connectors'],
168
+ logo_url: (p.logo_url ?? p.logoUrl ?? null) as string | null,
169
+ technology_connectors: ((p.technology_connect_ors ?? p.technology_connectors ?? p.technologyLinks ?? []) as any[]).map(tl => ({
170
+ type: tl.type,
171
+ slug: tl.slug,
172
+ label: tl.label,
173
+ is_primary_icon: !!(tl.is_primary_icon ?? tl.isPrimaryIcon),
174
+ })),
165
175
  tags: (p.tags ?? []) as string[],
166
176
  repo: (p.repo ?? null) as string | null,
167
177
  branch: (p.branch ?? null) as string | null,
168
178
  file_path: (p.file_path ?? null) as string | null,
169
179
  language: (p.language ?? null) as string | null,
170
- has_view: Boolean(p.has_view ?? false),
171
- view_label: (p.view_label ?? null) as string | null,
180
+ has_view: Boolean(p.has_view ?? p.hasView ?? false),
181
+ view_label: (p.view_label ?? p.viewLabel ?? null) as string | null,
172
182
  }
173
183
  }
174
184
 
@@ -62,7 +62,7 @@ function ContextNeighborNode({ data }: Props) {
62
62
 
63
63
  const logoUrl = useMemo(() => {
64
64
  if (data.logo_url) return resolveIconPath(data.logo_url)
65
- const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
65
+ const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)
66
66
  if (!selected?.slug) return undefined
67
67
  return resolveIconPath(`/icons/${selected.slug}.png`)
68
68
  }, [data.logo_url, data.technology_connectors])
@@ -34,7 +34,6 @@ interface Props {
34
34
  existingElementIds: Set<number>
35
35
  existingElements?: LibraryElement[]
36
36
  onCreateNew: () => void
37
- refresh: number
38
37
  isOpen: boolean
39
38
  onClose: () => void
40
39
  onTapAdd?: (obj: LibraryElement) => void
@@ -89,7 +88,6 @@ function ElementLibrary({
89
88
  existingElementIds,
90
89
  existingElements = [],
91
90
  onCreateNew,
92
- refresh,
93
91
  isOpen,
94
92
  onClose,
95
93
  onTapAdd,
@@ -134,7 +132,7 @@ function ElementLibrary({
134
132
  if (isOpen) {
135
133
  fetchElements(0, searchRef.current, true)
136
134
  }
137
- }, [isOpen, refresh, fetchElements])
135
+ }, [isOpen, fetchElements])
138
136
 
139
137
  // Debounced search
140
138
  useEffect(() => {
@@ -317,7 +317,7 @@ function ElementNode({ data, selected }: Props) {
317
317
  }, [data.reconnectCandidates])
318
318
 
319
319
  const derivedPrimaryIconPath = (() => {
320
- const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
320
+ const selected = data.technology_connectors?.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)
321
321
  if (!selected?.slug) return undefined
322
322
  return resolveIconPath(`/icons/${selected.slug}.png`)
323
323
  })()
@@ -18,8 +18,6 @@ import {
18
18
  PopoverBody,
19
19
  PopoverContent,
20
20
  PopoverTrigger,
21
- Radio,
22
- RadioGroup,
23
21
  Tag,
24
22
  TagCloseButton,
25
23
  TagLabel,
@@ -45,6 +43,173 @@ import TagUpsert from './TagUpsert'
45
43
 
46
44
  import { useViewEditorContext } from '../pages/ViewEditor/context'
47
45
 
46
+ function normalizeTechnologyLabel(value: string): string {
47
+ return value.trim().replace(/\s+/g, ' ').toLowerCase()
48
+ }
49
+
50
+ function splitTechnologyLabel(value: string): string[] {
51
+ return value.split(',').map((part) => part.trim()).filter(Boolean)
52
+ }
53
+
54
+ function findCatalogItemByLabel(index: Awaited<ReturnType<typeof getTechnologyCatalogIndex>>, label: string): TechnologyCatalogItem | null {
55
+ const normalized = normalizeTechnologyLabel(label)
56
+ if (!normalized) return null
57
+
58
+ const bySlugMatch = index.bySlug.get(label.trim())
59
+ if (bySlugMatch) return bySlugMatch
60
+
61
+ return index.items.find((item) => (
62
+ normalizeTechnologyLabel(item.name) === normalized ||
63
+ normalizeTechnologyLabel(item.nameShort) === normalized ||
64
+ normalizeTechnologyLabel(item.defaultSlug) === normalized
65
+ )) ?? null
66
+ }
67
+
68
+ function dedupeTechnologyLinks(links: TechnologyConnector[]): TechnologyConnector[] {
69
+ const seenCatalog = new Set<string>()
70
+ const seenCustom = new Set<string>()
71
+ const result: TechnologyConnector[] = []
72
+ let primarySet = false
73
+
74
+ // Sort links to process primary ones first, ensuring they are preserved during deduping
75
+ const sortedLinks = [...links].sort((a, b) => {
76
+ const aPrimary = !!(a.is_primary_icon ?? (a as any).isPrimaryIcon)
77
+ const bPrimary = !!(b.is_primary_icon ?? (b as any).isPrimaryIcon)
78
+ if (aPrimary && !bPrimary) return -1
79
+ if (!aPrimary && bPrimary) return 1
80
+ return 0
81
+ })
82
+
83
+ for (const link of sortedLinks) {
84
+ const label = link.label.trim()
85
+ if (!label) continue
86
+
87
+ const isPrimary = !!(link.is_primary_icon ?? (link as any).isPrimaryIcon)
88
+
89
+ if (link.type === 'catalog' && link.slug) {
90
+ const slug = link.slug.trim()
91
+ const key = slug.toLowerCase()
92
+ if (seenCatalog.has(key)) continue
93
+ seenCatalog.add(key)
94
+ result.push({
95
+ type: 'catalog',
96
+ slug,
97
+ label,
98
+ is_primary_icon: !primarySet && isPrimary,
99
+ })
100
+ if (isPrimary) primarySet = true
101
+ continue
102
+ }
103
+
104
+ const key = normalizeTechnologyLabel(label)
105
+ if (seenCustom.has(key)) continue
106
+ seenCustom.add(key)
107
+ result.push({ type: 'custom', label, is_primary_icon: false })
108
+ }
109
+
110
+ return result.slice(0, 3)
111
+ }
112
+
113
+ async function normalizeInitialTechnologyLinks(element: LibraryElement): Promise<TechnologyConnector[]> {
114
+ const rawLinks = element.technology_connectors ?? []
115
+ const legacyLabels = splitTechnologyLabel(element.technology ?? '')
116
+
117
+ if (rawLinks.length === 0 && legacyLabels.length === 0) return []
118
+
119
+ const index = await getTechnologyCatalogIndex()
120
+ const normalized: TechnologyConnector[] = []
121
+
122
+ const pushLabel = (label: string, isPrimaryIcon = false) => {
123
+ const match = findCatalogItemByLabel(index, label)
124
+ if (match) {
125
+ normalized.push({
126
+ type: 'catalog',
127
+ slug: match.defaultSlug,
128
+ label: match.name,
129
+ is_primary_icon: isPrimaryIcon,
130
+ })
131
+ } else {
132
+ normalized.push({ type: 'custom', label: label.trim(), is_primary_icon: false })
133
+ }
134
+ }
135
+
136
+ if (rawLinks.length > 0) {
137
+ for (const link of rawLinks) {
138
+ if (link.type === 'catalog') {
139
+ const match = link.slug ? index.bySlug.get(link.slug) : null
140
+ normalized.push({
141
+ type: 'catalog',
142
+ slug: link.slug,
143
+ label: match?.name ?? link.label,
144
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
145
+ })
146
+ } else {
147
+ const parts = splitTechnologyLabel(link.label)
148
+ if (parts.length > 1) {
149
+ for (const part of parts) pushLabel(part)
150
+ } else {
151
+ pushLabel(link.label)
152
+ }
153
+ }
154
+ }
155
+ } else {
156
+ for (const label of legacyLabels) {
157
+ pushLabel(label)
158
+ }
159
+ }
160
+
161
+ // If no catalog item is primary, try to match against element.logo_url or fallback to first catalog item
162
+ const deduped = dedupeTechnologyLinks(normalized)
163
+ const hasPrimary = deduped.some(l => l.type === 'catalog' && l.is_primary_icon)
164
+ if (!hasPrimary) {
165
+ let bestMatchIndex = -1
166
+ if (element.logo_url) {
167
+ bestMatchIndex = deduped.findIndex(l => l.type === 'catalog' && l.slug && element.logo_url?.toLowerCase().includes(l.slug.toLowerCase()))
168
+ }
169
+
170
+ if (bestMatchIndex !== -1) {
171
+ deduped[bestMatchIndex].is_primary_icon = true
172
+ } else {
173
+ const firstCatalog = deduped.find(l => l.type === 'catalog')
174
+ if (firstCatalog) {
175
+ firstCatalog.is_primary_icon = true
176
+ }
177
+ }
178
+ }
179
+
180
+ return deduped
181
+ }
182
+
183
+ function buildTechnologyFingerprintPayload(
184
+ element: LibraryElement,
185
+ links: TechnologyConnector[],
186
+ type: string,
187
+ ) {
188
+ const normalizedLinks = links.map((link) => ({
189
+ type: link.type,
190
+ slug: link.type === 'catalog' ? link.slug : undefined,
191
+ label: link.label,
192
+ is_primary_icon: !!link.is_primary_icon,
193
+ }))
194
+ const normalizedType = type.trim().toLowerCase()
195
+ const technology = links.map((link) => link.label).join(', ')
196
+
197
+ return {
198
+ name: element.name,
199
+ description: element.description ?? '',
200
+ kind: normalizedType,
201
+ technology,
202
+ url: element.url ?? '',
203
+ logo_url: element.logo_url ?? '',
204
+ technology_connectors: normalizedLinks,
205
+ tags: element.tags ?? [],
206
+ repo: element.repo,
207
+ branch: element.branch,
208
+ file_path: element.file_path,
209
+ language: element.language,
210
+ }
211
+ }
212
+
48
213
  export interface ElementPanelProps extends ElementPanelSlots {
49
214
  isOpen: boolean
50
215
  onClose: () => void
@@ -101,6 +266,8 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
101
266
  }, [technologyQuery])
102
267
 
103
268
  useEffect(() => {
269
+ let cancelled = false
270
+
104
271
  if (element) {
105
272
  setName(element.name)
106
273
  setDescription(element.description ?? '')
@@ -108,43 +275,42 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
108
275
  setTypeQuery('')
109
276
  setTypeResults([])
110
277
  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
278
  setTags(element.tags ?? [])
120
279
  setExplicitLogoClear(false)
121
280
 
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,
281
+ const linksFromElement = (element.technology_connectors ?? []).map(tl => ({
282
+ ...tl,
283
+ is_primary_icon: !!(tl.is_primary_icon ?? (tl as any).isPrimaryIcon),
131
284
  }))
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
- })
285
+ const fallbackLinks: TechnologyConnector[] = linksFromElement.length > 0
286
+ ? linksFromElement
287
+ : (element.technology ? [{ type: 'custom', label: element.technology, is_primary_icon: false }] : [])
288
+ setTechnologyConnectors(fallbackLinks)
289
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
290
+ element,
291
+ fallbackLinks,
292
+ element.kind ?? '',
293
+ ))
294
+
295
+ normalizeInitialTechnologyLinks(element)
296
+ .then((initialLinks) => {
297
+ if (cancelled) return
298
+ setTechnologyConnectors(initialLinks)
299
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
300
+ element,
301
+ initialLinks,
302
+ element.kind ?? '',
303
+ ))
304
+ })
305
+ .catch(() => {
306
+ if (cancelled) return
307
+ setTechnologyConnectors(fallbackLinks)
308
+ lastSavedFingerprintRef.current = JSON.stringify(buildTechnologyFingerprintPayload(
309
+ element,
310
+ fallbackLinks,
311
+ element.kind ?? '',
312
+ ))
313
+ })
148
314
  } else {
149
315
  setName('')
150
316
  setDescription('')
@@ -160,17 +326,21 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
160
326
  setExplicitLogoClear(false)
161
327
  lastSavedFingerprintRef.current = ''
162
328
  }
329
+
330
+ return () => {
331
+ cancelled = true
332
+ }
163
333
  }, [element, isOpen])
164
334
 
165
335
  const buildPayloadAndFingerprint = useCallback(async () => {
166
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
336
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
167
337
  const primarySlug = primaryLink?.slug
168
338
 
169
339
  const normalizedLinks = technologyLinks.map((link) => ({
170
340
  type: link.type,
171
341
  slug: link.type === 'catalog' ? link.slug : undefined,
172
342
  label: link.label,
173
- is_primary_icon: !!link.is_primary_icon,
343
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
174
344
  }))
175
345
 
176
346
  const normalizedType = type.trim().toLowerCase()
@@ -179,7 +349,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
179
349
  if (explicitLogoClear) {
180
350
  logoUrl = ''
181
351
  }
182
- if (primarySlug) {
352
+ if (!explicitLogoClear && primarySlug) {
183
353
  const cached = technologyMeta[primarySlug]
184
354
  if (cached?.iconUrl) {
185
355
  logoUrl = cached.iconUrl
@@ -378,43 +548,31 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
378
548
  }
379
549
 
380
550
  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
- })
551
+ if (linkToRemove.type === 'catalog' && linkToRemove.is_primary_icon) {
552
+ setExplicitLogoClear(true)
553
+ }
554
+ setTechnologyConnectors((prev) => prev.filter((link) => (
555
+ !(link.type === linkToRemove.type && link.slug === linkToRemove.slug && link.label === linkToRemove.label)
556
+ )))
394
557
  scheduleAutoSave()
395
558
  }
396
559
 
397
- const markPrimaryIcon = (selectedSlug: string) => {
560
+ const togglePrimaryIcon = (selectedSlug: string) => {
561
+ const isDeselecting = selectedPrimarySlug === selectedSlug
398
562
  setTechnologyConnectors((prev) => prev.map((link) => {
399
563
  if (link.type !== 'catalog') {
400
564
  return { ...link, is_primary_icon: false }
401
565
  }
402
566
  return {
403
567
  ...link,
404
- is_primary_icon: link.slug === selectedSlug,
568
+ is_primary_icon: !isDeselecting && link.slug === selectedSlug,
405
569
  }
406
570
  }))
407
- setExplicitLogoClear(false)
571
+ setExplicitLogoClear(isDeselecting)
408
572
  scheduleAutoSave()
409
573
  }
410
574
 
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 ?? ''
575
+ const selectedPrimarySlug = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)?.slug ?? ''
418
576
 
419
577
  const commitTypeFromQuery = () => {
420
578
  if (isReadOnly) return
@@ -437,7 +595,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
437
595
  if (isReadOnly || !name.trim()) return
438
596
  setLoading(true)
439
597
  try {
440
- const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && link.is_primary_icon && link.slug)
598
+ const primaryLink = technologyLinks.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && link.slug)
441
599
  const primaryMetadata = primaryLink?.slug
442
600
  ? (technologyMeta[primaryLink.slug] ?? await getTechnologyCatalogItemBySlug(primaryLink.slug))
443
601
  : null
@@ -446,7 +604,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
446
604
  type: link.type,
447
605
  slug: link.type === 'catalog' ? link.slug : undefined,
448
606
  label: link.label,
449
- is_primary_icon: !!link.is_primary_icon,
607
+ is_primary_icon: !!(link.is_primary_icon ?? (link as any).isPrimaryIcon),
450
608
  }))
451
609
 
452
610
  const normalizedType = type.trim().toLowerCase()
@@ -457,7 +615,7 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
457
615
  kind: normalizedType,
458
616
  technology: technologyLinks.map((link) => link.label).join(', '),
459
617
  url,
460
- logo_url: primaryMetadata?.iconUrl ?? '',
618
+ logo_url: explicitLogoClear ? '' : (primaryMetadata?.iconUrl ?? ''),
461
619
  technology_connectors: normalizedLinks,
462
620
  tags,
463
621
  repo: element?.repo,
@@ -703,11 +861,24 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
703
861
  {technologyLinks.map((link) => {
704
862
  const meta = link.slug ? technologyMeta[link.slug] : undefined
705
863
  const sourceUrl = meta?.websiteUrl || meta?.docsUrl
864
+ const isSelectable = link.type === 'catalog' && !!link.slug && !isReadOnly
865
+ const isPrimaryIcon = link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug
706
866
  return (
707
867
  <WrapItem key={`${link.type}:${link.slug ?? link.label}`}>
708
868
  <Popover trigger={isMobile ? 'click' : 'hover'} placement="top" closeOnBlur>
709
869
  <PopoverTrigger>
710
- <Tag size="sm" variant="subtle" bg="whiteAlpha.100" border="1px solid" borderColor="whiteAlpha.200" cursor="pointer">
870
+ <Tag
871
+ size="sm"
872
+ variant="subtle"
873
+ bg={isPrimaryIcon ? 'blue.500' : 'whiteAlpha.100'}
874
+ border="1px solid"
875
+ borderColor={isPrimaryIcon ? 'blue.300' : 'whiteAlpha.200'}
876
+ color={isPrimaryIcon ? 'white' : undefined}
877
+ cursor={isSelectable ? 'pointer' : 'default'}
878
+ onClick={() => {
879
+ if (isSelectable && link.slug) togglePrimaryIcon(link.slug)
880
+ }}
881
+ >
711
882
  <TagLabel color="white">
712
883
  {link.type === 'catalog' && meta && (
713
884
  <Box as="img" src={resolveWithBase(meta.iconUrl)} alt={link.label} boxSize="12px" objectFit="contain" display="inline-block" mr={1.5} verticalAlign="middle" />
@@ -715,7 +886,13 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
715
886
  {link.label}
716
887
  </TagLabel>
717
888
  {!isReadOnly && (
718
- <TagCloseButton onClick={() => removeTechnology(link)} />
889
+ <TagCloseButton
890
+ onClick={(e) => {
891
+ e.preventDefault()
892
+ e.stopPropagation()
893
+ removeTechnology(link)
894
+ }}
895
+ />
719
896
  )}
720
897
  </Tag>
721
898
  </PopoverTrigger>
@@ -739,28 +916,6 @@ function ElementPanel({ isOpen, onClose, element, onSave, autoSave = false, onDe
739
916
  })}
740
917
  </Wrap>
741
918
 
742
- <VStack align="stretch" spacing={1}>
743
- <Text fontSize="xs" color="gray.400">Canvas icon (optional)</Text>
744
- <Button size="xs" variant="ghost" w="fit-content" onClick={clearPrimaryIcon} isDisabled={isReadOnly}>
745
- None
746
- </Button>
747
- <RadioGroup value={selectedPrimarySlug} onChange={markPrimaryIcon}>
748
- <VStack align="stretch" spacing={1}>
749
- {technologyLinks.filter((link) => link.type === 'catalog' && !!link.slug).map((link) => (
750
- <Radio
751
- key={`primary-${link.slug}`}
752
- value={link.slug}
753
- isDisabled={isReadOnly}
754
- size="sm"
755
- colorScheme="blue"
756
- >
757
- <Text fontSize="xs" color="gray.200">{link.label}</Text>
758
- </Radio>
759
- ))}
760
- </VStack>
761
- </RadioGroup>
762
- </VStack>
763
-
764
919
  <Text fontSize="10px" color="gray.500">Maximum 3 linked technologies.</Text>
765
920
  </VStack>
766
921
  </FormControl>
@@ -5,9 +5,7 @@ export function ZoomOutIcon({ size = 14, strokeWidth = 3 }: { size?: number, str
5
5
  return (
6
6
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
7
7
  strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
8
- <circle cx="11" cy="11" r="8" />
9
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
10
- <line x1="8" y1="11" x2="14" y2="11" />
8
+ <path d="M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0M21 21l-4.343-4.343M8 11h6" />
11
9
  </svg>
12
10
  )
13
11
  }
@@ -16,10 +14,7 @@ export function ZoomInIcon({ size = 14, strokeWidth = 3 }: { size?: number, stro
16
14
  return (
17
15
  <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
18
16
  strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
19
- <circle cx="11" cy="11" r="8" />
20
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
21
- <line x1="11" y1="8" x2="11" y2="14" />
22
- <line x1="8" y1="11" x2="14" y2="11" />
17
+ <path d="M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0M21 21l-4.343-4.343M11 8v6M8 11h6" />
23
18
  </svg>
24
19
  )
25
20
  }
@@ -168,7 +168,7 @@ function buildNodes(
168
168
  }))
169
169
 
170
170
  const derivedPrimaryIconPath = (() => {
171
- const selected = obj.technology_connectors?.find((link) => link.type === 'catalog' && !!link.is_primary_icon && !!link.slug)
171
+ const selected = obj.technology_connectors?.find((link) => link.type === 'catalog' && !!(link.is_primary_icon ?? (link as any).isPrimaryIcon) && !!link.slug)
172
172
  if (!selected?.slug) return null
173
173
  return resolveIconPath(`/icons/${selected.slug}.png`)
174
174
  })()