@toolr/ui-design 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/components/content/info-panel-primitives.tsx +297 -0
- package/components/diagrams/diagram-utils.tsx +908 -0
- package/components/hooks/use-click-outside.ts +27 -0
- package/components/hooks/use-dropdown-max-height.ts +20 -0
- package/components/hooks/use-navigation-history.ts +94 -0
- package/components/lib/ai-tools.tsx +44 -0
- package/components/lib/cn.ts +6 -0
- package/components/lib/form-colors.ts +32 -0
- package/components/lib/theme-engine.ts +97 -0
- package/components/lib/toolr-brand.tsx +31 -0
- package/components/sections/ai-tools-paths/index.ts +37 -0
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
- package/components/sections/ai-tools-paths/types.ts +111 -0
- package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
- package/components/sections/captured-issues/index.ts +38 -0
- package/components/sections/captured-issues/types.ts +113 -0
- package/components/sections/captured-issues/use-captured-issues.ts +111 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
- package/components/sections/golden-snapshots/index.ts +145 -0
- package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
- package/components/sections/golden-snapshots/status-overview.tsx +305 -0
- package/components/sections/golden-snapshots/types.ts +288 -0
- package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
- package/components/sections/golden-snapshots/version-manager.tsx +186 -0
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
- package/components/sections/prompt-editor/index.ts +121 -0
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
- package/components/sections/prompt-editor/types.ts +101 -0
- package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
- package/components/sections/report-bug/error-logger.ts +392 -0
- package/components/sections/report-bug/index.ts +59 -0
- package/components/sections/report-bug/issue-reporter-api.ts +83 -0
- package/components/sections/report-bug/report-bug-form.tsx +282 -0
- package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
- package/components/sections/report-bug/use-report-bug.ts +170 -0
- package/components/sections/snapshot-browser/index.ts +53 -0
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
- package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
- package/components/sections/snapshot-browser/types.ts +106 -0
- package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
- package/components/sections/snippets-editor/index.ts +31 -0
- package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
- package/components/sections/snippets-editor/types.ts +48 -0
- package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
- package/components/ui/action-dialog.tsx +309 -0
- package/components/ui/ai-action-button.tsx +137 -0
- package/components/ui/ai-execution-action-buttons.tsx +106 -0
- package/components/ui/badge.tsx +67 -0
- package/components/ui/bottom-panel-header.tsx +240 -0
- package/components/ui/breadcrumb.tsx +168 -0
- package/components/ui/checkbox.tsx +102 -0
- package/components/ui/collapsible-section.tsx +100 -0
- package/components/ui/confirm-badge.tsx +71 -0
- package/components/ui/detail-section.tsx +67 -0
- package/components/ui/detail-view-wrapper.tsx +55 -0
- package/components/ui/editor-placeholder-card.tsx +197 -0
- package/components/ui/editor-toolbar.tsx +123 -0
- package/components/ui/execution-details-panel.tsx +93 -0
- package/components/ui/extension-list-card.tsx +105 -0
- package/components/ui/file-structure-section.tsx +373 -0
- package/components/ui/file-tree.tsx +171 -0
- package/components/ui/files-panel.tsx +251 -0
- package/components/ui/filter-dropdown.tsx +173 -0
- package/components/ui/form-actions.tsx +127 -0
- package/components/ui/frontmatter-form-header.tsx +80 -0
- package/components/ui/icon-button.tsx +388 -0
- package/components/ui/input.tsx +211 -0
- package/components/ui/label.tsx +159 -0
- package/components/ui/layout-tab-bar.tsx +289 -0
- package/components/ui/modal.tsx +194 -0
- package/components/ui/nav-card.tsx +81 -0
- package/components/ui/navigation-bar.tsx +285 -0
- package/components/ui/number-input.tsx +165 -0
- package/components/ui/registry-browser.tsx +261 -0
- package/components/ui/registry-card.tsx +710 -0
- package/components/ui/registry-detail.tsx +224 -0
- package/components/ui/resizable-textarea.tsx +290 -0
- package/components/ui/scope-badge.tsx +67 -0
- package/components/ui/segmented-toggle.tsx +133 -0
- package/components/ui/select.tsx +172 -0
- package/components/ui/selection-grid.tsx +313 -0
- package/components/ui/setting-row.tsx +97 -0
- package/components/ui/snapshot-card.tsx +107 -0
- package/components/ui/snippets-panel.tsx +161 -0
- package/components/ui/sort-dropdown.tsx +109 -0
- package/components/ui/status-card.tsx +96 -0
- package/components/ui/tab-bar.tsx +340 -0
- package/components/ui/toggle.tsx +142 -0
- package/components/ui/tooltip.tsx +326 -0
- package/dist/content.d.ts +110 -0
- package/dist/content.js +195 -0
- package/dist/diagrams.d.ts +371 -0
- package/dist/diagrams.js +702 -0
- package/dist/index.d.ts +2714 -0
- package/dist/index.js +11220 -0
- package/dist/preset.d.ts +24 -0
- package/dist/preset.js +17 -0
- package/dist/tokens/tokens/primitives.css +45 -0
- package/dist/tokens/tokens/semantic.css +46 -0
- package/dist/tokens/tokens/theme.css +11 -0
- package/dist/tokens/tokens/tokens.json +65 -0
- package/index.ts +123 -0
- package/package.json +63 -0
- package/tailwind-preset.ts +22 -0
- package/tokens/primitives.css +45 -0
- package/tokens/semantic.css +46 -0
- package/tokens/theme.css +11 -0
- package/tokens/tokens.json +65 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent } from 'react'
|
|
2
|
+
import { ChevronUp, ChevronDown } from 'lucide-react'
|
|
3
|
+
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
4
|
+
|
|
5
|
+
export interface NumberInputProps {
|
|
6
|
+
value: number
|
|
7
|
+
onChange: (value: number) => void
|
|
8
|
+
min?: number
|
|
9
|
+
max?: number
|
|
10
|
+
step?: number
|
|
11
|
+
variant?: 'filled' | 'outline'
|
|
12
|
+
color?: FormColor
|
|
13
|
+
size?: 'xss' | 'xs' | 'sm' | 'md' | 'lg'
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SIZE_CONFIG = {
|
|
19
|
+
xss: { wrapper: 'h-[18px]', input: 'px-1 text-[10px]', chevron: 'w-2.5 h-2.5', stepperW: 'w-4' },
|
|
20
|
+
xs: { wrapper: 'h-6', input: 'px-1.5 text-xs', chevron: 'w-2.5 h-2.5', stepperW: 'w-5' },
|
|
21
|
+
sm: { wrapper: 'h-7', input: 'px-2 text-xs', chevron: 'w-3 h-3', stepperW: 'w-5' },
|
|
22
|
+
md: { wrapper: 'h-8', input: 'px-3 text-sm', chevron: 'w-3 h-3', stepperW: 'w-6' },
|
|
23
|
+
lg: { wrapper: 'h-9', input: 'px-3 text-sm', chevron: 'w-3.5 h-3.5', stepperW: 'w-7' },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const VARIANT_CLASSES = {
|
|
27
|
+
filled: 'bg-neutral-800',
|
|
28
|
+
outline: 'bg-transparent',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function NumberInput({
|
|
32
|
+
value,
|
|
33
|
+
onChange,
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
step = 1,
|
|
37
|
+
variant = 'outline',
|
|
38
|
+
color = 'blue',
|
|
39
|
+
size = 'sm',
|
|
40
|
+
disabled = false,
|
|
41
|
+
className = '',
|
|
42
|
+
}: NumberInputProps) {
|
|
43
|
+
const [focused, setFocused] = useState(false)
|
|
44
|
+
const [editText, setEditText] = useState<string | null>(null)
|
|
45
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
46
|
+
|
|
47
|
+
const clamp = useCallback((n: number) => {
|
|
48
|
+
if (min != null && n < min) return min
|
|
49
|
+
if (max != null && n > max) return max
|
|
50
|
+
return n
|
|
51
|
+
}, [min, max])
|
|
52
|
+
|
|
53
|
+
const nudge = useCallback((direction: 1 | -1) => {
|
|
54
|
+
onChange(clamp(value + step * direction))
|
|
55
|
+
}, [value, step, clamp, onChange])
|
|
56
|
+
|
|
57
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
58
|
+
if (e.key === 'ArrowUp') {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
nudge(1)
|
|
61
|
+
} else if (e.key === 'ArrowDown') {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
nudge(-1)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const commitEdit = () => {
|
|
68
|
+
if (editText != null) {
|
|
69
|
+
const parsed = Number(editText)
|
|
70
|
+
if (!Number.isNaN(parsed)) {
|
|
71
|
+
onChange(clamp(parsed))
|
|
72
|
+
}
|
|
73
|
+
setEditText(null)
|
|
74
|
+
}
|
|
75
|
+
setFocused(false)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sc = SIZE_CONFIG[size]
|
|
79
|
+
const fc = FORM_COLORS[color]
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={`
|
|
84
|
+
inline-flex items-stretch rounded-lg border transition-colors
|
|
85
|
+
${VARIANT_CLASSES[variant]}
|
|
86
|
+
${focused ? fc.focus.replace('focus:', '') : fc.border}
|
|
87
|
+
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
|
88
|
+
${className}
|
|
89
|
+
`}
|
|
90
|
+
>
|
|
91
|
+
<input
|
|
92
|
+
ref={inputRef}
|
|
93
|
+
type="text"
|
|
94
|
+
inputMode="numeric"
|
|
95
|
+
value={editText ?? value}
|
|
96
|
+
onChange={(e) => setEditText(e.target.value)}
|
|
97
|
+
onFocus={() => {
|
|
98
|
+
setFocused(true)
|
|
99
|
+
setEditText(String(value))
|
|
100
|
+
}}
|
|
101
|
+
onBlur={commitEdit}
|
|
102
|
+
onKeyDown={(e) => {
|
|
103
|
+
if (e.key === 'Enter') {
|
|
104
|
+
commitEdit()
|
|
105
|
+
inputRef.current?.blur()
|
|
106
|
+
} else {
|
|
107
|
+
handleKeyDown(e)
|
|
108
|
+
}
|
|
109
|
+
}}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
style={{ width: `${Math.max(1, String(editText ?? value).length) + 0.5}ch` }}
|
|
112
|
+
className={`
|
|
113
|
+
${sc.wrapper} ${sc.input}
|
|
114
|
+
bg-transparent text-neutral-200 text-right
|
|
115
|
+
focus:outline-none
|
|
116
|
+
box-content min-w-0
|
|
117
|
+
disabled:cursor-not-allowed
|
|
118
|
+
[appearance:textfield]
|
|
119
|
+
[&::-webkit-outer-spin-button]:appearance-none
|
|
120
|
+
[&::-webkit-inner-spin-button]:appearance-none
|
|
121
|
+
`}
|
|
122
|
+
/>
|
|
123
|
+
<div
|
|
124
|
+
className={`
|
|
125
|
+
flex flex-col border-l ${fc.border} ${sc.stepperW}
|
|
126
|
+
${disabled ? 'pointer-events-none' : ''}
|
|
127
|
+
`}
|
|
128
|
+
>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
tabIndex={-1}
|
|
132
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
133
|
+
onClick={() => nudge(1)}
|
|
134
|
+
disabled={disabled || (max != null && value >= max)}
|
|
135
|
+
className={`
|
|
136
|
+
flex-1 flex items-center justify-center
|
|
137
|
+
text-neutral-500 hover:text-neutral-200 hover:bg-white/5
|
|
138
|
+
transition-colors cursor-pointer
|
|
139
|
+
disabled:opacity-30 disabled:cursor-not-allowed
|
|
140
|
+
rounded-tr-[calc(0.5rem-1px)]
|
|
141
|
+
`}
|
|
142
|
+
>
|
|
143
|
+
<ChevronUp className={sc.chevron} />
|
|
144
|
+
</button>
|
|
145
|
+
<div className={`border-t ${fc.border}`} />
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
tabIndex={-1}
|
|
149
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
150
|
+
onClick={() => nudge(-1)}
|
|
151
|
+
disabled={disabled || (min != null && value <= min)}
|
|
152
|
+
className={`
|
|
153
|
+
flex-1 flex items-center justify-center
|
|
154
|
+
text-neutral-500 hover:text-neutral-200 hover:bg-white/5
|
|
155
|
+
transition-colors cursor-pointer
|
|
156
|
+
disabled:opacity-30 disabled:cursor-not-allowed
|
|
157
|
+
rounded-br-[calc(0.5rem-1px)]
|
|
158
|
+
`}
|
|
159
|
+
>
|
|
160
|
+
<ChevronDown className={sc.chevron} />
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { type ReactNode, useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { Search, ArrowRight, RefreshCw, Loader2, X, AlertTriangle } from 'lucide-react'
|
|
3
|
+
import { IconButton } from './icon-button.tsx'
|
|
4
|
+
import { Input } from './input.tsx'
|
|
5
|
+
import { RegistryCard, type RegistryCardProps } from './registry-card.tsx'
|
|
6
|
+
|
|
7
|
+
const PAGE_SIZE = 60
|
|
8
|
+
|
|
9
|
+
export interface RegistryBrowserProps {
|
|
10
|
+
// Content states
|
|
11
|
+
isLoading: boolean
|
|
12
|
+
loadingMessage?: string
|
|
13
|
+
error: string | null
|
|
14
|
+
isEmpty: boolean
|
|
15
|
+
emptyMessage?: string
|
|
16
|
+
onRetry?: () => void
|
|
17
|
+
|
|
18
|
+
// Search
|
|
19
|
+
searchQuery: string
|
|
20
|
+
onSearchChange: (q: string) => void
|
|
21
|
+
onSearchSubmit?: () => void
|
|
22
|
+
searchPlaceholder?: string
|
|
23
|
+
|
|
24
|
+
// Debounce indicator: increment key on each keystroke to restart the animation
|
|
25
|
+
debounceKey?: number
|
|
26
|
+
debounceDurationMs?: number
|
|
27
|
+
|
|
28
|
+
// Toolbar extras (between search and refresh)
|
|
29
|
+
toolbarCenter?: ReactNode
|
|
30
|
+
toolbarEnd?: ReactNode
|
|
31
|
+
|
|
32
|
+
// Refresh
|
|
33
|
+
onRefresh?: () => void
|
|
34
|
+
refreshTooltip?: { title?: string; description: string }
|
|
35
|
+
|
|
36
|
+
// Above grid (e.g., type tabs, suggested tags)
|
|
37
|
+
aboveGrid?: ReactNode
|
|
38
|
+
|
|
39
|
+
// Reset filters
|
|
40
|
+
hasActiveFilters?: boolean
|
|
41
|
+
onResetFilters?: () => void
|
|
42
|
+
|
|
43
|
+
// Scroll persistence
|
|
44
|
+
initialScrollTop?: number
|
|
45
|
+
onScrollChange?: (scrollTop: number) => void
|
|
46
|
+
|
|
47
|
+
// Max width class
|
|
48
|
+
maxWidth?: string
|
|
49
|
+
|
|
50
|
+
// Grid items
|
|
51
|
+
items: RegistryCardProps[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function RegistryBrowser({
|
|
55
|
+
isLoading,
|
|
56
|
+
loadingMessage = 'Loading...',
|
|
57
|
+
error,
|
|
58
|
+
isEmpty,
|
|
59
|
+
emptyMessage = 'No items match your filters.',
|
|
60
|
+
onRetry,
|
|
61
|
+
searchQuery,
|
|
62
|
+
onSearchChange,
|
|
63
|
+
onSearchSubmit,
|
|
64
|
+
searchPlaceholder = 'Search...',
|
|
65
|
+
debounceKey,
|
|
66
|
+
debounceDurationMs = 1000,
|
|
67
|
+
toolbarCenter,
|
|
68
|
+
toolbarEnd,
|
|
69
|
+
onRefresh,
|
|
70
|
+
refreshTooltip,
|
|
71
|
+
aboveGrid,
|
|
72
|
+
hasActiveFilters,
|
|
73
|
+
onResetFilters,
|
|
74
|
+
initialScrollTop,
|
|
75
|
+
onScrollChange,
|
|
76
|
+
maxWidth = 'max-w-[1440px]',
|
|
77
|
+
items,
|
|
78
|
+
}: RegistryBrowserProps) {
|
|
79
|
+
// Incremental rendering: render a page at a time, load more on scroll
|
|
80
|
+
const totalCount = items.length
|
|
81
|
+
const needsPaging = totalCount > PAGE_SIZE
|
|
82
|
+
|
|
83
|
+
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE)
|
|
84
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
86
|
+
const isFirstRender = useRef(true)
|
|
87
|
+
const prevTotalCount = useRef(totalCount)
|
|
88
|
+
|
|
89
|
+
// Reset visible count when the item list changes (filters/sort/search)
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setVisibleCount(PAGE_SIZE)
|
|
92
|
+
}, [totalCount])
|
|
93
|
+
|
|
94
|
+
// Scroll back to top when list changes — but skip on first render if restoring scroll
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (isFirstRender.current) {
|
|
97
|
+
isFirstRender.current = false
|
|
98
|
+
if (initialScrollTop && initialScrollTop > 0) {
|
|
99
|
+
// Restore scroll position on first render
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
scrollRef.current?.scrollTo(0, initialScrollTop)
|
|
102
|
+
})
|
|
103
|
+
prevTotalCount.current = totalCount
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Only scroll to top when totalCount actually changes (filter/search action)
|
|
108
|
+
if (totalCount !== prevTotalCount.current) {
|
|
109
|
+
scrollRef.current?.scrollTo(0, 0)
|
|
110
|
+
prevTotalCount.current = totalCount
|
|
111
|
+
}
|
|
112
|
+
}, [totalCount, initialScrollTop])
|
|
113
|
+
|
|
114
|
+
// Debounced scroll position reporting
|
|
115
|
+
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
116
|
+
const handleScroll = useCallback(() => {
|
|
117
|
+
if (!onScrollChange) return
|
|
118
|
+
clearTimeout(scrollTimerRef.current)
|
|
119
|
+
scrollTimerRef.current = setTimeout(() => {
|
|
120
|
+
if (scrollRef.current) {
|
|
121
|
+
onScrollChange(scrollRef.current.scrollTop)
|
|
122
|
+
}
|
|
123
|
+
}, 150)
|
|
124
|
+
}, [onScrollChange])
|
|
125
|
+
|
|
126
|
+
const loadMore = useCallback(() => {
|
|
127
|
+
setVisibleCount((prev: number) => Math.min(prev + PAGE_SIZE, totalCount))
|
|
128
|
+
}, [totalCount])
|
|
129
|
+
|
|
130
|
+
// IntersectionObserver using the scroll container as root
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const sentinel = sentinelRef.current
|
|
133
|
+
const scrollRoot = scrollRef.current
|
|
134
|
+
if (!sentinel || !scrollRoot || !needsPaging) return
|
|
135
|
+
|
|
136
|
+
const observer = new IntersectionObserver(
|
|
137
|
+
([entry]) => {
|
|
138
|
+
if (entry.isIntersecting) loadMore()
|
|
139
|
+
},
|
|
140
|
+
{ root: scrollRoot, rootMargin: '400px' },
|
|
141
|
+
)
|
|
142
|
+
observer.observe(sentinel)
|
|
143
|
+
return () => observer.disconnect()
|
|
144
|
+
}, [needsPaging, loadMore])
|
|
145
|
+
|
|
146
|
+
const visibleItems = needsPaging ? items.slice(0, visibleCount) : items
|
|
147
|
+
const hasMore = visibleCount < totalCount
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div ref={scrollRef} className="h-full overflow-y-auto p-4 pb-8" onScroll={handleScroll}>
|
|
151
|
+
{/* Toolbar */}
|
|
152
|
+
<div className="sticky top-0 flex items-center gap-2 pb-4">
|
|
153
|
+
<div className="relative flex-1 max-w-sm">
|
|
154
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500" />
|
|
155
|
+
<Input
|
|
156
|
+
value={searchQuery}
|
|
157
|
+
onChange={onSearchChange}
|
|
158
|
+
onKeyDown={onSearchSubmit ? (e: { key: string }) => { if (e.key === 'Enter') onSearchSubmit() } : undefined}
|
|
159
|
+
placeholder={searchPlaceholder}
|
|
160
|
+
variant="outline"
|
|
161
|
+
className="pl-8 pr-8"
|
|
162
|
+
/>
|
|
163
|
+
{searchQuery && (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={() => onSearchChange('')}
|
|
167
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors"
|
|
168
|
+
>
|
|
169
|
+
<X className="w-2.5 h-2.5" />
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
{debounceKey != null && debounceKey > 0 && (
|
|
173
|
+
<svg
|
|
174
|
+
key={debounceKey}
|
|
175
|
+
className="absolute inset-0 pointer-events-none"
|
|
176
|
+
style={{ width: '100%', height: '100%' }}
|
|
177
|
+
>
|
|
178
|
+
<rect
|
|
179
|
+
x="1" y="1" rx="5" ry="5"
|
|
180
|
+
fill="none"
|
|
181
|
+
stroke="rgb(52 211 153 / 0.7)"
|
|
182
|
+
strokeWidth="1.5"
|
|
183
|
+
pathLength="100"
|
|
184
|
+
strokeDasharray="100"
|
|
185
|
+
strokeDashoffset="0"
|
|
186
|
+
style={{
|
|
187
|
+
width: 'calc(100% - 2px)',
|
|
188
|
+
height: 'calc(100% - 2px)',
|
|
189
|
+
animation: `debounce-border-drain ${debounceDurationMs}ms linear forwards`,
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</svg>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{onSearchSubmit && (
|
|
197
|
+
<IconButton
|
|
198
|
+
icon={<ArrowRight className="w-3.5 h-3.5" />}
|
|
199
|
+
onClick={onSearchSubmit}
|
|
200
|
+
size="sm"
|
|
201
|
+
color="blue"
|
|
202
|
+
tooltip={{ description: 'Search' }}
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
<div className="flex-1" />
|
|
207
|
+
|
|
208
|
+
{toolbarCenter}
|
|
209
|
+
{toolbarEnd}
|
|
210
|
+
|
|
211
|
+
{onRefresh && (
|
|
212
|
+
<IconButton
|
|
213
|
+
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
|
214
|
+
onClick={onRefresh}
|
|
215
|
+
size="sm"
|
|
216
|
+
tooltip={refreshTooltip ?? { description: 'Refresh registry data' }}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Above grid */}
|
|
222
|
+
{aboveGrid}
|
|
223
|
+
|
|
224
|
+
{/* Content */}
|
|
225
|
+
<div className={`${maxWidth} mx-auto relative z-10`}>
|
|
226
|
+
{isLoading ? (
|
|
227
|
+
<div className="flex items-center justify-center py-12 text-neutral-500">
|
|
228
|
+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
229
|
+
{loadingMessage}
|
|
230
|
+
</div>
|
|
231
|
+
) : error ? (
|
|
232
|
+
<div className="flex flex-col items-center justify-center py-12 text-neutral-500">
|
|
233
|
+
<AlertTriangle className="w-6 h-6 text-amber-400 mb-2" />
|
|
234
|
+
<p className="text-sm">{error}</p>
|
|
235
|
+
{onRetry && (
|
|
236
|
+
<button onClick={onRetry} className="mt-2 text-xs text-blue-400 hover:underline cursor-pointer">
|
|
237
|
+
Try again
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
) : isEmpty ? (
|
|
242
|
+
<div className="flex flex-col items-center justify-center py-12 text-neutral-500">
|
|
243
|
+
<p className="text-sm">{emptyMessage}</p>
|
|
244
|
+
{hasActiveFilters && onResetFilters && (
|
|
245
|
+
<button onClick={onResetFilters} className="mt-2 text-xs text-blue-400 hover:underline cursor-pointer">
|
|
246
|
+
Reset filters
|
|
247
|
+
</button>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
) : (
|
|
251
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
|
252
|
+
{visibleItems.map((item) => (
|
|
253
|
+
<RegistryCard key={item.name} {...item} />
|
|
254
|
+
))}
|
|
255
|
+
{hasMore && <div ref={sentinelRef} className="col-span-full h-1" />}
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
}
|