@tldiagram/core-ui 1.93.0 → 1.94.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.
@@ -1,10 +1,37 @@
1
- import { useState, useEffect, useCallback } from 'react'
2
- import { useSearchParams } from 'react-router-dom'
3
- import { Box, Flex, Button, Text, Spinner, Center } from '@chakra-ui/react'
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
+ import { useNavigate, useSearchParams } from 'react-router-dom'
3
+ import {
4
+ Box,
5
+ Flex,
6
+ Button,
7
+ Text,
8
+ Spinner,
9
+ Center,
10
+ FormControl,
11
+ FormLabel,
12
+ HStack,
13
+ IconButton,
14
+ Input,
15
+ InputGroup,
16
+ InputLeftElement,
17
+ InputRightElement,
18
+ Modal,
19
+ ModalBody,
20
+ ModalContent,
21
+ ModalFooter,
22
+ ModalHeader,
23
+ ModalOverlay,
24
+ useDisclosure,
25
+ useBreakpointValue,
26
+ } from '@chakra-ui/react'
27
+ import { AddIcon, CloseIcon, SearchIcon } from '@chakra-ui/icons'
4
28
  import { motion, AnimatePresence } from 'framer-motion'
5
29
  import ViewsGrid from './ViewsGrid'
6
- import InfiniteZoom from './InfiniteZoom'
30
+ import InfiniteZoom, { type InfiniteZoomHandle } from './InfiniteZoom'
31
+ import { ZoomInIcon } from '../components/Icons'
7
32
  import { api } from '../api/client'
33
+ import { toast } from '../utils/toast'
34
+ import type { ViewTreeNode } from '../types'
8
35
 
