@argus-vrt/web 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.
Files changed (50) hide show
  1. package/.cta.json +15 -0
  2. package/DEPLOYMENT.md +154 -0
  3. package/Dockerfile +51 -0
  4. package/README.md +159 -0
  5. package/docker-compose.prod.yml +38 -0
  6. package/docker-compose.yml +15 -0
  7. package/drizzle/0000_slim_makkari.sql +61 -0
  8. package/drizzle/meta/0000_snapshot.json +452 -0
  9. package/drizzle/meta/_journal.json +13 -0
  10. package/drizzle.config.ts +10 -0
  11. package/package.json +60 -0
  12. package/public/favicon.ico +0 -0
  13. package/public/logo-argus.svg +8 -0
  14. package/public/logo-variants/logo-argus-a.svg +9 -0
  15. package/public/logo-variants/logo-argus-modern.svg +11 -0
  16. package/public/logo-variants/logo-argus-peacock.svg +8 -0
  17. package/public/logo192.png +0 -0
  18. package/public/logo512.png +0 -0
  19. package/public/manifest.json +25 -0
  20. package/public/robots.txt +3 -0
  21. package/public/tanstack-circle-logo.png +0 -0
  22. package/public/tanstack-word-logo-white.svg +1 -0
  23. package/scripts/backfill-kind.ts +148 -0
  24. package/src/api-plugin.ts +169 -0
  25. package/src/components/image/ImageCompare.tsx +188 -0
  26. package/src/components/story/StoryFlatList.tsx +67 -0
  27. package/src/components/story/StoryGroupedTree.tsx +273 -0
  28. package/src/components/story/StoryTree.tsx +185 -0
  29. package/src/components/ui/Drawer.tsx +110 -0
  30. package/src/components/ui/SearchInput.tsx +95 -0
  31. package/src/components/ui/StatusBadge.tsx +59 -0
  32. package/src/components/ui/ViewModeToggle.tsx +39 -0
  33. package/src/db/index.ts +27 -0
  34. package/src/db/schema.ts +151 -0
  35. package/src/hooks/useDebounce.ts +23 -0
  36. package/src/hooks/useStoryTree.ts +205 -0
  37. package/src/lib/utils.ts +55 -0
  38. package/src/logo.svg +12 -0
  39. package/src/routeTree.gen.ts +177 -0
  40. package/src/router.tsx +17 -0
  41. package/src/routes/__root.tsx +174 -0
  42. package/src/routes/branches/$name.tsx +171 -0
  43. package/src/routes/branches/index.tsx +104 -0
  44. package/src/routes/index.tsx +178 -0
  45. package/src/routes/tests/$id.tsx +417 -0
  46. package/src/routes/tests/index.tsx +128 -0
  47. package/src/routes/upload.tsx +108 -0
  48. package/src/styles.css +213 -0
  49. package/tsconfig.json +28 -0
  50. package/vite.config.ts +30 -0
