@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill script to populate the `kind` field from storyId
|
|
3
|
+
*
|
|
4
|
+
* Run with: npx tsx scripts/backfill-kind.ts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
8
|
+
import postgres from 'postgres'
|
|
9
|
+
import { eq, isNull } from 'drizzle-orm'
|
|
10
|
+
import * as schema from '../src/db/schema'
|
|
11
|
+
|
|
12
|
+
const connectionString = process.env.DATABASE_URL
|
|
13
|
+
|
|
14
|
+
if (!connectionString) {
|
|
15
|
+
console.error('DATABASE_URL environment variable is required')
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const client = postgres(connectionString)
|
|
20
|
+
const db = drizzle(client, { schema })
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reconstruct kind (full path) from storyId
|
|
24
|
+
* e.g., "ui-button--primary" -> "UI/Button"
|
|
25
|
+
* e.g., "components-forms-textinput--default" -> "Components/Forms/TextInput"
|
|
26
|
+
*/
|
|
27
|
+
function extractKindFromStoryId(storyId: string): string {
|
|
28
|
+
const parts = storyId.split('--')
|
|
29
|
+
if (parts.length === 0) return storyId
|
|
30
|
+
|
|
31
|
+
const titlePart = parts[0]
|
|
32
|
+
const segments = titlePart.split('-')
|
|
33
|
+
|
|
34
|
+
if (segments.length === 1) {
|
|
35
|
+
// Single segment - just capitalize
|
|
36
|
+
return capitalize(segments[0])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Multiple segments - treat all but last as directory path, last as component
|
|
40
|
+
// e.g., ["ui", "button"] -> "UI/Button"
|
|
41
|
+
// e.g., ["components", "forms", "text", "input"] is ambiguous...
|
|
42
|
+
|
|
43
|
+
// Heuristic: common directory names are short (ui, components, screens, etc.)
|
|
44
|
+
// We'll assume segments that look like common dirs stay as dirs
|
|
45
|
+
const commonDirs = ['ui', 'components', 'screens', 'forms', 'layout', 'navigation', 'common', 'shared', 'core', 'atoms', 'molecules', 'organisms', 'templates', 'pages']
|
|
46
|
+
|
|
47
|
+
const pathParts: string[] = []
|
|
48
|
+
let i = 0
|
|
49
|
+
|
|
50
|
+
// Collect directory parts
|
|
51
|
+
while (i < segments.length - 1) {
|
|
52
|
+
if (commonDirs.includes(segments[i].toLowerCase())) {
|
|
53
|
+
pathParts.push(capitalize(segments[i]))
|
|
54
|
+
i++
|
|
55
|
+
} else {
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If no common dirs found, assume first segment is directory
|
|
61
|
+
if (pathParts.length === 0 && segments.length > 1) {
|
|
62
|
+
pathParts.push(capitalize(segments[0]))
|
|
63
|
+
i = 1
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Remaining segments form the component name (PascalCase)
|
|
67
|
+
const componentSegments = segments.slice(i)
|
|
68
|
+
const componentName = componentSegments.map(capitalize).join('')
|
|
69
|
+
|
|
70
|
+
if (pathParts.length > 0) {
|
|
71
|
+
return [...pathParts, componentName].join('/')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return componentName
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function capitalize(str: string): string {
|
|
78
|
+
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function backfill() {
|
|
82
|
+
console.log('Starting backfill of kind field...\n')
|
|
83
|
+
|
|
84
|
+
// Get all story results without kind
|
|
85
|
+
const stories = await db
|
|
86
|
+
.select()
|
|
87
|
+
.from(schema.storyResults)
|
|
88
|
+
.where(isNull(schema.storyResults.kind))
|
|
89
|
+
|
|
90
|
+
console.log(`Found ${stories.length} stories without kind field\n`)
|
|
91
|
+
|
|
92
|
+
if (stories.length === 0) {
|
|
93
|
+
console.log('Nothing to backfill!')
|
|
94
|
+
await client.end()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Group by derived kind to show preview
|
|
99
|
+
const preview = new Map<string, string[]>()
|
|
100
|
+
for (const story of stories) {
|
|
101
|
+
const kind = extractKindFromStoryId(story.storyId)
|
|
102
|
+
if (!preview.has(kind)) {
|
|
103
|
+
preview.set(kind, [])
|
|
104
|
+
}
|
|
105
|
+
preview.get(kind)!.push(story.storyId)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log('Preview of kind mappings:')
|
|
109
|
+
console.log('='.repeat(60))
|
|
110
|
+
for (const [kind, storyIds] of Array.from(preview.entries()).slice(0, 20)) {
|
|
111
|
+
console.log(` ${kind}`)
|
|
112
|
+
for (const id of storyIds.slice(0, 3)) {
|
|
113
|
+
console.log(` <- ${id}`)
|
|
114
|
+
}
|
|
115
|
+
if (storyIds.length > 3) {
|
|
116
|
+
console.log(` ... and ${storyIds.length - 3} more`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (preview.size > 20) {
|
|
120
|
+
console.log(` ... and ${preview.size - 20} more directories`)
|
|
121
|
+
}
|
|
122
|
+
console.log('='.repeat(60))
|
|
123
|
+
console.log()
|
|
124
|
+
|
|
125
|
+
// Update each story
|
|
126
|
+
let updated = 0
|
|
127
|
+
for (const story of stories) {
|
|
128
|
+
const kind = extractKindFromStoryId(story.storyId)
|
|
129
|
+
|
|
130
|
+
await db
|
|
131
|
+
.update(schema.storyResults)
|
|
132
|
+
.set({ kind })
|
|
133
|
+
.where(eq(schema.storyResults.id, story.id))
|
|
134
|
+
|
|
135
|
+
updated++
|
|
136
|
+
if (updated % 100 === 0) {
|
|
137
|
+
console.log(`Updated ${updated}/${stories.length}...`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log(`\nBackfill complete! Updated ${updated} stories.`)
|
|
142
|
+
await client.end()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
backfill().catch((err) => {
|
|
146
|
+
console.error('Backfill failed:', err)
|
|
147
|
+
process.exit(1)
|
|
148
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { Plugin } from 'vite'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/postgres-js'
|
|
3
|
+
import postgres from 'postgres'
|
|
4
|
+
import { createReadStream, existsSync, statSync } from 'fs'
|
|
5
|
+
import { extname } from 'path'
|
|
6
|
+
import * as schema from './db/schema'
|
|
7
|
+
|
|
8
|
+
const MIME_TYPES: Record<string, string> = {
|
|
9
|
+
'.png': 'image/png',
|
|
10
|
+
'.jpg': 'image/jpeg',
|
|
11
|
+
'.jpeg': 'image/jpeg',
|
|
12
|
+
'.gif': 'image/gif',
|
|
13
|
+
'.webp': 'image/webp',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Vite plugin to add API endpoints for the CLI
|
|
18
|
+
*/
|
|
19
|
+
export function apiPlugin(): Plugin {
|
|
20
|
+
let db: ReturnType<typeof drizzle> | null = null
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
name: 'api-plugin',
|
|
24
|
+
configureServer(server) {
|
|
25
|
+
// Initialize database connection
|
|
26
|
+
const connectionString = process.env.DATABASE_URL
|
|
27
|
+
if (connectionString) {
|
|
28
|
+
const client = postgres(connectionString)
|
|
29
|
+
db = drizzle(client, { schema })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Add middleware for API routes
|
|
33
|
+
server.middlewares.use(async (req, res, next) => {
|
|
34
|
+
// Image serving endpoint
|
|
35
|
+
if (req.url?.startsWith('/api/images') && req.method === 'GET') {
|
|
36
|
+
const url = new URL(req.url, 'http://localhost')
|
|
37
|
+
const filePath = url.searchParams.get('path')
|
|
38
|
+
|
|
39
|
+
if (!filePath) {
|
|
40
|
+
res.statusCode = 400
|
|
41
|
+
res.end('Missing path parameter')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Security: only allow image files
|
|
46
|
+
const ext = extname(filePath).toLowerCase()
|
|
47
|
+
if (!MIME_TYPES[ext]) {
|
|
48
|
+
res.statusCode = 400
|
|
49
|
+
res.end('Invalid file type')
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if file exists
|
|
54
|
+
if (!existsSync(filePath)) {
|
|
55
|
+
res.statusCode = 404
|
|
56
|
+
res.end('File not found')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const stat = statSync(filePath)
|
|
62
|
+
res.setHeader('Content-Type', MIME_TYPES[ext])
|
|
63
|
+
res.setHeader('Content-Length', stat.size)
|
|
64
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000') // Cache for 1 year
|
|
65
|
+
createReadStream(filePath).pipe(res)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('Error serving image:', error)
|
|
68
|
+
res.statusCode = 500
|
|
69
|
+
res.end('Error serving image')
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (req.url === '/api/upload' && req.method === 'POST') {
|
|
75
|
+
if (!db) {
|
|
76
|
+
res.statusCode = 500
|
|
77
|
+
res.setHeader('Content-Type', 'application/json')
|
|
78
|
+
res.end(JSON.stringify({ error: 'Database not configured' }))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let body = ''
|
|
83
|
+
req.on('data', (chunk) => {
|
|
84
|
+
body += chunk.toString()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
req.on('end', async () => {
|
|
88
|
+
try {
|
|
89
|
+
const data = JSON.parse(body)
|
|
90
|
+
const {
|
|
91
|
+
branch,
|
|
92
|
+
baseBranch = 'main',
|
|
93
|
+
commitHash,
|
|
94
|
+
commitMessage,
|
|
95
|
+
stories = [],
|
|
96
|
+
} = data
|
|
97
|
+
|
|
98
|
+
// Validate required fields
|
|
99
|
+
if (!branch || !commitHash) {
|
|
100
|
+
res.statusCode = 400
|
|
101
|
+
res.setHeader('Content-Type', 'application/json')
|
|
102
|
+
res.end(JSON.stringify({ error: 'Missing required fields: branch, commitHash' }))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Calculate counts
|
|
107
|
+
const totalStories = stories.length
|
|
108
|
+
const changedCount = stories.filter((s: any) => s.hasDiff || s.isNew).length
|
|
109
|
+
const passedCount = stories.filter((s: any) => !s.hasDiff && !s.isNew).length
|
|
110
|
+
const failedCount = stories.filter((s: any) => s.hasDiff).length
|
|
111
|
+
|
|
112
|
+
// Create test record
|
|
113
|
+
const [test] = await db
|
|
114
|
+
.insert(schema.tests)
|
|
115
|
+
.values({
|
|
116
|
+
branch,
|
|
117
|
+
baseBranch,
|
|
118
|
+
commitHash,
|
|
119
|
+
commitMessage,
|
|
120
|
+
status: 'PENDING',
|
|
121
|
+
totalStories,
|
|
122
|
+
changedCount,
|
|
123
|
+
passedCount,
|
|
124
|
+
failedCount,
|
|
125
|
+
})
|
|
126
|
+
.returning()
|
|
127
|
+
|
|
128
|
+
// Create story results
|
|
129
|
+
if (stories.length > 0) {
|
|
130
|
+
await db.insert(schema.storyResults).values(
|
|
131
|
+
stories.map((story: any) => ({
|
|
132
|
+
testId: test.id,
|
|
133
|
+
storyId: story.storyId,
|
|
134
|
+
kind: story.kind || story.title || null, // Full path like "UI/Button"
|
|
135
|
+
componentName: story.componentName,
|
|
136
|
+
storyName: story.storyName,
|
|
137
|
+
baselineUrl: story.baselineUrl || null,
|
|
138
|
+
currentUrl: story.currentUrl,
|
|
139
|
+
diffUrl: story.diffUrl || null,
|
|
140
|
+
pixelDiff: story.pixelDiff,
|
|
141
|
+
ssimScore: story.ssimScore,
|
|
142
|
+
hasDiff: story.hasDiff,
|
|
143
|
+
isNew: story.isNew,
|
|
144
|
+
}))
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
res.statusCode = 200
|
|
149
|
+
res.setHeader('Content-Type', 'application/json')
|
|
150
|
+
res.end(JSON.stringify({
|
|
151
|
+
success: true,
|
|
152
|
+
testId: test.id,
|
|
153
|
+
url: `/tests/${test.id}`,
|
|
154
|
+
}))
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error('Upload error:', error)
|
|
157
|
+
res.statusCode = 500
|
|
158
|
+
res.setHeader('Content-Type', 'application/json')
|
|
159
|
+
res.end(JSON.stringify({ error: 'Failed to upload test results' }))
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
next()
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Eye, Layers } from 'lucide-react'
|
|
3
|
+
import { cn } from '../../lib/utils'
|
|
4
|
+
|
|
5
|
+
type ViewMode = 'side-by-side' | 'diff' | 'overlay' | 'current'
|
|
6
|
+
|
|
7
|
+
interface ImageCompareProps {
|
|
8
|
+
baseline: string | null
|
|
9
|
+
current: string
|
|
10
|
+
diff: string | null
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const placeholderSvg = (text: string) =>
|
|
15
|
+
`data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect fill="%23f3f4f6" width="200" height="200"/><text fill="%239ca3af" x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="14">${encodeURIComponent(text)}</text></svg>`
|
|
16
|
+
|
|
17
|
+
export function ImageCompare({ baseline, current, diff, className }: ImageCompareProps) {
|
|
18
|
+
const [view, setView] = useState<ViewMode>('side-by-side')
|
|
19
|
+
const [overlayOpacity, setOverlayOpacity] = useState(0.5)
|
|
20
|
+
|
|
21
|
+
const viewModes: { value: ViewMode; label: string; icon: typeof Eye; showIf?: boolean }[] = [
|
|
22
|
+
{ value: 'side-by-side', label: 'Side by Side', icon: Layers },
|
|
23
|
+
{ value: 'diff', label: 'Diff Only', icon: Eye, showIf: !!diff },
|
|
24
|
+
{ value: 'overlay', label: 'Overlay', icon: Layers, showIf: !!diff },
|
|
25
|
+
{ value: 'current', label: 'Current Only', icon: Eye },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn('flex flex-col overflow-hidden', className)}>
|
|
30
|
+
{/* View mode buttons */}
|
|
31
|
+
<div className="flex gap-2 mb-4 flex-wrap items-center flex-shrink-0">
|
|
32
|
+
{viewModes.map(
|
|
33
|
+
(mode) =>
|
|
34
|
+
(mode.showIf === undefined || mode.showIf) && (
|
|
35
|
+
<button
|
|
36
|
+
key={mode.value}
|
|
37
|
+
onClick={() => setView(mode.value)}
|
|
38
|
+
className={cn(
|
|
39
|
+
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
|
40
|
+
view === mode.value
|
|
41
|
+
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-200'
|
|
42
|
+
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
<mode.icon className="w-4 h-4 inline mr-1" />
|
|
46
|
+
<span className="hidden sm:inline">{mode.label}</span>
|
|
47
|
+
</button>
|
|
48
|
+
)
|
|
49
|
+
)}
|
|
50
|
+
|
|
51
|
+
{/* Overlay opacity slider */}
|
|
52
|
+
{view === 'overlay' && diff && (
|
|
53
|
+
<div className="flex items-center gap-2 ml-4 pl-4 border-l border-gray-300 dark:border-gray-600">
|
|
54
|
+
<span className="text-sm text-gray-500 dark:text-gray-400 hidden sm:inline">
|
|
55
|
+
Diff Opacity:
|
|
56
|
+
</span>
|
|
57
|
+
<input
|
|
58
|
+
type="range"
|
|
59
|
+
min="0"
|
|
60
|
+
max="1"
|
|
61
|
+
step="0.1"
|
|
62
|
+
value={overlayOpacity}
|
|
63
|
+
onChange={(e) => setOverlayOpacity(parseFloat(e.target.value))}
|
|
64
|
+
className="w-24"
|
|
65
|
+
/>
|
|
66
|
+
<span className="text-sm text-gray-500 dark:text-gray-400 w-8">
|
|
67
|
+
{Math.round(overlayOpacity * 100)}%
|
|
68
|
+
</span>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Image content area - fills remaining height */}
|
|
74
|
+
<div className="flex-1 min-h-0 overflow-hidden">
|
|
75
|
+
{/* Side by side view */}
|
|
76
|
+
{view === 'side-by-side' && (
|
|
77
|
+
<div className="h-full grid grid-cols-2 gap-4">
|
|
78
|
+
<div className="min-h-0 flex flex-col">
|
|
79
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
|
|
80
|
+
Baseline
|
|
81
|
+
</div>
|
|
82
|
+
<div className="flex-1 min-h-0 flex items-center justify-center">
|
|
83
|
+
{baseline ? (
|
|
84
|
+
<img
|
|
85
|
+
src={baseline}
|
|
86
|
+
alt="Baseline"
|
|
87
|
+
className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
88
|
+
onError={(e) => {
|
|
89
|
+
e.currentTarget.src = placeholderSvg('No baseline')
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
) : (
|
|
93
|
+
<div className="border border-gray-200 dark:border-gray-600 rounded-lg h-48 w-32 flex items-center justify-center text-gray-400 text-sm text-center px-2">
|
|
94
|
+
No baseline<br />(new story)
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="min-h-0 flex flex-col">
|
|
100
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
|
|
101
|
+
Current
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex-1 min-h-0 flex items-center justify-center">
|
|
104
|
+
<img
|
|
105
|
+
src={current}
|
|
106
|
+
alt="Current"
|
|
107
|
+
className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
108
|
+
onError={(e) => {
|
|
109
|
+
e.currentTarget.src = placeholderSvg('Image not found')
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
{/* Diff only view */}
|
|
118
|
+
{view === 'diff' && diff && (
|
|
119
|
+
<div className="h-full flex flex-col">
|
|
120
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
|
|
121
|
+
Difference Highlight
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex-1 min-h-0 flex items-center justify-center">
|
|
124
|
+
<img
|
|
125
|
+
src={diff}
|
|
126
|
+
alt="Diff"
|
|
127
|
+
className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
128
|
+
onError={(e) => {
|
|
129
|
+
e.currentTarget.src = placeholderSvg('Diff not available')
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* Overlay view */}
|
|
137
|
+
{view === 'overlay' && diff && (
|
|
138
|
+
<div className="h-full flex flex-col">
|
|
139
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
|
|
140
|
+
Current with Diff Overlay
|
|
141
|
+
<span className="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
|
142
|
+
(red/magenta areas show differences)
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex-1 min-h-0 flex items-center justify-center relative">
|
|
146
|
+
<img
|
|
147
|
+
src={current}
|
|
148
|
+
alt="Current"
|
|
149
|
+
className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
150
|
+
onError={(e) => {
|
|
151
|
+
e.currentTarget.src = placeholderSvg('Image not found')
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
<img
|
|
155
|
+
src={diff}
|
|
156
|
+
alt="Diff overlay"
|
|
157
|
+
className="absolute max-w-full max-h-full object-contain pointer-events-none rounded-lg"
|
|
158
|
+
style={{ opacity: overlayOpacity }}
|
|
159
|
+
onError={(e) => {
|
|
160
|
+
e.currentTarget.style.display = 'none'
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Current only view */}
|
|
168
|
+
{view === 'current' && (
|
|
169
|
+
<div className="h-full flex flex-col">
|
|
170
|
+
<div className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2 flex-shrink-0">
|
|
171
|
+
Current Screenshot
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex-1 min-h-0 flex items-center justify-center">
|
|
174
|
+
<img
|
|
175
|
+
src={current}
|
|
176
|
+
alt="Current"
|
|
177
|
+
className="max-w-full max-h-full object-contain rounded-lg border border-gray-200 dark:border-gray-600"
|
|
178
|
+
onError={(e) => {
|
|
179
|
+
e.currentTarget.src = placeholderSvg('Image not found')
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { CheckCircle } from 'lucide-react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import type { Story } from '../../hooks/useStoryTree'
|
|
4
|
+
|
|
5
|
+
interface StoryFlatListProps {
|
|
6
|
+
stories: Story[]
|
|
7
|
+
selectedStoryId: string | null
|
|
8
|
+
onSelectStory: (storyId: string) => void
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StoryFlatList({
|
|
13
|
+
stories,
|
|
14
|
+
selectedStoryId,
|
|
15
|
+
onSelectStory,
|
|
16
|
+
className,
|
|
17
|
+
}: StoryFlatListProps) {
|
|
18
|
+
if (stories.length === 0) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
|
21
|
+
No stories match your filters
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<ul className={cn('divide-y divide-gray-200 dark:divide-gray-700', className)}>
|
|
28
|
+
{stories.map((story) => (
|
|
29
|
+
<li
|
|
30
|
+
key={story.id}
|
|
31
|
+
onClick={() => onSelectStory(story.id)}
|
|
32
|
+
className={cn(
|
|
33
|
+
'px-4 py-3 cursor-pointer transition-colors',
|
|
34
|
+
'hover:bg-gray-50 dark:hover:bg-gray-700',
|
|
35
|
+
selectedStoryId === story.id && 'bg-primary-50 dark:bg-primary-900/30'
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<div className="flex items-center justify-between">
|
|
39
|
+
<div className="min-w-0 flex-1">
|
|
40
|
+
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
41
|
+
{story.componentName}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
|
44
|
+
{story.storyName}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-center gap-2 ml-2 flex-shrink-0">
|
|
48
|
+
{story.isNew && (
|
|
49
|
+
<span className="px-2 py-0.5 bg-info-100 text-info-700 dark:bg-info-900 dark:text-info-200 text-xs rounded-full">
|
|
50
|
+
New
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
{story.hasDiff && (
|
|
54
|
+
<span className="px-2 py-0.5 bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-200 text-xs rounded-full">
|
|
55
|
+
{story.pixelDiff.toFixed(1)}%
|
|
56
|
+
</span>
|
|
57
|
+
)}
|
|
58
|
+
{!story.hasDiff && !story.isNew && (
|
|
59
|
+
<CheckCircle className="w-4 h-4 text-success-500" />
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
)
|
|
67
|
+
}
|