9
36
  interface Props {
10
37
  shareSlot?: React.ReactNode
@@ -15,12 +42,356 @@ type ViewType = 'explore' | 'hierarchy'
15
42
 
16
43
  const MotionBox = motion.create(Box)
17
44
 
45
+ function HierarchyModeIcon({ size = 13 }: { size?: number }) {
46
+ return (
47
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
48
+ <rect x="8" y="2" width="8" height="5" rx="1.5" fill="none" />
49
+ <line x1="12" y1="7" x2="12" y2="11.5" />
50
+ <line x1="4.5" y1="11.5" x2="19.5" y2="11.5" />
51
+ <line x1="4.5" y1="11.5" x2="4.5" y2="14" />
52
+ <rect x="1.5" y="14" width="6" height="4.5" rx="1.5" fill="none" />
53
+ <line x1="19.5" y1="11.5" x2="19.5" y2="14" />
54
+ <rect x="16.5" y="14" width="6" height="4.5" rx="1.5" fill="none" />
55
+ </svg>
56
+ )
57
+ }
58
+
59
+ function flattenTree(roots: ViewTreeNode[]): ViewTreeNode[] {
60
+ const result: ViewTreeNode[] = []
61
+ const traverse = (node: ViewTreeNode) => {
62
+ result.push(node)
63
+ node.children.forEach(traverse)
64
+ }
65
+ roots.forEach(traverse)
66
+ return result
67
+ }
68
+
69
+ interface DiagramJumpToolbarProps {
70
+ view: ViewType
71
+ searchTerm: string
72
+ searchResults: ViewTreeNode[]
73
+ activeSearchIndex: number
74
+ onSearchChange: (term: string) => void
75
+ onSearchKeyDown: (e: React.KeyboardEvent) => void
76
+ onResultClick: (result: ViewTreeNode) => void
77
+ onViewChange: (view: ViewType) => void
78
+ onCreateOpen: () => void
79
+ }
80
+
81
+ function DiagramJumpToolbar({
82
+ view,
83
+ searchTerm,
84
+ searchResults,
85
+ activeSearchIndex,
86
+ onSearchChange,
87
+ onSearchKeyDown,
88
+ onResultClick,
89
+ onViewChange,
90
+ onCreateOpen,
91
+ }: DiagramJumpToolbarProps) {
92
+ const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
93
+ const searchInputRef = useRef<HTMLInputElement>(null)
94
+ const [searchFocused, setSearchFocused] = useState(false)
95
+ const inactiveColor = 'gray.400'
96
+ const searchHasContent = searchTerm.length > 0 || searchResults.length > 0
97
+ const searchIsActive = searchFocused || searchHasContent
98
+ const showCreateButton = !searchHasContent
99
+ const desktopSearchWidth = searchHasContent ? 318 : searchFocused ? 236 : 118
100
+
101
+ const maybeCollapseSearch = useCallback(() => {
102
+ window.setTimeout(() => {
103
+ setSearchFocused(false)
104
+ }, 80)
105
+ }, [])
106
+
107
+ return (
108
+ <Box
109
+ position="absolute"
110
+ top={isMobileLayout ? 3 : 4}
111
+ left="50%"
112
+ transform="translateX(-50%)"
113
+ zIndex={1000}
114
+ pointerEvents="auto"
115
+ w={isMobileLayout ? "calc(100vw - 24px)" : "auto"}
116
+ maxW="calc(100vw - 24px)"
117
+ >
118
+ <motion.div
119
+ initial={{ y: -10, opacity: 0 }}
120
+ animate={{ y: 0, opacity: 1 }}
121
+ transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
122
+ >
123
+ <Flex
124
+ bg="var(--bg-panel)"
125
+ backdropFilter="blur(20px)"
126
+ border="1px solid"
127
+ borderColor="whiteAlpha.100"
128
+ borderRadius="xl"
129
+ px={1.5}
130
+ py={1.5}
131
+ gap={1.5}
132
+ boxShadow="0 8px 32px rgba(0,0,0,0.5)"
133
+ align="center"
134
+ w={isMobileLayout ? "full" : "auto"}
135
+ maxW="full"
136
+ overflow="hidden"
137
+ transition="all 0.2s cubic-bezier(0.4, 0, 0.2, 1)"
138
+ >
139
+ <HStack
140
+ spacing={0.5}
141
+ p={0.5}
142
+ bg="blackAlpha.200"
143
+ border="1px solid"
144
+ borderColor="whiteAlpha.50"
145
+ borderRadius="lg"
146
+ flexShrink={0}
147
+ >
148
+ <Button
149
+ size="sm"
150
+ variant="ghost"
151
+ borderRadius="md"
152
+ position="relative"
153
+ px={isMobileLayout ? 2.5 : 3}
154
+ h="28px"
155
+ minW={isMobileLayout ? "34px" : "auto"}
156
+ onClick={() => onViewChange('explore')}
157
+ _hover={{ bg: 'transparent' }}
158
+ _active={{ bg: 'transparent' }}
159
+ color={view === 'explore' ? 'white' : inactiveColor}
160
+ transition="color 0.2s"
161
+ aria-label="Explore view"
162
+ >
163
+ {view === 'explore' && (
164
+ <MotionBox
165
+ layoutId="active-pill"
166
+ position="absolute"
167
+ inset={0}
168
+ bg="var(--bg-element)"
169
+ borderRadius="md"
170
+ zIndex={-1}
171
+ transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
172
+ />
173
+ )}
174
+ <HStack spacing={1.5} zIndex={1}>
175
+ <ZoomInIcon size={13} strokeWidth={2.4} />
176
+ <Text fontSize="11px" fontWeight="semibold" display={isMobileLayout ? "none" : "block"}>Explore</Text>
177
+ </HStack>
178
+ </Button>
179
+ <Button
180
+ size="sm"
181
+ variant="ghost"
182
+ borderRadius="md"
183
+ position="relative"
184
+ px={isMobileLayout ? 2.5 : 3}
185
+ h="28px"
186
+ minW={isMobileLayout ? "34px" : "auto"}
187
+ onClick={() => onViewChange('hierarchy')}
188
+ _hover={{ bg: 'transparent' }}
189
+ _active={{ bg: 'transparent' }}
190
+ color={view === 'hierarchy' ? 'white' : inactiveColor}
191
+ transition="color 0.2s"
192
+ aria-label="Hierarchy view"
193
+ >
194
+ {view === 'hierarchy' && (
195
+ <MotionBox
196
+ layoutId="active-pill"
197
+ position="absolute"
198
+ inset={0}
199
+ bg="var(--bg-element)"
200
+ borderRadius="md"
201
+ zIndex={-1}
202
+ transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
203
+ />
204
+ )}
205
+ <HStack spacing={1.5} zIndex={1}>
206
+ <HierarchyModeIcon />
207
+ <Text fontSize="11px" fontWeight="semibold" display={isMobileLayout ? "none" : "block"}>Hierarchy</Text>
208
+ </HStack>
209
+ </Button>
210
+ </HStack>
211
+
212
+ <Box w="1px" h="18px" bg="whiteAlpha.100" flexShrink={0} mx={0.5} />
213
+
214
+ <motion.div
215
+ animate={isMobileLayout ? undefined : { width: desktopSearchWidth }}
216
+ transition={{ duration: 0.12, ease: [0.25, 1, 0.5, 1] }}
217
+ style={{ flex: isMobileLayout ? '1 1 0' : '0 0 auto', minWidth: 0 }}
218
+ >
219
+ <InputGroup size="sm" w="full">
220
+ <InputLeftElement pointerEvents="none" h="28px" w="28px">
221
+ <SearchIcon color="whiteAlpha.400" fontSize="10px" />
222
+ </InputLeftElement>
223
+ <Input
224
+ ref={searchInputRef}
225
+ placeholder="Search"
226
+ value={searchTerm}
227
+ onChange={(e) => onSearchChange(e.target.value)}
228
+ onKeyDown={onSearchKeyDown}
229
+ onFocus={() => {
230
+ setSearchFocused(true)
231
+ }}
232
+ onBlur={maybeCollapseSearch}
233
+ bg="blackAlpha.200"
234
+ border="1px solid"
235
+ borderColor={searchIsActive ? 'whiteAlpha.200' : 'whiteAlpha.100'}
236
+ borderRadius="lg"
237
+ fontSize="11px"
238
+ color="white"
239
+ h="28px"
240
+ pl="28px"
241
+ pr={searchTerm ? 8 : 2}
242
+ _placeholder={{ color: 'whiteAlpha.350' }}
243
+ _hover={{ borderColor: 'whiteAlpha.200' }}
244
+ _focus={{ borderColor: 'var(--accent)', boxShadow: '0 0 0 1px rgba(var(--accent-rgb), 0.45)' }}
245
+ />
246
+ {searchTerm && (
247
+ <InputRightElement h="28px" w="28px">
248
+ <IconButton
249
+ aria-label="Clear search"
250
+ icon={<CloseIcon fontSize="8px" />}
251
+ size="xs"
252
+ h="22px"
253
+ minW="22px"
254
+ variant="ghost"
255
+ color="whiteAlpha.400"
256
+ borderRadius="md"
257
+ _hover={{ color: 'white', bg: 'whiteAlpha.100' }}
258
+ onMouseDown={(e) => e.preventDefault()}
259
+ onClick={() => {
260
+ onSearchChange('')
261
+ searchInputRef.current?.focus()
262
+ }}
263
+ />
264
+ </InputRightElement>
265
+ )}
266
+ </InputGroup>
267
+ </motion.div>
268
+
269
+ <AnimatePresence initial={false}>
270
+ {showCreateButton && (
271
+ <motion.div
272
+ key="create-button"
273
+ initial={{ opacity: 0, width: 0 }}
274
+ animate={{ opacity: 1, width: 'auto' }}
275
+ exit={{ opacity: 0, width: 0 }}
276
+ transition={{ duration: 0.12, ease: [0.25, 1, 0.5, 1] }}
277
+ style={{ overflow: 'hidden', flexShrink: 0 }}
278
+ >
279
+ <Button
280
+ size="sm"
281
+ h="28px"
282
+ leftIcon={<AddIcon fontSize="9px" />}
283
+ bg="var(--accent)"
284
+ color="white"
285
+ _hover={{ bg: "var(--accent)", filter: "brightness(1.08)", transform: 'translateY(-1px)' }}
286
+ _active={{ transform: 'translateY(0)', filter: "brightness(0.92)" }}
287
+ variant="solid"
288
+ borderRadius="lg"
289
+ px={3}
290
+ fontSize="11px"
291
+ fontWeight="semibold"
292
+ onClick={onCreateOpen}
293
+ transition="transform 0.18s ease, filter 0.18s ease"
294
+ >
295
+ New
296
+ </Button>
297
+ </motion.div>
298
+ )}
299
+ </AnimatePresence>
300
+ </Flex>
301
+ </motion.div>
302
+
303
+ <AnimatePresence>
304
+ {searchResults.length > 0 && (
305
+ <motion.div
306
+ initial={{ opacity: 0, y: 8, scale: 0.98 }}
307
+ animate={{ opacity: 1, y: 0, scale: 1 }}
308
+ exit={{ opacity: 0, y: 8, scale: 0.98 }}
309
+ transition={{ duration: 0.18, ease: "easeOut" }}
310
+ style={{
311
+ position: 'absolute',
312
+ top: '100%',
313
+ marginTop: '8px',
314
+ left: isMobileLayout ? 0 : 'auto',
315
+ right: 0,
316
+ width: isMobileLayout ? '100%' : searchIsActive ? '318px' : '300px',
317
+ zIndex: 110,
318
+ }}
319
+ >
320
+ <Box
321
+ bg="var(--bg-panel)"
322
+ backdropFilter="blur(24px) saturate(180%)"
323
+ border="1px solid"
324
+ borderColor="var(--border-main)"
325
+ borderRadius="10px"
326
+ overflow="hidden"
327
+ boxShadow="0 20px 50px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)"
328
+ >
329
+ {searchResults.map((result, idx) => (
330
+ <Flex
331
+ key={result.id}
332
+ px={4}
333
+ py={2.5}
334
+ align="center"
335
+ gap={3}
336
+ cursor="pointer"
337
+ bg={idx === activeSearchIndex ? 'whiteAlpha.100' : 'transparent'}
338
+ _hover={{ bg: 'whiteAlpha.50' }}
339
+ onClick={() => onResultClick(result)}
340
+ transition="all 0.15s ease"
341
+ >
342
+ <Box
343
+ w="6px"
344
+ h="6px"
345
+ borderRadius="full"
346
+ bg={idx === activeSearchIndex ? 'var(--accent)' : 'whiteAlpha.300'}
347
+ boxShadow={idx === activeSearchIndex ? `0 0 10px var(--accent)` : 'none'}
348
+ transition="all 0.2s"
349
+ />
350
+ <Box flex={1} minW={0}>
351
+ <Text color="white" fontSize="xs" fontWeight="600" isTruncated>
352
+ {result.name}
353
+ </Text>
354
+ <Text color="whiteAlpha.500" fontSize="10px" textTransform="uppercase" letterSpacing="0.05em">
355
+ Level {result.level} • {result.level_label || 'Diagram'}
356
+ </Text>
357
+ </Box>
358
+ {idx === activeSearchIndex && (
359
+ <HStack spacing={1} opacity={0.8}>
360
+ <Text color="var(--accent)" fontSize="9px" fontWeight="800" letterSpacing="0.1em">
361
+ {view === 'explore' ? 'ZOOM' : 'OPEN'}
362
+ </Text>
363
+ <Text color="whiteAlpha.400" fontSize="9px">↵</Text>
364
+ </HStack>
365
+ )}
366
+ </Flex>
367
+ ))}
368
+ </Box>
369
+ </motion.div>
370
+ )}
371
+ </AnimatePresence>
372
+ </Box>
373
+ )
374
+ }
375
+
18
376
  export default function ViewsPage({ shareSlot, onShareView }: Props) {
377
+ const navigate = useNavigate()
19
378
  const [searchParams, setSearchParams] = useSearchParams()
20
379
  const requestedView = searchParams.get('view')
21
380
  const initialView: ViewType = requestedView === 'edit' ? 'hierarchy' : ((requestedView as ViewType) || 'explore')
22
381
  const [view, setView] = useState<ViewType>(initialView)
23
382
  const [initializing, setInitializing] = useState(true)
383
+ const [treeData, setTreeData] = useState<ViewTreeNode[]>([])
384
+ const [treeLoading, setTreeLoading] = useState(true)
385
+ const [focusedHierarchyId, setFocusedHierarchyId] = useState<number | null>(null)
386
+ const [searchTerm, setSearchTerm] = useState('')
387
+ const [searchResults, setSearchResults] = useState<ViewTreeNode[]>([])
388
+ const [activeSearchIndex, setActiveSearchIndex] = useState(-1)
389
+ const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure()
390
+ const [newName, setNewName] = useState('')
391
+ const [isCreating, setIsCreating] = useState(false)
392
+ const exploreRef = useRef<InfiniteZoomHandle>(null)
393
+
394
+ const flatTree = useMemo(() => flattenTree(treeData), [treeData])
24
395
 
25
396
  const handleViewChange = useCallback((newView: ViewType) => {
26
397
  setView(newView)
@@ -40,11 +411,26 @@ export default function ViewsPage({ shareSlot, onShareView }: Props) {
40
411
  }
41
412
  }, [searchParams])
42
413
 
414
+ const refreshTree = useCallback(async () => {
415
+ setTreeLoading(true)
416
+ const tree = await api.workspace.views.tree().catch(() => null)
417
+ if (tree) {
418
+ setTreeData(tree)
419
+ if (tree.length === 0 && !searchParams.get('view')) {
420
+ handleViewChange('hierarchy')
421
+ }
422
+ }
423
+ setTreeLoading(false)
424
+ setInitializing(false)
425
+ }, [handleViewChange, searchParams])
426
+
43
427
  useEffect(() => {
44
428
  let mounted = true
429
+ setTreeLoading(true)
45
430
  api.workspace.views.tree()
46
431
  .then((tree) => {
47
432
  if (!mounted) return
433
+ if (tree) setTreeData(tree)
48
434
  if (!tree || tree.length === 0) {
49
435
  // Only auto-switch to edit if no view is explicitly set in URL
50
436
  if (!searchParams.get('view')) {
@@ -56,15 +442,89 @@ export default function ViewsPage({ shareSlot, onShareView }: Props) {
56
442
  // Fallback to explore on error
57
443
  })
58
444
  .finally(() => {
59
- if (mounted) setInitializing(false)
445
+ if (mounted) {
446
+ setTreeLoading(false)
447
+ setInitializing(false)
448
+ }
60
449
  })
61
450
  return () => { mounted = false }
62
- }, [searchParams, handleViewChange])
451
+ // Initial tree load only; view changes should not refetch the hierarchy.
452
+ // eslint-disable-next-line react-hooks/exhaustive-deps
453
+ }, [])
63
454
 
64
- // Colors for the switch
65
- const bgColor = 'var(--bg)'
66
- const activeColor = 'clay.bg'
67
- const inactiveColor = 'gray.400'
455
+ const commitSearchResult = useCallback((result: ViewTreeNode) => {
456
+ if (view === 'explore') {
457
+ exploreRef.current?.focusDiagram(result.id)
458
+ } else {
459
+ setFocusedHierarchyId(result.id)
460
+ navigate(`/views/${result.id}`)
461
+ }
462
+ setSearchResults([])
463
+ setActiveSearchIndex(-1)
464
+ setSearchTerm('')
465
+ }, [navigate, view])
466
+
467
+ const handleSearchChange = useCallback((term: string) => {
468
+ setSearchTerm(term)
469
+ if (term.trim().length < 3) {
470
+ setSearchResults([])
471
+ setActiveSearchIndex(-1)
472
+ return
473
+ }
474
+
475
+ const normalized = term.trim().toLowerCase()
476
+ const matches = flatTree
477
+ .filter((n) => n.name.toLowerCase().includes(normalized))
478
+ .slice(0, 5)
479
+
480
+ setSearchResults(matches)
481
+ if (matches.length > 0) {
482
+ setActiveSearchIndex(0)
483
+ if (view === 'hierarchy') setFocusedHierarchyId(matches[0].id)
484
+ } else {
485
+ setActiveSearchIndex(-1)
486
+ }
487
+ }, [flatTree, view])
488
+
489
+ const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
490
+ if (e.key === 'Escape') {
491
+ setSearchResults([])
492
+ setActiveSearchIndex(-1)
493
+ return
494
+ }
495
+ if (searchResults.length === 0) return
496
+
497
+ if (e.key === 'ArrowDown') {
498
+ e.preventDefault()
499
+ const nextIndex = (activeSearchIndex + 1) % searchResults.length
500
+ setActiveSearchIndex(nextIndex)
501
+ if (view === 'hierarchy') setFocusedHierarchyId(searchResults[nextIndex].id)
502
+ } else if (e.key === 'ArrowUp') {
503
+ e.preventDefault()
504
+ const nextIndex = (activeSearchIndex - 1 + searchResults.length) % searchResults.length
505
+ setActiveSearchIndex(nextIndex)
506
+ if (view === 'hierarchy') setFocusedHierarchyId(searchResults[nextIndex].id)
507
+ } else if (e.key === 'Enter' && activeSearchIndex >= 0) {
508
+ e.preventDefault()
509
+ commitSearchResult(searchResults[activeSearchIndex])
510
+ }
511
+ }, [activeSearchIndex, commitSearchResult, searchResults, view])
512
+
513
+ const handleCreate = useCallback(async () => {
514
+ if (!newName.trim()) return
515
+ setIsCreating(true)
516
+ try {
517
+ const d = await api.workspace.views.create({ name: newName.trim() })
518
+ await refreshTree()
519
+ navigate(`/views/${d.id}`)
520
+ onCreateClose()
521
+ setNewName('')
522
+ } catch (err: unknown) {
523
+ toast({ title: 'Failed to create diagram', description: err instanceof Error ? err.message : 'An unexpected error occurred', status: 'error' })
524
+ } finally {
525
+ setIsCreating(false)
526
+ }
527
+ }, [navigate, newName, onCreateClose, refreshTree])
68
528
 
69
529
  if (initializing) {
70
530
  return (
@@ -76,77 +536,20 @@ export default function ViewsPage({ shareSlot, onShareView }: Props) {
76
536
 
77
537
  return (
78
538
  <Box position="relative" w="full" h="full" overflow="hidden">
79
- {/* Floating Switch */}
80
- <Flex
81
- position="absolute"
82
- top={4}
83
- left="50%"
84
- transform="translateX(-50%)"
85
- zIndex={1000}
86
- bg={bgColor}
87
- backdropFilter="blur(12px)"
88
- border="1px solid"
89
- borderColor="whiteAlpha.100"
90
- borderRadius="full"
91
- p={1}
92
- gap={1}
93
- boxShadow="0 4px 20px rgba(0, 0, 0, 0.4)"
94
- >
95
- <Button
96
- size="sm"
97
- variant="ghost"
98
- borderRadius="full"
99
- position="relative"
100
- px={6}
101
- h="32px"
102
- onClick={() => handleViewChange('explore')}
103
- _hover={{ bg: 'transparent' }}
104
- _active={{ bg: 'transparent' }}
105
- color={view === 'explore' ? 'white' : inactiveColor}
106
- transition="color 0.2s"
107
- >
108
- {view === 'explore' && (
109
- <MotionBox
110
- layoutId="active-pill"
111
- position="absolute"
112
- inset={0}
113
- bg={activeColor}
114
- borderRadius="full"
115
- zIndex={-1}
116
- transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
117
- />
118
- )}
119
- <Text fontSize="xs" fontWeight="bold" zIndex={1}>Explore</Text>
120
- </Button>
121
- <Button
122
- size="sm"
123
- variant="ghost"
124
- borderRadius="full"
125
- position="relative"
126
- px={6}
127
- h="32px"
128
- onClick={() => handleViewChange('hierarchy')}
129
- _hover={{ bg: 'transparent' }}
130
- _active={{
131
- bg: 'transparent'
132
- }}
133
- color={view === 'hierarchy' ? 'white' : inactiveColor}
134
- transition="color 0.2s"
135
- >
136
- {view === 'hierarchy' && (
137
- <MotionBox
138
- layoutId="active-pill"
139
- position="absolute"
140
- inset={0}
141
- bg={activeColor}
142
- borderRadius="full"
143
- zIndex={-1}
144
- transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
145
- />
146
- )}
147
- <Text fontSize="xs" fontWeight="bold" zIndex={1}>Hierarchy</Text>
148
- </Button>
149
- </Flex>
539
+ <DiagramJumpToolbar
540
+ view={view}
541
+ searchTerm={searchTerm}
542
+ searchResults={searchResults}
543
+ activeSearchIndex={activeSearchIndex}
544
+ onSearchChange={handleSearchChange}
545
+ onSearchKeyDown={handleSearchKeyDown}
546
+ onResultClick={commitSearchResult}
547
+ onViewChange={handleViewChange}
548
+ onCreateOpen={() => {
549
+ setNewName('')
550
+ onCreateOpen()
551
+ }}
552
+ />
150
553
 
151
554
  {/* Page Content */}
152
555
  <AnimatePresence mode="wait">
@@ -160,12 +563,78 @@ export default function ViewsPage({ shareSlot, onShareView }: Props) {
160
563
  h="full"
161
564
  >
162
565
  {view === 'explore' ? (
163
- <InfiniteZoom shareSlot={shareSlot} />
566
+ <InfiniteZoom ref={exploreRef} shareSlot={shareSlot} />
164
567
  ) : (
165
- <ViewsGrid onShare={onShareView} />
568
+ <ViewsGrid
569
+ onShare={onShareView}
570
+ treeData={treeData}
571
+ loading={treeLoading}
572
+ focusedId={focusedHierarchyId}
573
+ onFocusChange={setFocusedHierarchyId}
574
+ setTreeData={setTreeData}
575
+ refreshTree={refreshTree}
576
+ />
166
577
  )}
167
578
  </MotionBox>
168
579
  </AnimatePresence>
580
+
581
+ <Modal
582
+ isOpen={isCreateOpen}
583
+ onClose={onCreateClose}
584
+ isCentered
585
+ size="sm"
586
+ >
587
+ <ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
588
+ <ModalContent
589
+ bg="var(--bg-panel)"
590
+ border="1px solid"
591
+ borderColor="var(--border-main)"
592
+ borderRadius="xl"
593
+ boxShadow="0 24px 64px rgba(0,0,0,0.8)"
594
+ >
595
+ <ModalHeader color="gray.100" pb={1} fontSize="md">Create New Diagram</ModalHeader>
596
+ <ModalBody>
597
+ <FormControl id="new-view-name">
598
+ <FormLabel fontSize="xs" color="gray.500" textTransform="uppercase" letterSpacing="0.05em">
599
+ Diagram Name
600
+ </FormLabel>
601
+ <Input
602
+ name="name"
603
+ value={newName}
604
+ onChange={(e) => setNewName(e.target.value)}
605
+ size="sm"
606
+ bg="whiteAlpha.50"
607
+ border="1px solid"
608
+ borderColor="whiteAlpha.100"
609
+ _hover={{ borderColor: 'whiteAlpha.300' }}
610
+ _focus={{ borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }}
611
+ autoFocus
612
+ onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
613
+ placeholder="My New Architecture"
614
+ />
615
+ </FormControl>
616
+ </ModalBody>
617
+ <ModalFooter gap={2} pt={6}>
618
+ <Button size="sm" variant="ghost" color="gray.500" _hover={{ color: 'white', bg: 'whiteAlpha.100' }} onClick={onCreateClose}>
619
+ Cancel
620
+ </Button>
621
+ <Button
622
+ size="sm"
623
+ bg="var(--accent)"
624
+ color="white"
625
+ _hover={{ bg: "var(--accent)", filter: "brightness(1.1)" }}
626
+ _active={{ bg: "var(--accent)", filter: "brightness(0.9)" }}
627
+ isLoading={isCreating}
628
+ isDisabled={!newName.trim()}
629
+ onClick={handleCreate}
630
+ borderRadius="lg"
631
+ px={6}
632
+ >
633
+ Create
634
+ </Button>
635
+ </ModalFooter>
636
+ </ModalContent>
637
+ </Modal>
169
638
  </Box>
170
639
  )
171
640
  }