@@ -0,0 +1,273 @@
1
+ import { ChevronRight, ChevronDown, CheckCircle, Folder, FolderOpen } from 'lucide-react'
2
+ import { cn } from '../../lib/utils'
3
+ import type { Story, GroupedTreeNode, TreeNode } from '../../hooks/useStoryTree'
4
+
5
+ interface StoryGroupedTreeProps {
6
+ tree: GroupedTreeNode[]
7
+ selectedStoryId: string | null
8
+ onSelectStory: (storyId: string) => void
9
+ isExpanded: (key: string) => boolean
10
+ toggleComponent: (key: string) => void
11
+ expandAll: () => void
12
+ collapseAll: () => void
13
+ className?: string
14
+ }
15
+
16
+ export function StoryGroupedTree({
17
+ tree,
18
+ selectedStoryId,
19
+ onSelectStory,
20
+ isExpanded,
21
+ toggleComponent,
22
+ expandAll,
23
+ collapseAll,
24
+ className,
25
+ }: StoryGroupedTreeProps) {
26
+ if (tree.length === 0) {
27
+ return (
28
+ <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
29
+ No stories match your filters
30
+ </div>
31
+ )
32
+ }
33
+
34
+ const allExpanded = tree.every(
35
+ (dir) =>
36
+ isExpanded(`dir:${dir.directory}`) &&
37
+ dir.components.every((comp) => isExpanded(`dir:${dir.directory}/${comp.componentName}`))
38
+ )
39
+ const allCollapsed = tree.every(
40
+ (dir) =>
41
+ !isExpanded(`dir:${dir.directory}`) &&
42
+ dir.components.every((comp) => !isExpanded(`dir:${dir.directory}/${comp.componentName}`))
43
+ )
44
+
45
+ return (
46
+ <div className={className}>
47
+ {/* Expand/Collapse controls */}
48
+ <div className="px-4 py-2 flex items-center gap-2 text-xs border-b border-gray-200 dark:border-gray-700">
49
+ <button
50
+ onClick={expandAll}
51
+ disabled={allExpanded}
52
+ className={cn(
53
+ 'text-primary-600 dark:text-primary-400 hover:underline',
54
+ allExpanded && 'opacity-50 cursor-not-allowed'
55
+ )}
56
+ >
57
+ Expand all
58
+ </button>
59
+ <span className="text-gray-300 dark:text-gray-600">|</span>
60
+ <button
61
+ onClick={collapseAll}
62
+ disabled={allCollapsed}
63
+ className={cn(
64
+ 'text-primary-600 dark:text-primary-400 hover:underline',
65
+ allCollapsed && 'opacity-50 cursor-not-allowed'
66
+ )}
67
+ >
68
+ Collapse all
69
+ </button>
70
+ </div>
71
+
72
+ {/* Directory nodes */}
73
+ <ul className="divide-y divide-gray-200 dark:divide-gray-700">
74
+ {tree.map((dir) => (
75
+ <DirectoryNode
76
+ key={dir.directory}
77
+ node={dir}
78
+ selectedStoryId={selectedStoryId}
79
+ onSelectStory={onSelectStory}
80
+ isExpanded={isExpanded}
81
+ toggleComponent={toggleComponent}
82
+ />
83
+ ))}
84
+ </ul>
85
+ </div>
86
+ )
87
+ }
88
+
89
+ interface DirectoryNodeProps {
90
+ node: GroupedTreeNode
91
+ selectedStoryId: string | null
92
+ onSelectStory: (storyId: string) => void
93
+ isExpanded: (key: string) => boolean
94
+ toggleComponent: (key: string) => void
95
+ }
96
+
97
+ function DirectoryNode({
98
+ node,
99
+ selectedStoryId,
100
+ onSelectStory,
101
+ isExpanded,
102
+ toggleComponent,
103
+ }: DirectoryNodeProps) {
104
+ const dirKey = `dir:${node.directory}`
105
+ const expanded = isExpanded(dirKey)
106
+ const ChevronIcon = expanded ? ChevronDown : ChevronRight
107
+ const FolderIcon = expanded ? FolderOpen : Folder
108
+
109
+ const totalStories = node.components.reduce((sum, c) => sum + c.stories.length, 0)
110
+ const passedCount = node.components.reduce(
111
+ (sum, c) => sum + c.stories.filter((s) => !s.hasDiff && !s.isNew).length,
112
+ 0
113
+ )
114
+ const allPassed = passedCount === totalStories
115
+
116
+ return (
117
+ <li>
118
+ {/* Directory header */}
119
+ <button
120
+ onClick={() => toggleComponent(dirKey)}
121
+ className={cn(
122
+ 'w-full px-4 py-2 flex items-center gap-2 text-left',
123
+ 'hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors',
124
+ 'bg-gray-50/50 dark:bg-gray-800/50'
125
+ )}
126
+ >
127
+ <ChevronIcon className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" />
128
+ <FolderIcon className="w-4 h-4 text-primary-500 dark:text-primary-400 flex-shrink-0" />
129
+ <span className="font-semibold text-sm text-gray-900 dark:text-white truncate flex-1">
130
+ {node.directory}
131
+ </span>
132
+ <span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
133
+ ({totalStories})
134
+ </span>
135
+ {allPassed && (
136
+ <CheckCircle className="w-4 h-4 text-success-500 flex-shrink-0" />
137
+ )}
138
+ {node.hasChanges && !allPassed && (
139
+ <span className="w-2 h-2 rounded-full bg-warning-500 flex-shrink-0" />
140
+ )}
141
+ {node.hasNew && !node.hasChanges && (
142
+ <span className="w-2 h-2 rounded-full bg-info-500 flex-shrink-0" />
143
+ )}
144
+ </button>
145
+
146
+ {/* Component children */}
147
+ <div className={cn('tree-content', expanded && 'expanded')}>
148
+ <div>
149
+ <ul>
150
+ {node.components.map((comp) => (
151
+ <ComponentNode
152
+ key={comp.componentName}
153
+ node={comp}
154
+ directory={node.directory}
155
+ selectedStoryId={selectedStoryId}
156
+ onSelectStory={onSelectStory}
157
+ isExpanded={isExpanded(`dir:${node.directory}/${comp.componentName}`)}
158
+ onToggle={() => toggleComponent(`dir:${node.directory}/${comp.componentName}`)}
159
+ />
160
+ ))}
161
+ </ul>
162
+ </div>
163
+ </div>
164
+ </li>
165
+ )
166
+ }
167
+
168
+ interface ComponentNodeProps {
169
+ node: TreeNode
170
+ directory: string
171
+ selectedStoryId: string | null
172
+ onSelectStory: (storyId: string) => void
173
+ isExpanded: boolean
174
+ onToggle: () => void
175
+ }
176
+
177
+ function ComponentNode({
178
+ node,
179
+ selectedStoryId,
180
+ onSelectStory,
181
+ isExpanded,
182
+ onToggle,
183
+ }: ComponentNodeProps) {
184
+ const ChevronIcon = isExpanded ? ChevronDown : ChevronRight
185
+ const passedCount = node.stories.filter((s) => !s.hasDiff && !s.isNew).length
186
+ const allPassed = passedCount === node.stories.length
187
+
188
+ return (
189
+ <li>
190
+ {/* Component header */}
191
+ <button
192
+ onClick={onToggle}
193
+ className={cn(
194
+ 'w-full pl-8 pr-4 py-2 flex items-center gap-2 text-left',
195
+ 'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'
196
+ )}
197
+ >
198
+ <ChevronIcon className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" />
199
+ <span className="font-medium text-sm text-gray-900 dark:text-white truncate flex-1">
200
+ {node.componentName}
201
+ </span>
202
+ <span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
203
+ ({node.stories.length})
204
+ </span>
205
+ {allPassed && (
206
+ <CheckCircle className="w-4 h-4 text-success-500 flex-shrink-0" />
207
+ )}
208
+ {node.hasChanges && !allPassed && (
209
+ <span className="w-2 h-2 rounded-full bg-warning-500 flex-shrink-0" />
210
+ )}
211
+ {node.hasNew && !node.hasChanges && (
212
+ <span className="w-2 h-2 rounded-full bg-info-500 flex-shrink-0" />
213
+ )}
214
+ </button>
215
+
216
+ {/* Story children */}
217
+ <div className={cn('tree-content', isExpanded && 'expanded')}>
218
+ <div>
219
+ <ul className="bg-gray-50/50 dark:bg-gray-800/50">
220
+ {node.stories.map((story) => (
221
+ <StoryItem
222
+ key={story.id}
223
+ story={story}
224
+ isSelected={selectedStoryId === story.id}
225
+ onSelect={() => onSelectStory(story.id)}
226
+ />
227
+ ))}
228
+ </ul>
229
+ </div>
230
+ </div>
231
+ </li>
232
+ )
233
+ }
234
+
235
+ interface StoryItemProps {
236
+ story: Story
237
+ isSelected: boolean
238
+ onSelect: () => void
239
+ }
240
+
241
+ function StoryItem({ story, isSelected, onSelect }: StoryItemProps) {
242
+ return (
243
+ <li
244
+ onClick={onSelect}
245
+ className={cn(
246
+ 'pl-14 pr-4 py-2 cursor-pointer transition-colors',
247
+ 'hover:bg-gray-100 dark:hover:bg-gray-700',
248
+ isSelected && 'bg-primary-50 dark:bg-primary-900/30'
249
+ )}
250
+ >
251
+ <div className="flex items-center justify-between gap-2">
252
+ <span className="text-sm text-gray-700 dark:text-gray-300 truncate">
253
+ {story.storyName}
254
+ </span>
255
+ <div className="flex items-center gap-1 flex-shrink-0">
256
+ {story.isNew && (
257
+ <span className="px-1.5 py-0.5 bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-200 text-xs rounded">
258
+ New
259
+ </span>
260
+ )}
261
+ {story.hasDiff && (
262
+ <span className="px-1.5 py-0.5 bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-200 text-xs rounded">
263
+ {story.pixelDiff.toFixed(1)}%
264
+ </span>
265
+ )}
266
+ {!story.hasDiff && !story.isNew && (
267
+ <CheckCircle className="w-3.5 h-3.5 text-success-500" />
268
+ )}
269
+ </div>
270
+ </div>
271
+ </li>
272
+ )
273
+ }
@@ -0,0 +1,185 @@
1
+ import { ChevronRight, ChevronDown, CheckCircle } from 'lucide-react'
2
+ import { cn } from '../../lib/utils'
3
+ import type { Story, TreeNode } from '../../hooks/useStoryTree'
4
+
5
+ interface StoryTreeProps {
6
+ tree: TreeNode[]
7
+ selectedStoryId: string | null
8
+ onSelectStory: (storyId: string) => void
9
+ isExpanded: (componentName: string) => boolean
10
+ toggleComponent: (componentName: string) => void
11
+ expandAll: () => void
12
+ collapseAll: () => void
13
+ className?: string
14
+ }
15
+
16
+ export function StoryTree({
17
+ tree,
18
+ selectedStoryId,
19
+ onSelectStory,
20
+ isExpanded,
21
+ toggleComponent,
22
+ expandAll,
23
+ collapseAll,
24
+ className,
25
+ }: StoryTreeProps) {
26
+ if (tree.length === 0) {
27
+ return (
28
+ <div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
29
+ No stories match your filters
30
+ </div>
31
+ )
32
+ }
33
+
34
+ const allExpanded = tree.every((node) => isExpanded(node.componentName))
35
+ const allCollapsed = tree.every((node) => !isExpanded(node.componentName))
36
+
37
+ return (
38
+ <div className={className}>
39
+ {/* Expand/Collapse controls */}
40
+ <div className="px-4 py-2 flex items-center gap-2 text-xs border-b border-gray-200 dark:border-gray-700">
41
+ <button
42
+ onClick={expandAll}
43
+ disabled={allExpanded}
44
+ className={cn(
45
+ 'text-primary-600 dark:text-primary-400 hover:underline',
46
+ allExpanded && 'opacity-50 cursor-not-allowed'
47
+ )}
48
+ >
49
+ Expand all
50
+ </button>
51
+ <span className="text-gray-300 dark:text-gray-600">|</span>
52
+ <button
53
+ onClick={collapseAll}
54
+ disabled={allCollapsed}
55
+ className={cn(
56
+ 'text-primary-600 dark:text-primary-400 hover:underline',
57
+ allCollapsed && 'opacity-50 cursor-not-allowed'
58
+ )}
59
+ >
60
+ Collapse all
61
+ </button>
62
+ </div>
63
+
64
+ {/* Tree nodes */}
65
+ <ul className="divide-y divide-gray-200 dark:divide-gray-700">
66
+ {tree.map((node) => (
67
+ <TreeNodeItem
68
+ key={node.componentName}
69
+ node={node}
70
+ selectedStoryId={selectedStoryId}
71
+ onSelectStory={onSelectStory}
72
+ isExpanded={isExpanded(node.componentName)}
73
+ onToggle={() => toggleComponent(node.componentName)}
74
+ />
75
+ ))}
76
+ </ul>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ interface TreeNodeItemProps {
82
+ node: TreeNode
83
+ selectedStoryId: string | null
84
+ onSelectStory: (storyId: string) => void
85
+ isExpanded: boolean
86
+ onToggle: () => void
87
+ }
88
+
89
+ function TreeNodeItem({
90
+ node,
91
+ selectedStoryId,
92
+ onSelectStory,
93
+ isExpanded,
94
+ onToggle,
95
+ }: TreeNodeItemProps) {
96
+ const ChevronIcon = isExpanded ? ChevronDown : ChevronRight
97
+ const passedCount = node.stories.filter((s) => !s.hasDiff && !s.isNew).length
98
+ const allPassed = passedCount === node.stories.length
99
+
100
+ return (
101
+ <li>
102
+ {/* Component header */}
103
+ <button
104
+ onClick={onToggle}
105
+ className={cn(
106
+ 'w-full px-4 py-2 flex items-center gap-2 text-left',
107
+ 'hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors'
108
+ )}
109
+ >
110
+ <ChevronIcon className="w-4 h-4 text-gray-400 dark:text-gray-500 flex-shrink-0" />
111
+ <span className="font-medium text-sm text-gray-900 dark:text-white truncate flex-1">
112
+ {node.componentName}
113
+ </span>
114
+ <span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
115
+ ({node.stories.length})
116
+ </span>
117
+ {allPassed && (
118
+ <CheckCircle className="w-4 h-4 text-success-500 flex-shrink-0" />
119
+ )}
120
+ {node.hasChanges && !allPassed && (
121
+ <span className="w-2 h-2 rounded-full bg-warning-500 flex-shrink-0" />
122
+ )}
123
+ {node.hasNew && !node.hasChanges && (
124
+ <span className="w-2 h-2 rounded-full bg-info-500 flex-shrink-0" />
125
+ )}
126
+ </button>
127
+
128
+ {/* Story children with animation */}
129
+ <div className={cn('tree-content', isExpanded && 'expanded')}>
130
+ <div>
131
+ <ul className="bg-gray-50/50 dark:bg-gray-800/50">
132
+ {node.stories.map((story) => (
133
+ <StoryItem
134
+ key={story.id}
135
+ story={story}
136
+ isSelected={selectedStoryId === story.id}
137
+ onSelect={() => onSelectStory(story.id)}
138
+ />
139
+ ))}
140
+ </ul>
141
+ </div>
142
+ </div>
143
+ </li>
144
+ )
145
+ }
146
+
147
+ interface StoryItemProps {
148
+ story: Story
149
+ isSelected: boolean
150
+ onSelect: () => void
151
+ }
152
+
153
+ function StoryItem({ story, isSelected, onSelect }: StoryItemProps) {
154
+ return (
155
+ <li
156
+ onClick={onSelect}
157
+ className={cn(
158
+ 'pl-10 pr-4 py-2 cursor-pointer transition-colors',
159
+ 'hover:bg-gray-100 dark:hover:bg-gray-700',
160
+ isSelected && 'bg-primary-50 dark:bg-primary-900/30'
161
+ )}
162
+ >
163
+ <div className="flex items-center justify-between gap-2">
164
+ <span className="text-sm text-gray-700 dark:text-gray-300 truncate">
165
+ {story.storyName}
166
+ </span>
167
+ <div className="flex items-center gap-1 flex-shrink-0">
168
+ {story.isNew && (
169
+ <span className="px-1.5 py-0.5 bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-200 text-xs rounded">
170
+ New
171
+ </span>
172
+ )}
173
+ {story.hasDiff && (
174
+ <span className="px-1.5 py-0.5 bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-200 text-xs rounded">
175
+ {story.pixelDiff.toFixed(1)}%
176
+ </span>
177
+ )}
178
+ {!story.hasDiff && !story.isNew && (
179
+ <CheckCircle className="w-3.5 h-3.5 text-success-500" />
180
+ )}
181
+ </div>
182
+ </div>
183
+ </li>
184
+ )
185
+ }
@@ -0,0 +1,110 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ interface DrawerProps {
6
+ open: boolean
7
+ onClose: () => void
8
+ title?: string
9
+ children: React.ReactNode
10
+ className?: string
11
+ }
12
+
13
+ export function Drawer({ open, onClose, title, children, className }: DrawerProps) {
14
+ const [isAnimating, setIsAnimating] = useState(false)
15
+ const [shouldRender, setShouldRender] = useState(false)
16
+ const drawerRef = useRef<HTMLDivElement>(null)
17
+
18
+ useEffect(() => {
19
+ if (open) {
20
+ setShouldRender(true)
21
+ // Trigger animation on next frame
22
+ requestAnimationFrame(() => {
23
+ setIsAnimating(true)
24
+ })
25
+ // Lock body scroll
26
+ document.body.style.overflow = 'hidden'
27
+ } else if (shouldRender) {
28
+ setIsAnimating(false)
29
+ // Wait for animation to complete before unmounting
30
+ const timer = setTimeout(() => {
31
+ setShouldRender(false)
32
+ document.body.style.overflow = ''
33
+ }, 300)
34
+ return () => clearTimeout(timer)
35
+ }
36
+
37
+ return () => {
38
+ document.body.style.overflow = ''
39
+ }
40
+ }, [open, shouldRender])
41
+
42
+ // Handle escape key
43
+ useEffect(() => {
44
+ const handleEscape = (e: KeyboardEvent) => {
45
+ if (e.key === 'Escape' && open) {
46
+ onClose()
47
+ }
48
+ }
49
+ document.addEventListener('keydown', handleEscape)
50
+ return () => document.removeEventListener('keydown', handleEscape)
51
+ }, [open, onClose])
52
+
53
+ // Handle click outside
54
+ const handleBackdropClick = (e: React.MouseEvent) => {
55
+ if (e.target === e.currentTarget) {
56
+ onClose()
57
+ }
58
+ }
59
+
60
+ if (!shouldRender) return null
61
+
62
+ return (
63
+ <div
64
+ className={cn(
65
+ 'fixed inset-0 z-50',
66
+ isAnimating ? 'backdrop-enter' : 'backdrop-exit'
67
+ )}
68
+ onClick={handleBackdropClick}
69
+ >
70
+ {/* Backdrop */}
71
+ <div
72
+ className={cn(
73
+ 'absolute inset-0 bg-black/50 transition-opacity',
74
+ isAnimating ? 'opacity-100' : 'opacity-0'
75
+ )}
76
+ />
77
+
78
+ {/* Drawer panel */}
79
+ <div
80
+ ref={drawerRef}
81
+ className={cn(
82
+ 'absolute left-0 top-0 h-full w-[85%] max-w-sm bg-white dark:bg-gray-800 shadow-xl',
83
+ isAnimating ? 'drawer-enter' : 'drawer-exit',
84
+ className
85
+ )}
86
+ >
87
+ {/* Header */}
88
+ {title && (
89
+ <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
90
+ <h2 className="text-lg font-medium text-gray-900 dark:text-white">
91
+ {title}
92
+ </h2>
93
+ <button
94
+ onClick={onClose}
95
+ className="p-2 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
96
+ aria-label="Close drawer"
97
+ >
98
+ <X className="w-5 h-5" />
99
+ </button>
100
+ </div>
101
+ )}
102
+
103
+ {/* Content */}
104
+ <div className="h-full overflow-y-auto custom-scrollbar">
105
+ {children}
106
+ </div>
107
+ </div>
108
+ </div>
109
+ )
110
+ }
@@ -0,0 +1,95 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { Search, X } from 'lucide-react'
3
+ import { cn, getModifierKey, hasModifierKey } from '../../lib/utils'
4
+
5
+ interface SearchInputProps {
6
+ value: string
7
+ onChange: (value: string) => void
8
+ placeholder?: string
9
+ className?: string
10
+ resultCount?: number
11
+ showResultCount?: boolean
12
+ }
13
+
14
+ export function SearchInput({
15
+ value,
16
+ onChange,
17
+ placeholder = 'Search stories...',
18
+ className,
19
+ resultCount,
20
+ showResultCount = false,
21
+ }: SearchInputProps) {
22
+ const inputRef = useRef<HTMLInputElement>(null)
23
+ const modifierKey = getModifierKey()
24
+
25
+ // Handle keyboard shortcuts
26
+ useEffect(() => {
27
+ const handleKeyDown = (e: KeyboardEvent) => {
28
+ // Cmd/Ctrl + K to focus
29
+ if (hasModifierKey(e) && e.key === 'k') {
30
+ e.preventDefault()
31
+ inputRef.current?.focus()
32
+ inputRef.current?.select()
33
+ }
34
+
35
+ // Escape to clear and blur
36
+ if (e.key === 'Escape' && document.activeElement === inputRef.current) {
37
+ e.preventDefault()
38
+ onChange('')
39
+ inputRef.current?.blur()
40
+ }
41
+ }
42
+
43
+ document.addEventListener('keydown', handleKeyDown)
44
+ return () => document.removeEventListener('keydown', handleKeyDown)
45
+ }, [onChange])
46
+
47
+ const handleClear = () => {
48
+ onChange('')
49
+ inputRef.current?.focus()
50
+ }
51
+
52
+ return (
53
+ <div className={cn('relative', className)}>
54
+ <div className="relative">
55
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
56
+ <input
57
+ ref={inputRef}
58
+ type="text"
59
+ value={value}
60
+ onChange={(e) => onChange(e.target.value)}
61
+ placeholder={placeholder}
62
+ className={cn(
63
+ 'w-full pl-10 pr-20 py-2 text-sm',
64
+ 'bg-gray-100 dark:bg-gray-700',
65
+ 'border border-transparent',
66
+ 'focus:border-primary-500 focus:bg-white dark:focus:bg-gray-800',
67
+ 'rounded-lg outline-none transition-colors',
68
+ 'text-gray-900 dark:text-white',
69
+ 'placeholder:text-gray-500 dark:placeholder:text-gray-400'
70
+ )}
71
+ />
72
+ <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
73
+ {value ? (
74
+ <button
75
+ onClick={handleClear}
76
+ className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
77
+ aria-label="Clear search"
78
+ >
79
+ <X className="w-4 h-4 text-gray-400 dark:text-gray-500" />
80
+ </button>
81
+ ) : (
82
+ <kbd className="hidden sm:inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-600 rounded">
83
+ {modifierKey}+K
84
+ </kbd>
85
+ )}
86
+ </div>
87
+ </div>
88
+ {showResultCount && value.trim() && resultCount !== undefined && (
89
+ <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
90
+ {resultCount} {resultCount === 1 ? 'result' : 'results'}
91
+ </div>
92
+ )}
93
+ </div>
94
+ )
95
+ }