@toolr/ui-design 0.1.0 → 0.1.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.
- package/components/lib/theme-engine.ts +48 -15
- package/components/sections/ai-tools-paths/tools-paths-panel.tsx +11 -11
- package/components/sections/captured-issues/captured-issues-panel.tsx +20 -20
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +19 -19
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +3 -3
- package/components/sections/golden-snapshots/snapshot-manager.tsx +15 -15
- package/components/sections/golden-snapshots/status-overview.tsx +40 -40
- package/components/sections/golden-snapshots/version-manager.tsx +10 -10
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +11 -11
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +15 -15
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +19 -19
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +10 -10
- package/components/sections/snapshot-browser/snapshot-tree.tsx +11 -11
- package/components/sections/snippets-editor/snippets-editor.tsx +24 -24
- package/components/settings/SettingsHeader.tsx +78 -0
- package/components/settings/SettingsPanel.tsx +21 -0
- package/components/settings/SettingsTreeNav.tsx +256 -0
- package/components/settings/index.ts +7 -0
- package/components/settings/settings-tree-utils.ts +120 -0
- package/components/ui/breadcrumb.tsx +16 -4
- package/components/ui/cookie-consent.tsx +82 -0
- package/components/ui/file-tree.tsx +5 -5
- package/components/ui/filter-dropdown.tsx +4 -4
- package/components/ui/form-actions.tsx +1 -1
- package/components/ui/label.tsx +31 -3
- package/components/ui/resizable-textarea.tsx +2 -2
- package/components/ui/segmented-toggle.tsx +17 -4
- package/components/ui/select.tsx +3 -3
- package/components/ui/sort-dropdown.tsx +2 -2
- package/components/ui/status-card.tsx +1 -1
- package/components/ui/tooltip.tsx +2 -2
- package/dist/index.d.ts +79 -8
- package/dist/index.js +1119 -622
- package/dist/tokens/{tokens/primitives.css → primitives.css} +10 -8
- package/dist/tokens/{tokens/semantic.css → semantic.css} +5 -0
- package/index.ts +13 -0
- package/package.json +6 -2
- package/tokens/primitives.css +10 -8
- package/tokens/semantic.css +5 -0
- /package/dist/tokens/{tokens/theme.css → theme.css} +0 -0
- /package/dist/tokens/{tokens/tokens.json → tokens.json} +0 -0
|
@@ -194,14 +194,14 @@ function SnapshotEntryRow({
|
|
|
194
194
|
|
|
195
195
|
return (
|
|
196
196
|
<div
|
|
197
|
-
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-
|
|
197
|
+
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-neutral-700/50 text-neutral-400"
|
|
198
198
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
199
199
|
>
|
|
200
|
-
<Clock className="w-3 h-3 shrink-0 text-
|
|
200
|
+
<Clock className="w-3 h-3 shrink-0 text-neutral-500" />
|
|
201
201
|
<span className="text-xs flex-1 truncate">
|
|
202
202
|
{searchQuery ? highlightMatch(displayName, searchQuery) : displayName}
|
|
203
203
|
</span>
|
|
204
|
-
<span className="text-[10px] text-
|
|
204
|
+
<span className="text-[10px] text-neutral-500 shrink-0" title={formatFullDate(entry.savedAt)}>
|
|
205
205
|
{formatRelativeTime(entry.savedAt)}
|
|
206
206
|
</span>
|
|
207
207
|
<IconButton
|
|
@@ -248,7 +248,7 @@ function ExpandableNode({
|
|
|
248
248
|
<div>
|
|
249
249
|
<button
|
|
250
250
|
onClick={() => onToggle(path)}
|
|
251
|
-
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer text-
|
|
251
|
+
className="w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer text-neutral-400 hover:bg-neutral-700/50 hover:text-neutral-300"
|
|
252
252
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
253
253
|
>
|
|
254
254
|
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
|
@@ -266,7 +266,7 @@ function ExpandableNode({
|
|
|
266
266
|
</span>
|
|
267
267
|
|
|
268
268
|
{snapshotCount > 0 && (
|
|
269
|
-
<span className="text-xs text-
|
|
269
|
+
<span className="text-xs text-neutral-500 bg-neutral-700 px-1.5 py-0.5 rounded shrink-0">
|
|
270
270
|
{snapshotCount}
|
|
271
271
|
</span>
|
|
272
272
|
)}
|
|
@@ -322,7 +322,7 @@ export function SnapshotTree({
|
|
|
322
322
|
|
|
323
323
|
if (totalCount === 0) {
|
|
324
324
|
return (
|
|
325
|
-
<div className={cn('text-sm text-
|
|
325
|
+
<div className={cn('text-sm text-neutral-500 py-8 text-center', className)}>
|
|
326
326
|
No snapshots saved yet.
|
|
327
327
|
<br />
|
|
328
328
|
<span className="text-xs">Use the camera button in editors to save snapshots.</span>
|
|
@@ -335,7 +335,7 @@ export function SnapshotTree({
|
|
|
335
335
|
{/* Search and controls */}
|
|
336
336
|
<div className="flex items-center gap-2">
|
|
337
337
|
<div className="relative flex-1">
|
|
338
|
-
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-
|
|
338
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-500 pointer-events-none" />
|
|
339
339
|
<Input
|
|
340
340
|
placeholder="Search snapshots..."
|
|
341
341
|
value={searchQuery}
|
|
@@ -375,9 +375,9 @@ export function SnapshotTree({
|
|
|
375
375
|
</div>
|
|
376
376
|
|
|
377
377
|
{/* Tree */}
|
|
378
|
-
<div className="bg-
|
|
378
|
+
<div className="bg-neutral-900 border border-neutral-700 rounded-lg p-2 min-h-[200px] max-h-[60vh] overflow-y-auto">
|
|
379
379
|
{filteredScopes.length === 0 && searchQuery ? (
|
|
380
|
-
<p className="text-xs text-
|
|
380
|
+
<p className="text-xs text-neutral-500 text-center py-4">
|
|
381
381
|
No snapshots match “{searchQuery}”
|
|
382
382
|
</p>
|
|
383
383
|
) : (
|
|
@@ -401,7 +401,7 @@ export function SnapshotTree({
|
|
|
401
401
|
label={category.name}
|
|
402
402
|
icon={
|
|
403
403
|
category.icon ? (
|
|
404
|
-
<History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-
|
|
404
|
+
<History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-neutral-500')} />
|
|
405
405
|
) : undefined
|
|
406
406
|
}
|
|
407
407
|
snapshotCount={countCategorySnapshots(category)}
|
|
@@ -416,7 +416,7 @@ export function SnapshotTree({
|
|
|
416
416
|
id={item.id}
|
|
417
417
|
path={`${scope.id}/${category.id}/${item.id}`}
|
|
418
418
|
label={item.name}
|
|
419
|
-
icon={<History className="w-3.5 h-3.5 text-
|
|
419
|
+
icon={<History className="w-3.5 h-3.5 text-neutral-500" />}
|
|
420
420
|
snapshotCount={item.snapshots.length}
|
|
421
421
|
depth={2}
|
|
422
422
|
isExpanded={expandedPaths.has(`${scope.id}/${category.id}/${item.id}`)}
|
|
@@ -98,25 +98,25 @@ export function SnippetsEditor({
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
|
-
<div className={cn('flex flex-col bg-
|
|
101
|
+
<div className={cn('flex flex-col bg-neutral-900 border border-neutral-700 rounded-lg overflow-hidden', className)}>
|
|
102
102
|
{/* Header */}
|
|
103
|
-
<div className="flex items-center justify-between px-4 py-3 border-b border-
|
|
103
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-neutral-700 bg-purple-500/5">
|
|
104
104
|
<div className="flex items-center gap-2">
|
|
105
105
|
<Braces className="w-4 h-4 text-purple-400" />
|
|
106
|
-
<h3 className="text-sm font-medium text-
|
|
107
|
-
<span className="px-2 py-0.5 text-xs rounded-full bg-
|
|
106
|
+
<h3 className="text-sm font-medium text-neutral-300">{title}</h3>
|
|
107
|
+
<span className="px-2 py-0.5 text-xs rounded-full bg-neutral-700 text-neutral-400">
|
|
108
108
|
{snippets.length}
|
|
109
109
|
</span>
|
|
110
110
|
</div>
|
|
111
|
-
<p className="text-xs text-
|
|
111
|
+
<p className="text-xs text-neutral-500 hidden sm:block">{description}</p>
|
|
112
112
|
</div>
|
|
113
113
|
|
|
114
114
|
{/* Body: two columns */}
|
|
115
115
|
<div className="flex flex-1 min-h-[400px]">
|
|
116
116
|
{/* Left: Snippet list */}
|
|
117
|
-
<div className="flex flex-col border-r border-
|
|
117
|
+
<div className="flex flex-col border-r border-neutral-700" style={{ width: sidebarWidth, minWidth: MIN_SIDEBAR }}>
|
|
118
118
|
{/* Search + Add */}
|
|
119
|
-
<div className="flex items-center gap-1.5 p-2 border-b border-
|
|
119
|
+
<div className="flex items-center gap-1.5 p-2 border-b border-neutral-700">
|
|
120
120
|
<div className="flex-1">
|
|
121
121
|
<Input
|
|
122
122
|
type="search"
|
|
@@ -141,10 +141,10 @@ export function SnippetsEditor({
|
|
|
141
141
|
{filteredSnippets.length === 0 && !isAdding && (
|
|
142
142
|
<div className="text-center py-10 px-4">
|
|
143
143
|
<Braces className="w-8 h-8 mx-auto text-purple-400/40 mb-3" />
|
|
144
|
-
<p className="text-xs text-
|
|
144
|
+
<p className="text-xs text-neutral-500 mb-1">
|
|
145
145
|
{searchQuery ? 'No matching snippets' : 'No snippets defined'}
|
|
146
146
|
</p>
|
|
147
|
-
<p className="text-[10px] text-
|
|
147
|
+
<p className="text-[10px] text-neutral-600">
|
|
148
148
|
{searchQuery ? 'Try a different search term' : 'Click + to add your first snippet'}
|
|
149
149
|
</p>
|
|
150
150
|
</div>
|
|
@@ -186,8 +186,8 @@ export function SnippetsEditor({
|
|
|
186
186
|
<div className="flex-1 flex items-center justify-center p-6">
|
|
187
187
|
<div className="text-center max-w-xs">
|
|
188
188
|
<Braces className="w-10 h-10 mx-auto text-purple-400/30 mb-4" />
|
|
189
|
-
<p className="text-sm text-
|
|
190
|
-
<p className="text-xs text-
|
|
189
|
+
<p className="text-sm text-neutral-500 mb-2">Select a snippet to edit</p>
|
|
190
|
+
<p className="text-xs text-neutral-600 leading-relaxed">
|
|
191
191
|
Choose a snippet from the list, or click{' '}
|
|
192
192
|
<span className="text-blue-400">+</span> to create a new one.
|
|
193
193
|
Reference snippets in prompts with{' '}
|
|
@@ -219,20 +219,20 @@ function SnippetListItem({ snippet, selected, onSelect, onDelete }: SnippetListI
|
|
|
219
219
|
className={cn(
|
|
220
220
|
'group flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors border-l-2',
|
|
221
221
|
selected
|
|
222
|
-
? 'bg-
|
|
223
|
-
: 'border-l-transparent hover:bg-
|
|
222
|
+
? 'bg-neutral-850 border-l-blue-400'
|
|
223
|
+
: 'border-l-transparent hover:bg-neutral-850/50',
|
|
224
224
|
)}
|
|
225
225
|
onClick={onSelect}
|
|
226
226
|
>
|
|
227
227
|
<div className="flex-1 min-w-0">
|
|
228
|
-
<p className="text-xs font-mono font-medium text-
|
|
228
|
+
<p className="text-xs font-mono font-medium text-neutral-300 truncate">
|
|
229
229
|
{snippet.name}
|
|
230
230
|
</p>
|
|
231
|
-
<p className="text-[10px] text-
|
|
231
|
+
<p className="text-[10px] text-neutral-500 truncate mt-0.5">
|
|
232
232
|
{snippet.description}
|
|
233
233
|
</p>
|
|
234
234
|
{snippet.value && (
|
|
235
|
-
<p className="text-[10px] text-
|
|
235
|
+
<p className="text-[10px] text-neutral-600 truncate mt-0.5 font-mono">
|
|
236
236
|
{snippet.value.slice(0, 80)}{snippet.value.length > 80 ? '...' : ''}
|
|
237
237
|
</p>
|
|
238
238
|
)}
|
|
@@ -240,7 +240,7 @@ function SnippetListItem({ snippet, selected, onSelect, onDelete }: SnippetListI
|
|
|
240
240
|
<button
|
|
241
241
|
type="button"
|
|
242
242
|
onClick={(e) => { e.stopPropagation(); onDelete() }}
|
|
243
|
-
className="opacity-0 group-hover:opacity-100 mt-0.5 p-0.5 rounded text-
|
|
243
|
+
className="opacity-0 group-hover:opacity-100 mt-0.5 p-0.5 rounded text-neutral-500 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
|
244
244
|
>
|
|
245
245
|
<X className="w-3 h-3" />
|
|
246
246
|
</button>
|
|
@@ -282,7 +282,7 @@ function SnippetForm({
|
|
|
282
282
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
283
283
|
{/* Name */}
|
|
284
284
|
<div>
|
|
285
|
-
<label className="block text-xs text-
|
|
285
|
+
<label className="block text-xs text-neutral-500 mb-1.5">
|
|
286
286
|
Snippet Name <span className="text-red-400">*</span>
|
|
287
287
|
</label>
|
|
288
288
|
<Input
|
|
@@ -292,14 +292,14 @@ function SnippetForm({
|
|
|
292
292
|
error={nameHasError}
|
|
293
293
|
autoFocus={!isEditing}
|
|
294
294
|
/>
|
|
295
|
-
<p className="mt-1 text-[10px] text-
|
|
295
|
+
<p className="mt-1 text-[10px] text-neutral-600">
|
|
296
296
|
Use in prompts as <span className="font-mono text-purple-400">{'{{' + (formData.name || 'NAME') + '}}'}</span>
|
|
297
297
|
</p>
|
|
298
298
|
</div>
|
|
299
299
|
|
|
300
300
|
{/* Description */}
|
|
301
301
|
<div>
|
|
302
|
-
<label className="block text-xs text-
|
|
302
|
+
<label className="block text-xs text-neutral-500 mb-1.5">
|
|
303
303
|
Description <span className="text-red-400">*</span>
|
|
304
304
|
</label>
|
|
305
305
|
<Input
|
|
@@ -311,7 +311,7 @@ function SnippetForm({
|
|
|
311
311
|
|
|
312
312
|
{/* Value */}
|
|
313
313
|
<div>
|
|
314
|
-
<label className="block text-xs text-
|
|
314
|
+
<label className="block text-xs text-neutral-500 mb-1.5">Value</label>
|
|
315
315
|
<ResizableTextarea
|
|
316
316
|
mode="code"
|
|
317
317
|
language="markdown"
|
|
@@ -319,7 +319,7 @@ function SnippetForm({
|
|
|
319
319
|
onChange={(val) => setFormField('value', val)}
|
|
320
320
|
minHeight={160}
|
|
321
321
|
/>
|
|
322
|
-
<p className="mt-1 text-[10px] text-
|
|
322
|
+
<p className="mt-1 text-[10px] text-neutral-600">
|
|
323
323
|
Can be a single value, multi-line text, or an entire document
|
|
324
324
|
</p>
|
|
325
325
|
</div>
|
|
@@ -333,7 +333,7 @@ function SnippetForm({
|
|
|
333
333
|
</div>
|
|
334
334
|
|
|
335
335
|
{/* Footer actions */}
|
|
336
|
-
<div className="flex items-center justify-between gap-2 border-t border-
|
|
336
|
+
<div className="flex items-center justify-between gap-2 border-t border-neutral-700 px-4 py-3">
|
|
337
337
|
<div>
|
|
338
338
|
{onDelete && (
|
|
339
339
|
<IconButton
|
|
@@ -352,7 +352,7 @@ function SnippetForm({
|
|
|
352
352
|
type="button"
|
|
353
353
|
onClick={onCancel}
|
|
354
354
|
disabled={isSaving}
|
|
355
|
-
className="rounded-md border border-
|
|
355
|
+
className="rounded-md border border-neutral-700 bg-transparent px-3 py-1.5 text-xs text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-300 disabled:opacity-50"
|
|
356
356
|
>
|
|
357
357
|
Cancel
|
|
358
358
|
</button>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { RotateCcw, Info } from 'lucide-react'
|
|
3
|
+
import { IconButton } from '../ui/icon-button.tsx'
|
|
4
|
+
|
|
5
|
+
export interface SettingsHeaderProps {
|
|
6
|
+
description: string
|
|
7
|
+
icon?: ReactNode
|
|
8
|
+
onReset?: () => void
|
|
9
|
+
resetTooltip?: {
|
|
10
|
+
title: string
|
|
11
|
+
description: string
|
|
12
|
+
}
|
|
13
|
+
confirmReset?: boolean
|
|
14
|
+
action?: ReactNode
|
|
15
|
+
variant?: 'default' | 'info' | 'warning' | 'danger'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const variantStyles = {
|
|
19
|
+
default: {
|
|
20
|
+
border: 'border-l-blue-500/60',
|
|
21
|
+
iconBg: 'bg-blue-500/10',
|
|
22
|
+
iconColor: 'text-blue-400',
|
|
23
|
+
},
|
|
24
|
+
info: {
|
|
25
|
+
border: 'border-l-cyan-500/60',
|
|
26
|
+
iconBg: 'bg-cyan-500/10',
|
|
27
|
+
iconColor: 'text-cyan-400',
|
|
28
|
+
},
|
|
29
|
+
warning: {
|
|
30
|
+
border: 'border-l-orange-500/60',
|
|
31
|
+
iconBg: 'bg-orange-500/10',
|
|
32
|
+
iconColor: 'text-orange-400',
|
|
33
|
+
},
|
|
34
|
+
danger: {
|
|
35
|
+
border: 'border-l-red-500/60',
|
|
36
|
+
iconBg: 'bg-red-500/10',
|
|
37
|
+
iconColor: 'text-red-400',
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function SettingsHeader({
|
|
42
|
+
description,
|
|
43
|
+
icon,
|
|
44
|
+
onReset,
|
|
45
|
+
resetTooltip,
|
|
46
|
+
action,
|
|
47
|
+
variant = 'default',
|
|
48
|
+
}: SettingsHeaderProps) {
|
|
49
|
+
const styles = variantStyles[variant]
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={`bg-neutral-950 border border-neutral-700 border-l-2 ${styles.border} rounded-lg p-4 flex items-center gap-4`}
|
|
54
|
+
>
|
|
55
|
+
<div className={`flex-shrink-0 w-8 h-8 rounded-lg ${styles.iconBg} flex items-center justify-center`}>
|
|
56
|
+
{icon ?? <Info className={`w-4 h-4 ${styles.iconColor}`} />}
|
|
57
|
+
</div>
|
|
58
|
+
<p className="flex-1 text-sm text-neutral-400 leading-relaxed">{description}</p>
|
|
59
|
+
<div className="flex-shrink-0 flex items-center gap-2">
|
|
60
|
+
{action}
|
|
61
|
+
{onReset && (
|
|
62
|
+
<IconButton
|
|
63
|
+
icon={<RotateCcw className="w-4 h-4" />}
|
|
64
|
+
onClick={onReset}
|
|
65
|
+
size="sm"
|
|
66
|
+
color="orange"
|
|
67
|
+
tooltip={
|
|
68
|
+
resetTooltip ?? {
|
|
69
|
+
title: 'Reset to Defaults',
|
|
70
|
+
description: 'Restore all settings to their default values',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { SettingsTreeNav } from './SettingsTreeNav.tsx'
|
|
3
|
+
import type { SettingsTreeNode } from './settings-tree-utils.ts'
|
|
4
|
+
|
|
5
|
+
export interface SettingsPanelProps {
|
|
6
|
+
tree: SettingsTreeNode[]
|
|
7
|
+
selectedPath: string
|
|
8
|
+
onSelectPath: (path: string) => void
|
|
9
|
+
children: ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SettingsPanel({ tree, selectedPath, onSelectPath, children }: SettingsPanelProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex-1 flex overflow-hidden">
|
|
15
|
+
<SettingsTreeNav tree={tree} selectedPath={selectedPath} onSelectPath={onSelectPath} />
|
|
16
|
+
<div className="flex-1 overflow-y-auto">
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { ChevronRight, ChevronDown, ChevronsUpDown, ChevronsDownUp } from 'lucide-react'
|
|
3
|
+
import { Input } from '../ui/input.tsx'
|
|
4
|
+
import { IconButton } from '../ui/icon-button.tsx'
|
|
5
|
+
import {
|
|
6
|
+
type SettingsTreeNode,
|
|
7
|
+
isLeafNode,
|
|
8
|
+
collectExpandablePaths,
|
|
9
|
+
getParentPaths,
|
|
10
|
+
filterTree,
|
|
11
|
+
} from './settings-tree-utils.ts'
|
|
12
|
+
|
|
13
|
+
export interface SettingsTreeNavProps {
|
|
14
|
+
tree: SettingsTreeNode[]
|
|
15
|
+
selectedPath: string
|
|
16
|
+
onSelectPath: (path: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TreeNodeProps {
|
|
20
|
+
node: SettingsTreeNode
|
|
21
|
+
path: string
|
|
22
|
+
selectedPath: string
|
|
23
|
+
onSelectPath: (path: string) => void
|
|
24
|
+
expandedPaths: Set<string>
|
|
25
|
+
onToggleExpand: (path: string) => void
|
|
26
|
+
depth: number
|
|
27
|
+
searchQuery?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function highlightMatch(text: string, query: string | undefined) {
|
|
31
|
+
if (!query) return text
|
|
32
|
+
const lowerText = text.toLowerCase()
|
|
33
|
+
const lowerQuery = query.toLowerCase()
|
|
34
|
+
const index = lowerText.indexOf(lowerQuery)
|
|
35
|
+
if (index === -1) return text
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
{text.slice(0, index)}
|
|
39
|
+
<span className="bg-yellow-500/30 text-yellow-200">
|
|
40
|
+
{text.slice(index, index + query.length)}
|
|
41
|
+
</span>
|
|
42
|
+
{text.slice(index + query.length)}
|
|
43
|
+
</>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function TreeNode({
|
|
48
|
+
node,
|
|
49
|
+
path,
|
|
50
|
+
selectedPath,
|
|
51
|
+
onSelectPath,
|
|
52
|
+
expandedPaths,
|
|
53
|
+
onToggleExpand,
|
|
54
|
+
depth,
|
|
55
|
+
searchQuery,
|
|
56
|
+
}: TreeNodeProps) {
|
|
57
|
+
const isLeaf = isLeafNode(node)
|
|
58
|
+
const isExpanded = expandedPaths.has(path)
|
|
59
|
+
const isSelected = selectedPath === path
|
|
60
|
+
const isInSelectedPath = selectedPath.startsWith(path + '.')
|
|
61
|
+
|
|
62
|
+
const handleClick = () => {
|
|
63
|
+
if (isLeaf) {
|
|
64
|
+
onSelectPath(path)
|
|
65
|
+
} else {
|
|
66
|
+
onToggleExpand(path)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div>
|
|
72
|
+
<button
|
|
73
|
+
onClick={handleClick}
|
|
74
|
+
className={`
|
|
75
|
+
w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer
|
|
76
|
+
${isSelected ? 'bg-blue-500/20 text-blue-400' : isInSelectedPath ? 'text-neutral-300' : 'text-neutral-400'}
|
|
77
|
+
${!isSelected && 'hover:bg-neutral-800 hover:text-neutral-200'}
|
|
78
|
+
`}
|
|
79
|
+
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
80
|
+
>
|
|
81
|
+
{!isLeaf && (
|
|
82
|
+
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
|
|
83
|
+
{isExpanded ? (
|
|
84
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
85
|
+
) : (
|
|
86
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
87
|
+
)}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{isLeaf && <span className="w-4 h-4 flex-shrink-0" />}
|
|
92
|
+
|
|
93
|
+
<span className="flex-shrink-0">{node.icon}</span>
|
|
94
|
+
|
|
95
|
+
<span className="truncate">
|
|
96
|
+
{searchQuery ? highlightMatch(node.label, searchQuery) : node.label}
|
|
97
|
+
</span>
|
|
98
|
+
</button>
|
|
99
|
+
|
|
100
|
+
{!isLeaf && isExpanded && node.children && (
|
|
101
|
+
<div>
|
|
102
|
+
{node.children.map((child) => (
|
|
103
|
+
<TreeNode
|
|
104
|
+
key={child.id}
|
|
105
|
+
node={child}
|
|
106
|
+
path={`${path}.${child.id}`}
|
|
107
|
+
selectedPath={selectedPath}
|
|
108
|
+
onSelectPath={onSelectPath}
|
|
109
|
+
expandedPaths={expandedPaths}
|
|
110
|
+
onToggleExpand={onToggleExpand}
|
|
111
|
+
depth={depth + 1}
|
|
112
|
+
searchQuery={searchQuery}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function SettingsTreeNav({ tree, selectedPath, onSelectPath }: SettingsTreeNavProps) {
|
|
122
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
|
123
|
+
const expanded = new Set<string>()
|
|
124
|
+
if (selectedPath) {
|
|
125
|
+
for (const p of getParentPaths(selectedPath)) {
|
|
126
|
+
expanded.add(p)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return expanded
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
133
|
+
|
|
134
|
+
const allExpandablePaths = useMemo(() => collectExpandablePaths(tree), [tree])
|
|
135
|
+
|
|
136
|
+
const filteredTree = useMemo(
|
|
137
|
+
() => filterTree(tree, searchQuery),
|
|
138
|
+
[tree, searchQuery],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const collapsedCount = allExpandablePaths.filter((p) => !expandedPaths.has(p)).length
|
|
142
|
+
const allCollapsed = collapsedCount === allExpandablePaths.length && allExpandablePaths.length > 0
|
|
143
|
+
const allExpanded = collapsedCount === 0
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (selectedPath) {
|
|
147
|
+
const parents = getParentPaths(selectedPath)
|
|
148
|
+
const newExpanded = new Set(expandedPaths)
|
|
149
|
+
let changed = false
|
|
150
|
+
|
|
151
|
+
for (const p of parents) {
|
|
152
|
+
if (!newExpanded.has(p)) {
|
|
153
|
+
newExpanded.add(p)
|
|
154
|
+
changed = true
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (changed) {
|
|
159
|
+
setExpandedPaths(newExpanded)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, [selectedPath])
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (searchQuery.trim()) {
|
|
166
|
+
setExpandedPaths(new Set(allExpandablePaths))
|
|
167
|
+
}
|
|
168
|
+
}, [searchQuery, allExpandablePaths])
|
|
169
|
+
|
|
170
|
+
const handleToggleExpand = (path: string) => {
|
|
171
|
+
setExpandedPaths((prev) => {
|
|
172
|
+
const next = new Set(prev)
|
|
173
|
+
if (next.has(path)) {
|
|
174
|
+
next.delete(path)
|
|
175
|
+
} else {
|
|
176
|
+
next.add(path)
|
|
177
|
+
}
|
|
178
|
+
return next
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handleExpandAll = () => {
|
|
183
|
+
setExpandedPaths(new Set(allExpandablePaths))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const handleCollapseAll = () => {
|
|
187
|
+
const parentPaths = getParentPaths(selectedPath)
|
|
188
|
+
setExpandedPaths(new Set(parentPaths))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<nav className="w-56 flex-shrink-0 bg-neutral-900 border-r border-neutral-800 flex flex-col overflow-hidden">
|
|
193
|
+
<div className="p-2 border-b border-neutral-800">
|
|
194
|
+
<div className="flex items-center gap-1">
|
|
195
|
+
<Input
|
|
196
|
+
type="search"
|
|
197
|
+
placeholder="Search..."
|
|
198
|
+
value={searchQuery}
|
|
199
|
+
onChange={setSearchQuery}
|
|
200
|
+
size="sm"
|
|
201
|
+
variant="filled"
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
{!allExpanded && allExpandablePaths.length > 0 && (
|
|
205
|
+
<IconButton
|
|
206
|
+
icon={<ChevronsUpDown className="w-3.5 h-3.5" />}
|
|
207
|
+
onClick={handleExpandAll}
|
|
208
|
+
size="sm"
|
|
209
|
+
color="neutral"
|
|
210
|
+
tooltipPosition="left"
|
|
211
|
+
tooltip={{
|
|
212
|
+
title: 'Expand All',
|
|
213
|
+
description: 'Expand all sections',
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
{!allCollapsed && allExpandablePaths.length > 0 && (
|
|
218
|
+
<IconButton
|
|
219
|
+
icon={<ChevronsDownUp className="w-3.5 h-3.5" />}
|
|
220
|
+
onClick={handleCollapseAll}
|
|
221
|
+
size="sm"
|
|
222
|
+
color="neutral"
|
|
223
|
+
tooltipPosition="left"
|
|
224
|
+
tooltip={{
|
|
225
|
+
title: 'Collapse All',
|
|
226
|
+
description: 'Collapse all sections',
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="flex-1 overflow-y-auto py-2">
|
|
234
|
+
{filteredTree.length === 0 && searchQuery ? (
|
|
235
|
+
<p className="text-xs text-neutral-500 text-center py-4 px-2">
|
|
236
|
+
No settings match "{searchQuery}"
|
|
237
|
+
</p>
|
|
238
|
+
) : (
|
|
239
|
+
filteredTree.map((node) => (
|
|
240
|
+
<TreeNode
|
|
241
|
+
key={node.id}
|
|
242
|
+
node={node}
|
|
243
|
+
path={node.id}
|
|
244
|
+
selectedPath={selectedPath}
|
|
245
|
+
onSelectPath={onSelectPath}
|
|
246
|
+
expandedPaths={expandedPaths}
|
|
247
|
+
onToggleExpand={handleToggleExpand}
|
|
248
|
+
depth={0}
|
|
249
|
+
searchQuery={searchQuery}
|
|
250
|
+
/>
|
|
251
|
+
))
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
</nav>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { SettingsPanel } from './SettingsPanel.tsx'
|
|
2
|
+
export type { SettingsPanelProps } from './SettingsPanel.tsx'
|
|
3
|
+
export { SettingsTreeNav } from './SettingsTreeNav.tsx'
|
|
4
|
+
export type { SettingsTreeNavProps } from './SettingsTreeNav.tsx'
|
|
5
|
+
export { SettingsHeader } from './SettingsHeader.tsx'
|
|
6
|
+
export type { SettingsHeaderProps } from './SettingsHeader.tsx'
|
|
7
|
+
export * from './settings-tree-utils.ts'
|