v3
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { cn } from "@/utils/cn";
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
|
||||
export function JsonErrorHighlighter({
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
onBlur,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
error: { line?: number; column?: number; message: string } | null;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollInfo, setScrollInfo] = useState({
|
||||
firstVisible: 0,
|
||||
lastVisible: 20,
|
||||
scrollTop: 0,
|
||||
lineHeight: 24,
|
||||
clientHeight: 250,
|
||||
});
|
||||
|
||||
const lines = value.split("\n");
|
||||
const errorLineIdx = (error?.line ?? 1) - 1;
|
||||
|
||||
// Calculate visible lines on scroll or resize
|
||||
const recalcVisibleLines = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight) || 24;
|
||||
const scrollTop = textarea.scrollTop;
|
||||
const clientHeight = textarea.clientHeight;
|
||||
const firstVisible = Math.floor(scrollTop / lineHeight);
|
||||
const lastVisible = Math.min(
|
||||
lines.length - 1,
|
||||
Math.ceil((scrollTop + clientHeight) / lineHeight),
|
||||
);
|
||||
setScrollInfo({
|
||||
firstVisible,
|
||||
lastVisible,
|
||||
scrollTop,
|
||||
lineHeight,
|
||||
clientHeight,
|
||||
});
|
||||
}, [lines.length]);
|
||||
|
||||
useEffect(() => {
|
||||
recalcVisibleLines();
|
||||
// Sync overlay height with textarea
|
||||
const handleResize = () => recalcVisibleLines();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [value, recalcVisibleLines]);
|
||||
|
||||
// Attach scroll handler
|
||||
const handleScroll = () => {
|
||||
recalcVisibleLines();
|
||||
if (textareaRef.current && preRef.current) {
|
||||
preRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
// Only render visible lines in <pre>
|
||||
const visibleLines = lines.slice(
|
||||
scrollInfo.firstVisible,
|
||||
scrollInfo.lastVisible + 1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full h-full relative font-mono text-foreground text-sm min-h-[250px] overflow-hidden focus:border-none focus-visible:border-none focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{/* Highlight overlay */}
|
||||
{error?.line && (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="absolute inset-0 pointer-events-none select-none text-transparent whitespace-pre-wrap break-words focus-visible:outline-none shadow-none border-none rounded-md"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "1.5",
|
||||
margin: 0,
|
||||
padding: "8px 12px",
|
||||
paddingLeft: "0",
|
||||
boxSizing: "border-box",
|
||||
minHeight: "250px",
|
||||
transform: `translateY(-${scrollInfo.scrollTop}px)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ height: scrollInfo.firstVisible * 1.5 + "em" }} />
|
||||
{visibleLines.map((line, idx) => {
|
||||
const globalIdx = idx + scrollInfo.firstVisible;
|
||||
if (globalIdx === errorLineIdx) {
|
||||
return (
|
||||
<div
|
||||
key={globalIdx}
|
||||
className="bg-red-500/20"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div key={globalIdx}>{line}</div>;
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
{/* Line numbers overlay */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
className="absolute left-0 top-0 bottom-0 pointer-events-none select-none text-muted-foreground/60 text-xs border-r border-border/50 bg-muted/20 rounded-l-md h-fit"
|
||||
style={{
|
||||
width: "3rem",
|
||||
padding: "11px 9px",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "0.75em",
|
||||
lineHeight: "1.5",
|
||||
transform: `translateY(-${scrollInfo.scrollTop}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: scrollInfo.firstVisible * scrollInfo.lineHeight + "px",
|
||||
}}
|
||||
/>
|
||||
{visibleLines.map((_, idx) => {
|
||||
const globalIdx = idx + scrollInfo.firstVisible;
|
||||
return (
|
||||
<div
|
||||
key={globalIdx}
|
||||
className="pr-2"
|
||||
style={{
|
||||
height: "16px",
|
||||
marginTop: idx === 0 ? 0 : "5px",
|
||||
paddingTop: "2px",
|
||||
}}
|
||||
>
|
||||
{globalIdx + 1}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"absolute inset-0 resize-none bg-transparent border rounded-md text-black dark:text-white focus:overline-none focus:border-zinc-200 focus-visible:border-zinc-200 focus-visible:outline-none",
|
||||
error?.message ? "!border-destructive" : "border-zinc-200",
|
||||
)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onScroll={handleScroll}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "1.5",
|
||||
margin: 0,
|
||||
padding: "8px 12px 8px 4rem", // Add left padding to account for line numbers
|
||||
boxSizing: "border-box",
|
||||
minHeight: "250px",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
zIndex: 1,
|
||||
outline: "none",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Error message overlay */}
|
||||
{error?.message && (
|
||||
<div
|
||||
className="absolute left-0 right-0 bottom-0 px-3 py-1 text-xs text-white bg-red-500/90 z-10 pointer-events-none"
|
||||
style={{
|
||||
fontFamily: "inherit",
|
||||
fontSize: "0.85em",
|
||||
borderBottomLeftRadius: 6,
|
||||
borderBottomRightRadius: 6,
|
||||
}}
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
export default function LivePreviewFrame({
|
||||
sessionId,
|
||||
onScrapeComplete,
|
||||
children,
|
||||
}: {
|
||||
sessionId: string;
|
||||
children: React.ReactNode;
|
||||
onScrapeComplete?: () => void;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const initialPositionSetRef = useRef(false);
|
||||
const idleStartTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const idleMoveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>({ x: 980, y: 54 });
|
||||
const [targetPosition, setTargetPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
}>({ x: 980, y: 54 });
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
|
||||
// Function to start the random idle movement sequence
|
||||
const scheduleNextIdleMove = useCallback(() => {
|
||||
if (idleMoveTimerRef.current) {
|
||||
clearTimeout(idleMoveTimerRef.current);
|
||||
}
|
||||
const randomDelay = Math.random() * 500 + 500; // 500ms to 1000ms
|
||||
idleMoveTimerRef.current = setTimeout(() => {
|
||||
if (isIdle) {
|
||||
// Check if still idle
|
||||
const randomOffsetX = (Math.random() - 0.5) * 10; // -5 to +5 pixels
|
||||
const randomOffsetY = (Math.random() - 0.5) * 10;
|
||||
// Update target slightly - the main animation loop will handle the movement
|
||||
setTargetPosition((prevTarget) => ({
|
||||
x: prevTarget.x + randomOffsetX,
|
||||
y: prevTarget.y + randomOffsetY,
|
||||
}));
|
||||
scheduleNextIdleMove(); // Schedule the next one
|
||||
}
|
||||
}, randomDelay);
|
||||
}, [isIdle]);
|
||||
|
||||
// Effect to handle starting/stopping idle movement sequence
|
||||
useEffect(() => {
|
||||
if (isIdle) {
|
||||
scheduleNextIdleMove();
|
||||
} else {
|
||||
if (idleMoveTimerRef.current) {
|
||||
clearTimeout(idleMoveTimerRef.current);
|
||||
}
|
||||
}
|
||||
// Cleanup function for this effect
|
||||
return () => {
|
||||
if (idleMoveTimerRef.current) {
|
||||
clearTimeout(idleMoveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isIdle, scheduleNextIdleMove]);
|
||||
|
||||
// Main Animation effect (runs continuously)
|
||||
useEffect(() => {
|
||||
let animationFrameId: number | null = null;
|
||||
const step = () => {
|
||||
setCursorPosition((currentPos) => {
|
||||
const dx = targetPosition.x - currentPos.x;
|
||||
const dy = targetPosition.y - currentPos.y;
|
||||
const isClose = Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1;
|
||||
|
||||
if (isClose) {
|
||||
// Reached target
|
||||
if (!isIdle && !idleStartTimerRef.current) {
|
||||
// Only start the idle timer if not already idle and no timer is running
|
||||
idleStartTimerRef.current = setTimeout(() => {
|
||||
setIsIdle(true);
|
||||
idleStartTimerRef.current = null; // Clear ref after timer runs
|
||||
}, 5000);
|
||||
}
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
return targetPosition; // Snap to final position
|
||||
} else {
|
||||
// Moving towards target
|
||||
// If we were waiting to go idle, cancel it because we're moving again
|
||||
if (idleStartTimerRef.current) {
|
||||
clearTimeout(idleStartTimerRef.current);
|
||||
idleStartTimerRef.current = null;
|
||||
}
|
||||
// Ensure idle state is false if we are moving significantly
|
||||
if (isIdle) setIsIdle(false);
|
||||
|
||||
const nextX = currentPos.x + dx * 0.05; // Keep slow easing for now
|
||||
const nextY = currentPos.y + dy * 0.05;
|
||||
animationFrameId = requestAnimationFrame(step);
|
||||
return { x: nextX, y: nextY };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start animation frame loop
|
||||
animationFrameId = requestAnimationFrame(step);
|
||||
|
||||
// Cleanup function for main animation loop
|
||||
return () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
// Also clear idle start timer on unmount or if target changes causing effect re-run
|
||||
if (idleStartTimerRef.current) {
|
||||
clearTimeout(idleStartTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [targetPosition, isIdle]); // Re-run main loop logic if targetPosition changes
|
||||
|
||||
const cleanupConnection = () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
// Cancel animation frame (handled in effect cleanup, but good practice here too)
|
||||
// Clear timers
|
||||
if (idleStartTimerRef.current) clearTimeout(idleStartTimerRef.current);
|
||||
if (idleMoveTimerRef.current) clearTimeout(idleMoveTimerRef.current);
|
||||
// Reset state
|
||||
setCursorPosition({ x: 0, y: 0 });
|
||||
setTargetPosition({ x: 0, y: 0 });
|
||||
setIsIdle(false);
|
||||
initialPositionSetRef.current = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onScrapeComplete) {
|
||||
cleanupConnection();
|
||||
}
|
||||
}, [onScrapeComplete]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
setIsConnecting(true);
|
||||
// Clear any existing connection
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
// Create new WebSocket connection
|
||||
const wsUrl = `wss://api.firecrawl.dev/agent-livecast?userProvidedId=${sessionId}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
console.log("Connected - Streaming frames...");
|
||||
setIsConnecting(false);
|
||||
// Clear any pending reconnection attempts
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
// Try to handle as raw base64 first
|
||||
if (
|
||||
typeof event.data === "string" &&
|
||||
event.data.startsWith("data:image")
|
||||
) {
|
||||
setImageSrc(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not direct image data, try parsing as JSON
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.mouseCoordinates) {
|
||||
let { x, y } = data.mouseCoordinates;
|
||||
|
||||
// --- Interrupt Idle State ---
|
||||
if (idleStartTimerRef.current) {
|
||||
clearTimeout(idleStartTimerRef.current);
|
||||
idleStartTimerRef.current = null;
|
||||
}
|
||||
if (isIdle) {
|
||||
setIsIdle(false);
|
||||
// idleMoveTimerRef is cleared by the isIdle effect cleanup
|
||||
}
|
||||
// --- End Interrupt Idle State ---
|
||||
|
||||
if (imgRef.current && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const imageRect = imgRef.current.getBoundingClientRect();
|
||||
|
||||
// Calculate the scale factor between the original coordinates and our container
|
||||
const scaleX = imageRect.width / 1920;
|
||||
const scaleY =
|
||||
imageRect.height > 2000 ? 0 : imageRect.height / 1080;
|
||||
|
||||
if (x === 0 && y === 0) {
|
||||
x = 1800;
|
||||
y = 100;
|
||||
}
|
||||
|
||||
// Scale the coordinates to match our container size
|
||||
const scaledX = x * scaleX;
|
||||
const scaledY = y * scaleY;
|
||||
|
||||
setTargetPosition({ x: scaledX, y: scaledY });
|
||||
|
||||
if (!initialPositionSetRef.current) {
|
||||
setCursorPosition({ x: scaledX, y: scaledY });
|
||||
initialPositionSetRef.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.frame) {
|
||||
const img = "data:image/jpeg;base64," + data.frame;
|
||||
localStorage.setItem("browserImageData", img);
|
||||
setImageSrc(img);
|
||||
}
|
||||
} catch (e) {
|
||||
// Try to use raw data as fallback if JSON parsing fails
|
||||
if (typeof event.data === "string") {
|
||||
setImageSrc(event.data);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
console.log(`Disconnected (Code: ${event.code})`);
|
||||
wsRef.current = null;
|
||||
|
||||
// Attempt to reconnect after a delay for any unexpected closure
|
||||
if (event.code !== 1000) {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (sessionId) {
|
||||
connect();
|
||||
}
|
||||
}, 3000); // Wait 3 seconds before reconnecting
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (error) => {
|
||||
console.error("Connection error - Will attempt to reconnect");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create connection");
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [sessionId, isIdle]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only connect if we have a sessionId
|
||||
if (sessionId) {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cleanupConnection();
|
||||
};
|
||||
} else {
|
||||
// Clean up any existing connection
|
||||
cleanupConnection();
|
||||
console.log("Waiting for session ID...");
|
||||
|
||||
return () => {
|
||||
cleanupConnection();
|
||||
};
|
||||
}
|
||||
}, [sessionId, connect]); // Re-run effect when sessionId changes
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-full flex items-center justify-center"
|
||||
>
|
||||
{/* Cursor */}
|
||||
{cursorPosition && cursorPosition.x !== 0 && cursorPosition.y !== 0 && (
|
||||
<div
|
||||
className="absolute pointer-events-none transform-gpu"
|
||||
style={{
|
||||
left: `${cursorPosition.x}px`,
|
||||
top: `${cursorPosition.y}px`,
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
backgroundImage: `url("/images/agent-cursor.svg")`,
|
||||
backgroundSize: "contain",
|
||||
backgroundRepeat: "no-repeat",
|
||||
transform: "translate(-20%, -20%)",
|
||||
zIndex: 10,
|
||||
transition: isIdle ? "all 0.5s ease-out" : "all 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Children fallback */}
|
||||
{children && !imgRef.current?.src ? (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Preview image - Using regular img tag for dynamic WebSocket stream */}
|
||||
{imageSrc && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
id="live-frame"
|
||||
src={imageSrc}
|
||||
alt="Live preview"
|
||||
onLoad={() => {
|
||||
setImageLoaded(true);
|
||||
if (onScrapeComplete) onScrapeComplete();
|
||||
}}
|
||||
className={`w-auto h-auto max-w-full max-h-full object-contain transform-gpu ${
|
||||
!imageLoaded ? "opacity-0 scale-95" : "opacity-100 scale-100"
|
||||
} transition-all duration-300 ease-out`}
|
||||
style={{
|
||||
backgroundColor: "#f0f0f0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import WebBrowser from "./web-browser";
|
||||
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
type BrowserData = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
isScrapeComplete: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function MultipleWebBrowsers({
|
||||
browsers,
|
||||
offset,
|
||||
scaleFactor,
|
||||
autoRotate = true,
|
||||
rotationInterval = 5000,
|
||||
}: {
|
||||
browsers: BrowserData[];
|
||||
offset?: number;
|
||||
scaleFactor?: number;
|
||||
autoRotate?: boolean;
|
||||
rotationInterval?: number;
|
||||
}) {
|
||||
const CARD_OFFSET = offset || 10;
|
||||
const SCALE_FACTOR = scaleFactor || 0.06;
|
||||
const [activeBrowsers, setActiveBrowsers] = useState<BrowserData[]>(browsers);
|
||||
|
||||
const startRotation = useCallback(() => {
|
||||
interval = setInterval(() => {
|
||||
setActiveBrowsers((prevBrowsers: BrowserData[]) => {
|
||||
const newArray = [...prevBrowsers];
|
||||
newArray.unshift(newArray.pop()!); // move the last element to the front
|
||||
return newArray;
|
||||
});
|
||||
}, rotationInterval);
|
||||
}, [rotationInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRotate) {
|
||||
startRotation();
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRotate, startRotation]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[600px] w-full">
|
||||
{activeBrowsers.map((browser, index) => {
|
||||
return (
|
||||
<motion.div
|
||||
key={browser.id}
|
||||
className="absolute w-full rounded-xl overflow-hidden"
|
||||
style={{
|
||||
transformOrigin: "top center",
|
||||
}}
|
||||
animate={{
|
||||
top: index * -CARD_OFFSET,
|
||||
scale: 1 - index * SCALE_FACTOR,
|
||||
zIndex: activeBrowsers.length - index,
|
||||
}}
|
||||
>
|
||||
<WebBrowser
|
||||
sessionId={browser.sessionId}
|
||||
isScrapeComplete={browser.isScrapeComplete}
|
||||
>
|
||||
{browser.children}
|
||||
</WebBrowser>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import LivePreviewFrame from "./live-preview-frame";
|
||||
|
||||
export default function WebBrowser({
|
||||
url,
|
||||
sessionId,
|
||||
isScrapeComplete,
|
||||
children,
|
||||
}: {
|
||||
url?: string;
|
||||
sessionId: string;
|
||||
isScrapeComplete: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Add a slight delay before showing the browser to ensure smooth entrance
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
setIsLoading(false);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative w-full h-full flex items-center justify-center bg-transparent">
|
||||
<div
|
||||
className={`w-full h-full max-w-[95vw] max-h-[85vh] min-w-full sm:min-w-[700px] rounded-2xl shadow-lg border border-gray-100 bg-white overflow-hidden flex flex-col transform-gpu ${
|
||||
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
} transition-all duration-300 ease-out`}
|
||||
>
|
||||
{/* macOS-style top bar with loading indicator */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 bg-zinc-50 flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors duration-200 ${
|
||||
isLoading ? "bg-yellow-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors duration-200 ${
|
||||
isLoading ? "bg-yellow-500" : "bg-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors duration-200 ${
|
||||
isLoading ? "bg-yellow-500" : "bg-green-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Spinner */}
|
||||
<div className="browser-spinner animate-spin w-4 h-4 border-2 border-zinc-300 border-t-zinc-600 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area with fade transition */}
|
||||
<div
|
||||
className={`flex-1 overflow-hidden transition-opacity duration-300 ${
|
||||
isLoading ? "opacity-50" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<LivePreviewFrame
|
||||
sessionId={sessionId}
|
||||
onScrapeComplete={
|
||||
isScrapeComplete
|
||||
? () => {
|
||||
console.log("Scrape complete");
|
||||
setIsLoading(false);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</LivePreviewFrame>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user