@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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot Browser — Section barrel export
|
|
3
|
+
*
|
|
4
|
+
* This section provides a complete, reusable snapshot management feature
|
|
5
|
+
* with a hierarchical tree browser, search, and delete capabilities.
|
|
6
|
+
*
|
|
7
|
+
* File structure:
|
|
8
|
+
* - snapshot-browser-panel.tsx — Main panel component (drop-in usage)
|
|
9
|
+
* - snapshot-tree.tsx — Hierarchical tree sub-component
|
|
10
|
+
* - use-snapshot-browser.ts — Tree state & search hook
|
|
11
|
+
* - types.ts — Data types and API interface
|
|
12
|
+
*
|
|
13
|
+
* Quick start for consuming apps:
|
|
14
|
+
* import { SnapshotBrowserPanel } from '@toolr/ui-design'
|
|
15
|
+
*
|
|
16
|
+
* <SnapshotBrowserPanel
|
|
17
|
+
* api={{ deleteSnapshot, clearAllSnapshots }}
|
|
18
|
+
* scopes={scopes}
|
|
19
|
+
* snapshotLimit={20}
|
|
20
|
+
* onSnapshotLimitChange={setLimit}
|
|
21
|
+
* onClearAll={handleClearAll}
|
|
22
|
+
* />
|
|
23
|
+
*
|
|
24
|
+
* Data hierarchy:
|
|
25
|
+
* SnapshotScope → SnapshotCategory → SnapshotItem → SnapshotEntry
|
|
26
|
+
*
|
|
27
|
+
* Example scope structure:
|
|
28
|
+
* { id: 'settings', name: 'Settings (Prompts)', categories: [
|
|
29
|
+
* { id: 'verifier', name: 'Verifier Prompts', items: [
|
|
30
|
+
* { id: 'system-prompt', name: 'System Prompt', snapshots: [
|
|
31
|
+
* { id: 'snap-1', content: '...', savedAt: '2026-02-25T10:00:00Z' }
|
|
32
|
+
* ]}
|
|
33
|
+
* ]}
|
|
34
|
+
* ]}
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
// Main panel component
|
|
38
|
+
export { SnapshotBrowserPanel, type SnapshotBrowserPanelProps } from './snapshot-browser-panel.tsx'
|
|
39
|
+
|
|
40
|
+
// Tree sub-component (standalone usage)
|
|
41
|
+
export { SnapshotTree, type SnapshotTreeProps } from './snapshot-tree.tsx'
|
|
42
|
+
|
|
43
|
+
// Hook for custom UIs
|
|
44
|
+
export { useSnapshotBrowser, type UseSnapshotBrowserOptions, type UseSnapshotBrowserReturn } from './use-snapshot-browser.ts'
|
|
45
|
+
|
|
46
|
+
// Data types and API interface
|
|
47
|
+
export type {
|
|
48
|
+
SnapshotEntry,
|
|
49
|
+
SnapshotItem,
|
|
50
|
+
SnapshotCategory,
|
|
51
|
+
SnapshotScope,
|
|
52
|
+
SnapshotBrowserApi,
|
|
53
|
+
} from './types.ts'
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapshotBrowserPanel — Self-contained snapshot management panel
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snapshot Browser
|
|
5
|
+
*
|
|
6
|
+
* This is the main "drop in and it works" component. Replicate the configr
|
|
7
|
+
* app's Settings > General > Snapshots page as a reusable panel. Provide
|
|
8
|
+
* scopes data, an API adapter, and snapshot limit controls.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <SnapshotBrowserPanel
|
|
12
|
+
* api={snapshotApi}
|
|
13
|
+
* scopes={scopes}
|
|
14
|
+
* snapshotLimit={20}
|
|
15
|
+
* onSnapshotLimitChange={setLimit}
|
|
16
|
+
* onClearAll={handleClearAll}
|
|
17
|
+
* />
|
|
18
|
+
*
|
|
19
|
+
* AI agent notes:
|
|
20
|
+
* - This component manages tree expansion state internally via useSnapshotBrowser hook
|
|
21
|
+
* - It renders: snapshot limit selector, hierarchical tree browser, help section
|
|
22
|
+
* - The api prop bridges to the app backend (Tauri commands, REST, etc.)
|
|
23
|
+
* - Consuming apps provide scopes data and handle persistence
|
|
24
|
+
* - Uses ui-design components (Select, IconButton, Input) for consistency
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { Trash2, HelpCircle } from 'lucide-react'
|
|
28
|
+
import { cn } from '../../lib/cn.ts'
|
|
29
|
+
import { Select } from '../../ui/select.tsx'
|
|
30
|
+
import { IconButton } from '../../ui/icon-button.tsx'
|
|
31
|
+
import { SnapshotTree } from './snapshot-tree.tsx'
|
|
32
|
+
import { useSnapshotBrowser } from './use-snapshot-browser.ts'
|
|
33
|
+
import type { SnapshotScope, SnapshotBrowserApi } from './types.ts'
|
|
34
|
+
|
|
35
|
+
const SNAPSHOT_LIMIT_OPTIONS = [5, 10, 15, 20, 25, 30, 40, 50].map((n) => ({
|
|
36
|
+
value: n,
|
|
37
|
+
label: String(n),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
export interface SnapshotBrowserPanelProps {
|
|
41
|
+
api: SnapshotBrowserApi
|
|
42
|
+
scopes: SnapshotScope[]
|
|
43
|
+
snapshotLimit: number
|
|
44
|
+
onSnapshotLimitChange: (limit: number) => void
|
|
45
|
+
onClearAll?: () => void
|
|
46
|
+
className?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function SnapshotBrowserPanel({
|
|
50
|
+
api,
|
|
51
|
+
scopes,
|
|
52
|
+
snapshotLimit,
|
|
53
|
+
onSnapshotLimitChange,
|
|
54
|
+
onClearAll,
|
|
55
|
+
className,
|
|
56
|
+
}: SnapshotBrowserPanelProps) {
|
|
57
|
+
const {
|
|
58
|
+
searchQuery,
|
|
59
|
+
setSearchQuery,
|
|
60
|
+
expandedPaths,
|
|
61
|
+
toggleExpand,
|
|
62
|
+
expandAll,
|
|
63
|
+
collapseAll,
|
|
64
|
+
allExpanded,
|
|
65
|
+
totalSnapshotCount,
|
|
66
|
+
allExpandablePaths,
|
|
67
|
+
deleteSnapshot,
|
|
68
|
+
deletingSnapshotId,
|
|
69
|
+
} = useSnapshotBrowser({ scopes, api })
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className={cn('space-y-6', className)}>
|
|
73
|
+
{/* Snapshot Limit */}
|
|
74
|
+
<div className="bg-[#181825] border border-[#313244] rounded-lg p-4">
|
|
75
|
+
<div className="flex items-center justify-between">
|
|
76
|
+
<div>
|
|
77
|
+
<label className="text-[#cdd6f4]">Snapshot Limit</label>
|
|
78
|
+
<p className="text-sm text-[#6c7086]">
|
|
79
|
+
Maximum number of snapshots to keep per item (1-50)
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
<Select
|
|
83
|
+
value={snapshotLimit}
|
|
84
|
+
options={SNAPSHOT_LIMIT_OPTIONS}
|
|
85
|
+
onChange={(v) => onSnapshotLimitChange(v)}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Snapshot Browser */}
|
|
91
|
+
<div className="bg-[#181825] border border-[#313244] rounded-lg p-4">
|
|
92
|
+
<div className="flex items-center justify-between mb-3">
|
|
93
|
+
<div>
|
|
94
|
+
<label className="text-[#cdd6f4]">Browse Snapshots</label>
|
|
95
|
+
<p className="text-sm text-[#6c7086]">
|
|
96
|
+
{totalSnapshotCount === 0
|
|
97
|
+
? 'No snapshots saved yet'
|
|
98
|
+
: `${totalSnapshotCount} snapshot${totalSnapshotCount === 1 ? '' : 's'} stored`}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<IconButton
|
|
102
|
+
icon={<Trash2 className="w-4 h-4" />}
|
|
103
|
+
onClick={() => onClearAll?.()}
|
|
104
|
+
disabled={totalSnapshotCount === 0 || !onClearAll}
|
|
105
|
+
size="sm"
|
|
106
|
+
color="red"
|
|
107
|
+
tooltip={{
|
|
108
|
+
title: 'Clear all snapshots',
|
|
109
|
+
description: 'Delete all saved snapshots',
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
<SnapshotTree
|
|
114
|
+
scopes={scopes}
|
|
115
|
+
searchQuery={searchQuery}
|
|
116
|
+
onSearchChange={setSearchQuery}
|
|
117
|
+
expandedPaths={expandedPaths}
|
|
118
|
+
onToggleExpand={toggleExpand}
|
|
119
|
+
onExpandAll={expandAll}
|
|
120
|
+
onCollapseAll={collapseAll}
|
|
121
|
+
allExpanded={allExpanded}
|
|
122
|
+
allExpandablePaths={allExpandablePaths}
|
|
123
|
+
onDeleteSnapshot={deleteSnapshot}
|
|
124
|
+
deletingSnapshotId={deletingSnapshotId}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Help */}
|
|
129
|
+
<div className="bg-[#181825]/50 border border-[#313244] rounded-lg p-4">
|
|
130
|
+
<div className="flex items-start gap-3">
|
|
131
|
+
<HelpCircle className="w-4 h-4 text-[#6c7086] mt-0.5 shrink-0" />
|
|
132
|
+
<div className="text-sm text-[#6c7086] space-y-2">
|
|
133
|
+
<p>
|
|
134
|
+
<strong className="text-[#a6adc8]">How snapshots work:</strong>
|
|
135
|
+
</p>
|
|
136
|
+
<ul className="list-disc list-inside space-y-1 ml-1">
|
|
137
|
+
<li>Click “Save Snapshot” in any editor to save the current version</li>
|
|
138
|
+
<li>When resetting content, you can choose from saved snapshots or the file default</li>
|
|
139
|
+
<li>Snapshots are stored locally and persist across sessions</li>
|
|
140
|
+
<li>When the limit is reached, the oldest snapshot is removed</li>
|
|
141
|
+
</ul>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapshotTree — Hierarchical tree browser for viewing and managing snapshots
|
|
3
|
+
*
|
|
4
|
+
* Part of: Sections > Snapshot Browser
|
|
5
|
+
*
|
|
6
|
+
* Renders a 4-level tree: Scope > Category > Item > Snapshot
|
|
7
|
+
* with search filtering, expand/collapse all, and delete actions.
|
|
8
|
+
*
|
|
9
|
+
* Ported from configr's SnapshotBrowser component, decoupled from Zustand stores.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useMemo } from 'react'
|
|
13
|
+
import {
|
|
14
|
+
ChevronRight,
|
|
15
|
+
ChevronDown,
|
|
16
|
+
Search,
|
|
17
|
+
X,
|
|
18
|
+
ChevronsUpDown,
|
|
19
|
+
ChevronsDownUp,
|
|
20
|
+
Trash2,
|
|
21
|
+
History,
|
|
22
|
+
Clock,
|
|
23
|
+
} from 'lucide-react'
|
|
24
|
+
import { cn } from '../../lib/cn.ts'
|
|
25
|
+
import { Input } from '../../ui/input.tsx'
|
|
26
|
+
import { IconButton } from '../../ui/icon-button.tsx'
|
|
27
|
+
import type { SnapshotScope, SnapshotCategory, SnapshotItem, SnapshotEntry } from './types.ts'
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
function formatRelativeTime(isoString: string): string {
|
|
34
|
+
const date = new Date(isoString)
|
|
35
|
+
const now = new Date()
|
|
36
|
+
const diffMs = now.getTime() - date.getTime()
|
|
37
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
38
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
39
|
+
const diffDays = Math.floor(diffMs / 86400000)
|
|
40
|
+
|
|
41
|
+
if (diffMins < 1) return 'Just now'
|
|
42
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
43
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
44
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
45
|
+
|
|
46
|
+
return date.toLocaleDateString(undefined, {
|
|
47
|
+
month: 'short',
|
|
48
|
+
day: 'numeric',
|
|
49
|
+
hour: 'numeric',
|
|
50
|
+
minute: '2-digit',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatFullDate(isoString: string): string {
|
|
55
|
+
const date = new Date(isoString)
|
|
56
|
+
return date.toLocaleString(undefined, {
|
|
57
|
+
month: 'short',
|
|
58
|
+
day: 'numeric',
|
|
59
|
+
year: 'numeric',
|
|
60
|
+
hour: 'numeric',
|
|
61
|
+
minute: '2-digit',
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getContentPreview(content: string, maxLength = 40): string {
|
|
66
|
+
const firstNonEmptyLine = content.split('\n').find((line) => line.trim()) ?? ''
|
|
67
|
+
const cleaned = firstNonEmptyLine.replace(/^[#/*\-\s]+/, '').trim()
|
|
68
|
+
if (cleaned.length <= maxLength) return cleaned
|
|
69
|
+
return cleaned.slice(0, maxLength - 3) + '...'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countScopeSnapshots(scope: SnapshotScope): number {
|
|
73
|
+
let count = 0
|
|
74
|
+
for (const cat of scope.categories) {
|
|
75
|
+
for (const item of cat.items) {
|
|
76
|
+
count += item.snapshots.length
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return count
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function countCategorySnapshots(category: SnapshotCategory): number {
|
|
83
|
+
let count = 0
|
|
84
|
+
for (const item of category.items) {
|
|
85
|
+
count += item.snapshots.length
|
|
86
|
+
}
|
|
87
|
+
return count
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Category icon mapping
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const CATEGORY_ICON_COLORS: Record<string, string> = {
|
|
95
|
+
verifier: 'text-teal-400',
|
|
96
|
+
simulator: 'text-purple-400',
|
|
97
|
+
console: 'text-blue-400',
|
|
98
|
+
skills: 'text-yellow-400',
|
|
99
|
+
hooks: 'text-green-400',
|
|
100
|
+
agents: 'text-violet-400',
|
|
101
|
+
'project-memory': 'text-indigo-400',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Search filtering
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function filterScopes(scopes: SnapshotScope[], query: string): SnapshotScope[] {
|
|
109
|
+
if (!query.trim()) return scopes
|
|
110
|
+
|
|
111
|
+
const lowerQuery = query.toLowerCase()
|
|
112
|
+
|
|
113
|
+
return scopes
|
|
114
|
+
.map((scope) => {
|
|
115
|
+
if (scope.name.toLowerCase().includes(lowerQuery)) return scope
|
|
116
|
+
|
|
117
|
+
const filteredCategories = scope.categories
|
|
118
|
+
.map((cat) => {
|
|
119
|
+
if (cat.name.toLowerCase().includes(lowerQuery)) return cat
|
|
120
|
+
|
|
121
|
+
const filteredItems = cat.items
|
|
122
|
+
.map((item) => {
|
|
123
|
+
if (item.name.toLowerCase().includes(lowerQuery)) return item
|
|
124
|
+
|
|
125
|
+
const filteredSnapshots = item.snapshots.filter((snap) => {
|
|
126
|
+
const label = snap.label || getContentPreview(snap.content)
|
|
127
|
+
return label.toLowerCase().includes(lowerQuery)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (filteredSnapshots.length > 0) {
|
|
131
|
+
return { ...item, snapshots: filteredSnapshots }
|
|
132
|
+
}
|
|
133
|
+
return null
|
|
134
|
+
})
|
|
135
|
+
.filter((item): item is SnapshotItem => item !== null)
|
|
136
|
+
|
|
137
|
+
if (filteredItems.length > 0) {
|
|
138
|
+
return { ...cat, items: filteredItems }
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
})
|
|
142
|
+
.filter((cat): cat is SnapshotCategory => cat !== null)
|
|
143
|
+
|
|
144
|
+
if (filteredCategories.length > 0) {
|
|
145
|
+
return { ...scope, categories: filteredCategories }
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
})
|
|
149
|
+
.filter((scope): scope is SnapshotScope => scope !== null)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function highlightMatch(text: string, query: string) {
|
|
153
|
+
if (!query) return text
|
|
154
|
+
const lowerText = text.toLowerCase()
|
|
155
|
+
const lowerQuery = query.toLowerCase()
|
|
156
|
+
const index = lowerText.indexOf(lowerQuery)
|
|
157
|
+
if (index === -1) return text
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
{text.slice(0, index)}
|
|
161
|
+
<span className="bg-yellow-500/30 text-yellow-200">{text.slice(index, index + query.length)}</span>
|
|
162
|
+
{text.slice(index + query.length)}
|
|
163
|
+
</>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Tree node components
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface SnapshotEntryRowProps {
|
|
172
|
+
entry: SnapshotEntry
|
|
173
|
+
depth: number
|
|
174
|
+
scopeId: string
|
|
175
|
+
categoryId: string
|
|
176
|
+
itemId: string
|
|
177
|
+
onDelete: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => void
|
|
178
|
+
deletingSnapshotId: string | null
|
|
179
|
+
searchQuery: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function SnapshotEntryRow({
|
|
183
|
+
entry,
|
|
184
|
+
depth,
|
|
185
|
+
scopeId,
|
|
186
|
+
categoryId,
|
|
187
|
+
itemId,
|
|
188
|
+
onDelete,
|
|
189
|
+
deletingSnapshotId,
|
|
190
|
+
searchQuery,
|
|
191
|
+
}: SnapshotEntryRowProps) {
|
|
192
|
+
const displayName = entry.label || getContentPreview(entry.content) || 'Snapshot'
|
|
193
|
+
const isDeleting = deletingSnapshotId === entry.id
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div
|
|
197
|
+
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded-md group transition-colors hover:bg-[#313244]/50 text-[#a6adc8]"
|
|
198
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
199
|
+
>
|
|
200
|
+
<Clock className="w-3 h-3 shrink-0 text-[#6c7086]" />
|
|
201
|
+
<span className="text-xs flex-1 truncate">
|
|
202
|
+
{searchQuery ? highlightMatch(displayName, searchQuery) : displayName}
|
|
203
|
+
</span>
|
|
204
|
+
<span className="text-[10px] text-[#6c7086] shrink-0" title={formatFullDate(entry.savedAt)}>
|
|
205
|
+
{formatRelativeTime(entry.savedAt)}
|
|
206
|
+
</span>
|
|
207
|
+
<IconButton
|
|
208
|
+
icon={<Trash2 className="w-3.5 h-3.5" />}
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e?.stopPropagation()
|
|
211
|
+
onDelete(scopeId, categoryId, itemId, entry.id)
|
|
212
|
+
}}
|
|
213
|
+
size="sm"
|
|
214
|
+
color="red"
|
|
215
|
+
className={cn('transition-opacity', isDeleting ? 'opacity-50' : 'opacity-0 group-hover:opacity-100')}
|
|
216
|
+
disabled={isDeleting}
|
|
217
|
+
tooltip={{ title: 'Delete', description: 'Delete this snapshot' }}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface ExpandableNodeProps {
|
|
224
|
+
id: string
|
|
225
|
+
path: string
|
|
226
|
+
label: string
|
|
227
|
+
icon?: React.ReactNode
|
|
228
|
+
snapshotCount: number
|
|
229
|
+
depth: number
|
|
230
|
+
isExpanded: boolean
|
|
231
|
+
onToggle: (path: string) => void
|
|
232
|
+
searchQuery: string
|
|
233
|
+
children: React.ReactNode
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function ExpandableNode({
|
|
237
|
+
path,
|
|
238
|
+
label,
|
|
239
|
+
icon,
|
|
240
|
+
snapshotCount,
|
|
241
|
+
depth,
|
|
242
|
+
isExpanded,
|
|
243
|
+
onToggle,
|
|
244
|
+
searchQuery,
|
|
245
|
+
children,
|
|
246
|
+
}: ExpandableNodeProps) {
|
|
247
|
+
return (
|
|
248
|
+
<div>
|
|
249
|
+
<button
|
|
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-[#a6adc8] hover:bg-[#313244]/50 hover:text-[#cdd6f4]"
|
|
252
|
+
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
253
|
+
>
|
|
254
|
+
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
|
255
|
+
{isExpanded ? (
|
|
256
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
257
|
+
) : (
|
|
258
|
+
<ChevronRight className="w-3.5 h-3.5" />
|
|
259
|
+
)}
|
|
260
|
+
</span>
|
|
261
|
+
|
|
262
|
+
{icon && <span className="shrink-0">{icon}</span>}
|
|
263
|
+
|
|
264
|
+
<span className="truncate flex-1 text-left">
|
|
265
|
+
{searchQuery ? highlightMatch(label, searchQuery) : label}
|
|
266
|
+
</span>
|
|
267
|
+
|
|
268
|
+
{snapshotCount > 0 && (
|
|
269
|
+
<span className="text-xs text-[#6c7086] bg-[#313244] px-1.5 py-0.5 rounded shrink-0">
|
|
270
|
+
{snapshotCount}
|
|
271
|
+
</span>
|
|
272
|
+
)}
|
|
273
|
+
</button>
|
|
274
|
+
|
|
275
|
+
{isExpanded && children}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Main SnapshotTree component
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
export interface SnapshotTreeProps {
|
|
285
|
+
scopes: SnapshotScope[]
|
|
286
|
+
searchQuery: string
|
|
287
|
+
onSearchChange: (query: string) => void
|
|
288
|
+
expandedPaths: Set<string>
|
|
289
|
+
onToggleExpand: (path: string) => void
|
|
290
|
+
onExpandAll: () => void
|
|
291
|
+
onCollapseAll: () => void
|
|
292
|
+
allExpanded: boolean
|
|
293
|
+
allExpandablePaths: string[]
|
|
294
|
+
onDeleteSnapshot: (scopeId: string, categoryId: string, itemId: string, snapshotId: string) => void
|
|
295
|
+
deletingSnapshotId: string | null
|
|
296
|
+
className?: string
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function SnapshotTree({
|
|
300
|
+
scopes,
|
|
301
|
+
searchQuery,
|
|
302
|
+
onSearchChange,
|
|
303
|
+
expandedPaths,
|
|
304
|
+
onToggleExpand,
|
|
305
|
+
onExpandAll,
|
|
306
|
+
onCollapseAll,
|
|
307
|
+
allExpanded,
|
|
308
|
+
allExpandablePaths,
|
|
309
|
+
onDeleteSnapshot,
|
|
310
|
+
deletingSnapshotId,
|
|
311
|
+
className,
|
|
312
|
+
}: SnapshotTreeProps) {
|
|
313
|
+
const filteredScopes = useMemo(() => filterScopes(scopes, searchQuery), [scopes, searchQuery])
|
|
314
|
+
|
|
315
|
+
const totalCount = useMemo(() => {
|
|
316
|
+
let count = 0
|
|
317
|
+
for (const scope of scopes) {
|
|
318
|
+
count += countScopeSnapshots(scope)
|
|
319
|
+
}
|
|
320
|
+
return count
|
|
321
|
+
}, [scopes])
|
|
322
|
+
|
|
323
|
+
if (totalCount === 0) {
|
|
324
|
+
return (
|
|
325
|
+
<div className={cn('text-sm text-[#6c7086] py-8 text-center', className)}>
|
|
326
|
+
No snapshots saved yet.
|
|
327
|
+
<br />
|
|
328
|
+
<span className="text-xs">Use the camera button in editors to save snapshots.</span>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
335
|
+
{/* Search and controls */}
|
|
336
|
+
<div className="flex items-center gap-2">
|
|
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-[#6c7086] pointer-events-none" />
|
|
339
|
+
<Input
|
|
340
|
+
placeholder="Search snapshots..."
|
|
341
|
+
value={searchQuery}
|
|
342
|
+
onChange={onSearchChange}
|
|
343
|
+
variant="outline"
|
|
344
|
+
size="sm"
|
|
345
|
+
autoComplete="off"
|
|
346
|
+
autoCorrect="off"
|
|
347
|
+
autoCapitalize="off"
|
|
348
|
+
spellCheck={false}
|
|
349
|
+
className="pl-8 pr-7"
|
|
350
|
+
/>
|
|
351
|
+
{searchQuery && (
|
|
352
|
+
<IconButton
|
|
353
|
+
icon={<X className="w-3.5 h-3.5" />}
|
|
354
|
+
onClick={() => onSearchChange('')}
|
|
355
|
+
color="neutral"
|
|
356
|
+
size="xss"
|
|
357
|
+
className="absolute right-2 top-1/2 -translate-y-1/2"
|
|
358
|
+
tooltip={{ title: 'Clear', description: 'Clear search' }}
|
|
359
|
+
/>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{allExpandablePaths.length > 0 && (
|
|
364
|
+
<IconButton
|
|
365
|
+
icon={allExpanded ? <ChevronsDownUp className="w-3.5 h-3.5" /> : <ChevronsUpDown className="w-3.5 h-3.5" />}
|
|
366
|
+
onClick={allExpanded ? onCollapseAll : onExpandAll}
|
|
367
|
+
size="sm"
|
|
368
|
+
color="neutral"
|
|
369
|
+
tooltip={{
|
|
370
|
+
title: allExpanded ? 'Collapse All' : 'Expand All',
|
|
371
|
+
description: allExpanded ? 'Collapse all sections' : 'Expand all sections',
|
|
372
|
+
}}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{/* Tree */}
|
|
378
|
+
<div className="bg-[#181825] border border-[#313244] rounded-lg p-2 min-h-[200px] max-h-[60vh] overflow-y-auto">
|
|
379
|
+
{filteredScopes.length === 0 && searchQuery ? (
|
|
380
|
+
<p className="text-xs text-[#6c7086] text-center py-4">
|
|
381
|
+
No snapshots match “{searchQuery}”
|
|
382
|
+
</p>
|
|
383
|
+
) : (
|
|
384
|
+
filteredScopes.map((scope) => (
|
|
385
|
+
<ExpandableNode
|
|
386
|
+
key={scope.id}
|
|
387
|
+
id={scope.id}
|
|
388
|
+
path={scope.id}
|
|
389
|
+
label={scope.name}
|
|
390
|
+
snapshotCount={countScopeSnapshots(scope)}
|
|
391
|
+
depth={0}
|
|
392
|
+
isExpanded={expandedPaths.has(scope.id)}
|
|
393
|
+
onToggle={onToggleExpand}
|
|
394
|
+
searchQuery={searchQuery}
|
|
395
|
+
>
|
|
396
|
+
{scope.categories.map((category) => (
|
|
397
|
+
<ExpandableNode
|
|
398
|
+
key={category.id}
|
|
399
|
+
id={category.id}
|
|
400
|
+
path={`${scope.id}/${category.id}`}
|
|
401
|
+
label={category.name}
|
|
402
|
+
icon={
|
|
403
|
+
category.icon ? (
|
|
404
|
+
<History className={cn('w-3.5 h-3.5', CATEGORY_ICON_COLORS[category.icon] || 'text-[#6c7086]')} />
|
|
405
|
+
) : undefined
|
|
406
|
+
}
|
|
407
|
+
snapshotCount={countCategorySnapshots(category)}
|
|
408
|
+
depth={1}
|
|
409
|
+
isExpanded={expandedPaths.has(`${scope.id}/${category.id}`)}
|
|
410
|
+
onToggle={onToggleExpand}
|
|
411
|
+
searchQuery={searchQuery}
|
|
412
|
+
>
|
|
413
|
+
{category.items.map((item) => (
|
|
414
|
+
<ExpandableNode
|
|
415
|
+
key={item.id}
|
|
416
|
+
id={item.id}
|
|
417
|
+
path={`${scope.id}/${category.id}/${item.id}`}
|
|
418
|
+
label={item.name}
|
|
419
|
+
icon={<History className="w-3.5 h-3.5 text-[#6c7086]" />}
|
|
420
|
+
snapshotCount={item.snapshots.length}
|
|
421
|
+
depth={2}
|
|
422
|
+
isExpanded={expandedPaths.has(`${scope.id}/${category.id}/${item.id}`)}
|
|
423
|
+
onToggle={onToggleExpand}
|
|
424
|
+
searchQuery={searchQuery}
|
|
425
|
+
>
|
|
426
|
+
{[...item.snapshots]
|
|
427
|
+
.sort((a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime())
|
|
428
|
+
.map((entry) => (
|
|
429
|
+
<SnapshotEntryRow
|
|
430
|
+
key={entry.id}
|
|
431
|
+
entry={entry}
|
|
432
|
+
depth={3}
|
|
433
|
+
scopeId={scope.id}
|
|
434
|
+
categoryId={category.id}
|
|
435
|
+
itemId={item.id}
|
|
436
|
+
onDelete={onDeleteSnapshot}
|
|
437
|
+
deletingSnapshotId={deletingSnapshotId}
|
|
438
|
+
searchQuery={searchQuery}
|
|
439
|
+
/>
|
|
440
|
+
))}
|
|
441
|
+
</ExpandableNode>
|
|
442
|
+
))}
|
|
443
|
+
</ExpandableNode>
|
|
444
|
+
))}
|
|
445
|
+
</ExpandableNode>
|
|
446
|
+
))
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
)
|
|
451
|
+
}
|