This commit is contained in:
Developers Digest
2025-11-19 10:15:21 -05:00
320 changed files with 38446 additions and 7311 deletions
@@ -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>
);
}
+84
View File
@@ -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>
);
}