gitea
This commit is contained in:
303
frontend/components/visual-selector.tsx
Normal file
303
frontend/components/visual-selector.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user