@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.
- package/.cta.json +15 -0
- package/DEPLOYMENT.md +154 -0
- package/Dockerfile +51 -0
- package/README.md +159 -0
- package/docker-compose.prod.yml +38 -0
- package/docker-compose.yml +15 -0
- package/drizzle/0000_slim_makkari.sql +61 -0
- package/drizzle/meta/0000_snapshot.json +452 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +60 -0
- package/public/favicon.ico +0 -0
- package/public/logo-argus.svg +8 -0
- package/public/logo-variants/logo-argus-a.svg +9 -0
- package/public/logo-variants/logo-argus-modern.svg +11 -0
- package/public/logo-variants/logo-argus-peacock.svg +8 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/backfill-kind.ts +148 -0
- package/src/api-plugin.ts +169 -0
- package/src/components/image/ImageCompare.tsx +188 -0
- package/src/components/story/StoryFlatList.tsx +67 -0
- package/src/components/story/StoryGroupedTree.tsx +273 -0
- package/src/components/story/StoryTree.tsx +185 -0
- package/src/components/ui/Drawer.tsx +110 -0
- package/src/components/ui/SearchInput.tsx +95 -0
- package/src/components/ui/StatusBadge.tsx +59 -0
- package/src/components/ui/ViewModeToggle.tsx +39 -0
- package/src/db/index.ts +27 -0
- package/src/db/schema.ts +151 -0
- package/src/hooks/useDebounce.ts +23 -0
- package/src/hooks/useStoryTree.ts +205 -0
- package/src/lib/utils.ts +55 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +177 -0
- package/src/router.tsx +17 -0
- package/src/routes/__root.tsx +174 -0
- package/src/routes/branches/$name.tsx +171 -0
- package/src/routes/branches/index.tsx +104 -0
- package/src/routes/index.tsx +178 -0
- package/src/routes/tests/$id.tsx +417 -0
- package/src/routes/tests/index.tsx +128 -0
- package/src/routes/upload.tsx +108 -0
- package/src/styles.css +213 -0
- package/tsconfig.json +28 -0
- 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
|
+
}
|