website-monitor/frontend/components/visual-selector.tsx

304 lines
12 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
interface VisualSelectorProps {
url: string
onSelect: (selector: string) => void
onClose: () => void
}
/**
* Generate an optimal CSS selector for an element
*/
function generateSelector(element: Element): string {
// Try ID first
if (element.id) {
return `#${element.id}`
}
// Try unique class combination
if (element.classList.length > 0) {
const classes = Array.from(element.classList)
const classSelector = '.' + classes.join('.')
if (document.querySelectorAll(classSelector).length === 1) {
return classSelector
}
}
// Build path from parent elements
const path: string[] = []
let current: Element | null = element
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector = `#${current.id}`
path.unshift(selector)
break
}
if (current.classList.length > 0) {
const significantClasses = Array.from(current.classList)
.filter(c => !c.includes('hover') && !c.includes('active') && !c.includes('focus'))
.slice(0, 2)
if (significantClasses.length > 0) {
selector += '.' + significantClasses.join('.')
}
}
// Add nth-child if needed for uniqueness
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter(
c => c.tagName === current!.tagName
)
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
}
path.unshift(selector)
current = current.parentElement
}
return path.join(' > ')
}
export function VisualSelector({ url, onSelect, onClose }: VisualSelectorProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedSelector, setSelectedSelector] = useState('')
const [testResult, setTestResult] = useState<{ count: number; success: boolean } | null>(null)
const [proxyHtml, setProxyHtml] = useState<string | null>(null)
// Fetch page content through proxy
useEffect(() => {
async function fetchProxyContent() {
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`)
if (!response.ok) {
throw new Error('Failed to load page')
}
const html = await response.text()
setProxyHtml(html)
setLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load page')
setLoading(false)
}
}
fetchProxyContent()
}, [url])
// Handle clicks within the iframe
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current
if (!iframe?.contentDocument) return
const doc = iframe.contentDocument
// Inject selection styles
const style = doc.createElement('style')
style.textContent = `
.visual-selector-hover {
outline: 2px solid #3b82f6 !important;
outline-offset: 2px;
cursor: crosshair !important;
}
.visual-selector-selected {
outline: 3px solid #22c55e !important;
outline-offset: 2px;
background-color: rgba(34, 197, 94, 0.1) !important;
}
`
doc.head.appendChild(style)
// Add event listeners
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target && target !== doc.body) {
target.classList.add('visual-selector-hover')
}
}
const handleMouseOut = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target) {
target.classList.remove('visual-selector-hover')
}
}
const handleClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const target = e.target as HTMLElement
if (!target || target === doc.body) return
// Remove previous selection
doc.querySelectorAll('.visual-selector-selected').forEach(el => {
el.classList.remove('visual-selector-selected')
})
// Add selection to current element
target.classList.add('visual-selector-selected')
// Generate and set selector
const selector = generateSelector(target)
setSelectedSelector(selector)
// Test the selector
const matches = doc.querySelectorAll(selector)
setTestResult({
count: matches.length,
success: matches.length === 1
})
}
doc.body.addEventListener('mouseover', handleMouseOver)
doc.body.addEventListener('mouseout', handleMouseOut)
doc.body.addEventListener('click', handleClick)
// Cleanup
return () => {
doc.body.removeEventListener('mouseover', handleMouseOver)
doc.body.removeEventListener('mouseout', handleMouseOut)
doc.body.removeEventListener('click', handleClick)
}
}, [])
const handleConfirm = () => {
if (selectedSelector) {
onSelect(selectedSelector)
}
}
const handleTestSelector = () => {
const iframe = iframeRef.current
if (!iframe?.contentDocument || !selectedSelector) return
try {
const matches = iframe.contentDocument.querySelectorAll(selectedSelector)
setTestResult({
count: matches.length,
success: matches.length === 1
})
// Highlight matches
iframe.contentDocument.querySelectorAll('.visual-selector-selected').forEach(el => {
el.classList.remove('visual-selector-selected')
})
matches.forEach(el => {
el.classList.add('visual-selector-selected')
})
} catch {
setTestResult({ count: 0, success: false })
}
}
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle>Visual Element Selector</CardTitle>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Click on an element to select it. The CSS selector will be generated automatically.
</p>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col gap-4">
{/* URL display */}
<div className="text-sm text-muted-foreground truncate">
Loading: {url}
</div>
{/* Iframe container */}
<div className="flex-1 relative border rounded-lg overflow-hidden bg-white">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Loading page...</p>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="text-center p-4">
<p className="text-destructive font-medium">Failed to load page</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-xs text-muted-foreground mt-2">
Note: Some sites may block embedding due to security policies.
</p>
</div>
</div>
)}
{proxyHtml && (
<iframe
ref={iframeRef}
srcDoc={proxyHtml}
className="w-full h-full"
sandbox="allow-same-origin"
onLoad={handleIframeLoad}
style={{ minHeight: '400px' }}
/>
)}
</div>
{/* Selector controls */}
<div className="flex-shrink-0 space-y-3">
<div className="flex gap-2">
<Input
value={selectedSelector}
onChange={(e) => setSelectedSelector(e.target.value)}
placeholder="CSS selector will appear here..."
className="flex-1 font-mono text-sm"
/>
<Button variant="outline" onClick={handleTestSelector} disabled={!selectedSelector}>
Test
</Button>
</div>
{testResult && (
<div className={`text-sm ${testResult.success ? 'text-green-600' : 'text-orange-600'}`}>
{testResult.success
? `✓ Selector matches exactly 1 element`
: `⚠ Selector matches ${testResult.count} elements (should be 1)`
}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedSelector}>
Use This Selector
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}