304 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|