add in firecrawl search
This commit is contained in:
@@ -164,9 +164,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Install packages using provider method
|
// Install packages using provider method
|
||||||
const installResult = await providerInstance.installPackages(packagesToInstall);
|
const installResult = await providerInstance.installPackages(packagesToInstall);
|
||||||
|
|
||||||
// Get install output
|
// Get install output - ensure stdout/stderr are strings
|
||||||
const stdout = installResult.stdout;
|
const stdout = String(installResult.stdout || '');
|
||||||
const stderr = installResult.stderr;
|
const stderr = String(installResult.stderr || '');
|
||||||
|
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
const lines = stdout.split('\n').filter(line => line.trim());
|
const lines = stdout.split('\n').filter(line => line.trim());
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { query } = await req.json();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: 'Query is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Firecrawl search to get top 10 results with screenshots
|
||||||
|
const searchResponse = await fetch('https://api.firecrawl.dev/v1/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${process.env.FIRECRAWL_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
limit: 10,
|
||||||
|
scrapeOptions: {
|
||||||
|
formats: ['markdown', 'screenshot'],
|
||||||
|
onlyMainContent: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!searchResponse.ok) {
|
||||||
|
throw new Error('Search failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchData = await searchResponse.json();
|
||||||
|
|
||||||
|
// Format results with screenshots and markdown
|
||||||
|
const results = searchData.data?.map((result: any) => ({
|
||||||
|
url: result.url,
|
||||||
|
title: result.title || result.url,
|
||||||
|
description: result.description || '',
|
||||||
|
screenshot: result.screenshot || null,
|
||||||
|
markdown: result.markdown || '',
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to perform search' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-14
@@ -174,11 +174,12 @@ export default function AISandboxPage() {
|
|||||||
// Mark that we have an initial submission since we're loading with a URL
|
// Mark that we have an initial submission since we're loading with a URL
|
||||||
setHasInitialSubmission(true);
|
setHasInitialSubmission(true);
|
||||||
|
|
||||||
// Clear sessionStorage after reading
|
// Clear sessionStorage after reading
|
||||||
sessionStorage.removeItem('targetUrl');
|
sessionStorage.removeItem('targetUrl');
|
||||||
sessionStorage.removeItem('selectedStyle');
|
sessionStorage.removeItem('selectedStyle');
|
||||||
sessionStorage.removeItem('selectedModel');
|
sessionStorage.removeItem('selectedModel');
|
||||||
sessionStorage.removeItem('additionalInstructions');
|
sessionStorage.removeItem('additionalInstructions');
|
||||||
|
// Note: Don't clear siteMarkdown here, it will be cleared when used
|
||||||
|
|
||||||
// Set the values in the component state
|
// Set the values in the component state
|
||||||
setHomeUrlInput(storedUrl);
|
setHomeUrlInput(storedUrl);
|
||||||
@@ -2638,20 +2639,37 @@ Tip: I automatically detect and install npm packages from your code imports (lik
|
|||||||
|
|
||||||
// Screenshot is already being captured in parallel above
|
// Screenshot is already being captured in parallel above
|
||||||
|
|
||||||
const scrapeResponse = await fetch('/api/scrape-url-enhanced', {
|
let scrapeData;
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scrapeResponse.ok) {
|
// Check if we have pre-scraped markdown content from search results
|
||||||
throw new Error('Failed to scrape website');
|
const storedMarkdown = sessionStorage.getItem('siteMarkdown');
|
||||||
}
|
if (storedMarkdown) {
|
||||||
|
// Use the pre-scraped content
|
||||||
const scrapeData = await scrapeResponse.json();
|
scrapeData = {
|
||||||
|
success: true,
|
||||||
if (!scrapeData.success) {
|
content: storedMarkdown,
|
||||||
throw new Error(scrapeData.error || 'Failed to scrape website');
|
title: new URL(url).hostname,
|
||||||
|
source: 'search-result'
|
||||||
|
};
|
||||||
|
sessionStorage.removeItem('siteMarkdown'); // Clear after use
|
||||||
|
addChatMessage('Using cached content from search results...', 'system');
|
||||||
|
} else {
|
||||||
|
// Perform fresh scraping
|
||||||
|
const scrapeResponse = await fetch('/api/scrape-url-enhanced', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scrapeResponse.ok) {
|
||||||
|
throw new Error('Failed to scrape website');
|
||||||
|
}
|
||||||
|
|
||||||
|
scrapeData = await scrapeResponse.json();
|
||||||
|
|
||||||
|
if (!scrapeData.success) {
|
||||||
|
throw new Error(scrapeData.error || 'Failed to scrape website');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrlStatus(['Website scraped successfully!', 'Generating React app...']);
|
setUrlStatus(['Website scraped successfully!', 'Generating React app...']);
|
||||||
@@ -3580,6 +3598,7 @@ Focus on the key sections and content, making it clean and modern.`;
|
|||||||
onChange={setAiChatInput}
|
onChange={setAiChatInput}
|
||||||
onSubmit={sendChatMessage}
|
onSubmit={sendChatMessage}
|
||||||
placeholder="Describe what you want to build..."
|
placeholder="Describe what you want to build..."
|
||||||
|
showSearchFeatures={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+342
-17
@@ -28,11 +28,25 @@ import HeaderDropdownWrapper from "@/components/shared/header/Dropdown/Wrapper/W
|
|||||||
import GithubIcon from "@/components/shared/header/Github/_svg/GithubIcon";
|
import GithubIcon from "@/components/shared/header/Github/_svg/GithubIcon";
|
||||||
import ButtonUI from "@/components/ui/shadcn/button"
|
import ButtonUI from "@/components/ui/shadcn/button"
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
screenshot: string | null;
|
||||||
|
markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [url, setUrl] = useState<string>("");
|
const [url, setUrl] = useState<string>("");
|
||||||
const [selectedStyle, setSelectedStyle] = useState<string>("1");
|
const [selectedStyle, setSelectedStyle] = useState<string>("1");
|
||||||
const [selectedModel, setSelectedModel] = useState<string>(appConfig.ai.defaultModel);
|
const [selectedModel, setSelectedModel] = useState<string>(appConfig.ai.defaultModel);
|
||||||
const [isValidUrl, setIsValidUrl] = useState<boolean>(false);
|
const [isValidUrl, setIsValidUrl] = useState<boolean>(false);
|
||||||
|
const [showSearchTiles, setShowSearchTiles] = useState<boolean>(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||||
|
const [hasSearched, setHasSearched] = useState<boolean>(false);
|
||||||
|
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
|
||||||
|
const [showSelectMessage, setShowSelectMessage] = useState<boolean>(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Simple URL validation
|
// Simple URL validation
|
||||||
@@ -43,6 +57,12 @@ export default function HomePage() {
|
|||||||
return urlPattern.test(urlString.toLowerCase());
|
return urlPattern.test(urlString.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if input is a URL (contains a dot)
|
||||||
|
const isURL = (str: string): boolean => {
|
||||||
|
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/;
|
||||||
|
return urlPattern.test(str.trim());
|
||||||
|
};
|
||||||
|
|
||||||
const styles = [
|
const styles = [
|
||||||
{ id: "1", name: "Glassmorphism", description: "Frosted glass effect" },
|
{ id: "1", name: "Glassmorphism", description: "Frosted glass effect" },
|
||||||
{ id: "2", name: "Neumorphism", description: "Soft 3D shadows" },
|
{ id: "2", name: "Neumorphism", description: "Soft 3D shadows" },
|
||||||
@@ -59,20 +79,117 @@ export default function HomePage() {
|
|||||||
name: appConfig.ai.modelDisplayNames[model] || model,
|
name: appConfig.ai.modelDisplayNames[model] || model,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = async (selectedResult?: SearchResult) => {
|
||||||
if (!url.trim()) {
|
const inputValue = url.trim();
|
||||||
toast.error("Please enter a URL");
|
|
||||||
|
if (!inputValue) {
|
||||||
|
toast.error("Please enter a URL or search term");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the configuration in sessionStorage
|
// If it's a search result being selected, fade out and redirect
|
||||||
sessionStorage.setItem('targetUrl', url);
|
if (selectedResult) {
|
||||||
sessionStorage.setItem('selectedStyle', selectedStyle);
|
setIsFadingOut(true);
|
||||||
sessionStorage.setItem('selectedModel', selectedModel);
|
|
||||||
sessionStorage.setItem('autoStart', 'true'); // Set flag to auto-start generation
|
// Wait for fade animation
|
||||||
|
setTimeout(() => {
|
||||||
|
sessionStorage.setItem('targetUrl', selectedResult.url);
|
||||||
|
sessionStorage.setItem('selectedStyle', selectedStyle);
|
||||||
|
sessionStorage.setItem('selectedModel', selectedModel);
|
||||||
|
sessionStorage.setItem('autoStart', 'true');
|
||||||
|
if (selectedResult.markdown) {
|
||||||
|
sessionStorage.setItem('siteMarkdown', selectedResult.markdown);
|
||||||
|
}
|
||||||
|
router.push('/generation');
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Redirect to the generation interface
|
// If it's a URL, go straight to generation
|
||||||
router.push('/generation');
|
if (isURL(inputValue)) {
|
||||||
|
sessionStorage.setItem('targetUrl', inputValue);
|
||||||
|
sessionStorage.setItem('selectedStyle', selectedStyle);
|
||||||
|
sessionStorage.setItem('selectedModel', selectedModel);
|
||||||
|
sessionStorage.setItem('autoStart', 'true');
|
||||||
|
router.push('/generation');
|
||||||
|
} else {
|
||||||
|
// It's a search term, fade out if results exist, then search
|
||||||
|
if (hasSearched && searchResults.length > 0) {
|
||||||
|
setIsFadingOut(true);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsFadingOut(false);
|
||||||
|
setShowSelectMessage(true);
|
||||||
|
|
||||||
|
// Perform new search
|
||||||
|
await performSearch(inputValue);
|
||||||
|
setHasSearched(true);
|
||||||
|
setShowSearchTiles(true);
|
||||||
|
setShowSelectMessage(false);
|
||||||
|
|
||||||
|
// Smooth scroll to carousel
|
||||||
|
setTimeout(() => {
|
||||||
|
const carouselSection = document.querySelector('.carousel-section');
|
||||||
|
if (carouselSection) {
|
||||||
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
// First search, no fade needed
|
||||||
|
setShowSelectMessage(true);
|
||||||
|
setIsSearching(true);
|
||||||
|
setHasSearched(true);
|
||||||
|
setShowSearchTiles(true);
|
||||||
|
|
||||||
|
// Scroll to carousel area immediately
|
||||||
|
setTimeout(() => {
|
||||||
|
const carouselSection = document.querySelector('.carousel-section');
|
||||||
|
if (carouselSection) {
|
||||||
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
await performSearch(inputValue);
|
||||||
|
setShowSelectMessage(false);
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
// Smooth scroll to carousel
|
||||||
|
setTimeout(() => {
|
||||||
|
const carouselSection = document.querySelector('.carousel-section');
|
||||||
|
if (carouselSection) {
|
||||||
|
carouselSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform search when user types
|
||||||
|
const performSearch = async (searchQuery: string) => {
|
||||||
|
if (!searchQuery.trim() || isURL(searchQuery)) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: searchQuery }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSearchResults(data.results || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -155,33 +272,83 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
|
|
||||||
<div className="p-16 flex gap-12 items-center w-full relative bg-white rounded-20">
|
<div className="p-16 flex gap-12 items-center w-full relative bg-white rounded-20">
|
||||||
<Globe />
|
{isURL(url) ? (
|
||||||
|
// Scrape icon for URLs
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="opacity-40 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="14" height="14" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M7 10L9 12L13 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// Search icon for search terms
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="opacity-40 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M12.5 12.5L16.5 16.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent text-body-input text-accent-black placeholder:text-black-alpha-48 focus:outline-none focus:ring-0 focus:border-transparent"
|
className="flex-1 bg-transparent text-body-input text-accent-black placeholder:text-black-alpha-48 focus:outline-none focus:ring-0 focus:border-transparent"
|
||||||
placeholder="example.com"
|
placeholder="Enter URL or search term..."
|
||||||
type="text"
|
type="text"
|
||||||
value={url}
|
value={url}
|
||||||
|
disabled={isSearching}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setUrl(e.target.value);
|
const value = e.target.value;
|
||||||
setIsValidUrl(validateUrl(e.target.value));
|
setUrl(value);
|
||||||
|
setIsValidUrl(validateUrl(value));
|
||||||
|
// Reset search state when input changes
|
||||||
|
if (value.trim() === "") {
|
||||||
|
setShowSearchTiles(false);
|
||||||
|
setHasSearched(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter" && !isSearching) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (url.trim() && !isURL(url)) {
|
||||||
|
setShowSearchTiles(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setTimeout(() => setShowSearchTiles(false), 200);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
if (!isSearching) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
className={isSearching ? 'pointer-events-none' : ''}
|
||||||
>
|
>
|
||||||
<HeroInputSubmitButton dirty={url.length > 0} />
|
<HeroInputSubmitButton
|
||||||
|
dirty={url.length > 0}
|
||||||
|
buttonText={isURL(url) ? 'Scrape Site' : 'Search'}
|
||||||
|
disabled={isSearching}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Options Section - Only show when valid URL */}
|
{/* Options Section - Only show when valid URL */}
|
||||||
<div className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
<div className={`overflow-hidden transition-all duration-500 ease-in-out ${
|
||||||
isValidUrl ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
isValidUrl ? 'max-h-[200px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
@@ -255,7 +422,165 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Full-width oval carousel section */}
|
||||||
|
{showSearchTiles && hasSearched && (
|
||||||
|
<section className={`carousel-section relative w-full overflow-hidden mt-32 mb-32 transition-opacity duration-500 ${
|
||||||
|
isFadingOut ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-gray-50/50 to-white rounded-[50%] transform scale-x-150 -translate-y-24" />
|
||||||
|
|
||||||
|
{isSearching ? (
|
||||||
|
// Loading state with animated skeletons
|
||||||
|
<div className="relative h-[250px] overflow-hidden">
|
||||||
|
{/* Edge fade overlays */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to right, white 0%, white 20%, transparent 100%)'}} />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to left, white 0%, white 20%, transparent 100%)'}} />
|
||||||
|
|
||||||
|
<div className="flex gap-12 py-4 px-8">
|
||||||
|
{[0, 1, 2, 3, 4].map((index) => (
|
||||||
|
<div
|
||||||
|
key={`loading-${index}`}
|
||||||
|
className="flex-shrink-0 w-[400px] h-[240px] rounded-24 overflow-hidden border-2 border-gray-200/30 bg-white relative"
|
||||||
|
style={{
|
||||||
|
animation: `fadeIn 0.5s ease-out forwards`,
|
||||||
|
animationDelay: `${index * 100}ms`,
|
||||||
|
opacity: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 skeleton-shimmer">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-gray-100 via-gray-50 to-gray-100 skeleton-gradient" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fake browser UI */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-8 bg-gray-100 border-b border-gray-200/50 flex items-center px-3 gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gray-300" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-4 bg-gray-200 rounded-sm mx-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content skeleton */}
|
||||||
|
<div className="p-4 mt-8">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3" />
|
||||||
|
<div className="h-4 bg-gray-150 rounded w-full mb-2" />
|
||||||
|
<div className="h-4 bg-gray-150 rounded w-5/6 mb-2" />
|
||||||
|
<div className="h-4 bg-gray-150 rounded w-4/6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading text */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-full px-6 py-3 shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Searching for sites...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : searchResults.length > 0 ? (
|
||||||
|
// Actual results
|
||||||
|
<div className="relative h-[250px] overflow-hidden">
|
||||||
|
{/* Edge fade overlays */}
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to right, white 0%, white 20%, transparent 100%)'}} />
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-[120px] z-20 pointer-events-none" style={{background: 'linear-gradient(to left, white 0%, white 20%, transparent 100%)'}} />
|
||||||
|
|
||||||
|
<div className="carousel-container absolute left-0 flex gap-12 py-4">
|
||||||
|
{/* Duplicate results for infinite scroll */}
|
||||||
|
{[...searchResults, ...searchResults].map((result, index) => (
|
||||||
|
<button
|
||||||
|
key={`${result.url}-${index}`}
|
||||||
|
onClick={() => handleSubmit(result)}
|
||||||
|
className="flex-shrink-0 w-[400px] h-[240px] rounded-24 overflow-hidden border-2 border-gray-200/50 hover:border-orange-500 transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] cursor-pointer bg-white"
|
||||||
|
>
|
||||||
|
{result.screenshot ? (
|
||||||
|
<img
|
||||||
|
src={result.screenshot}
|
||||||
|
alt={result.title}
|
||||||
|
className="w-full h-full object-cover object-top"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-to-br from-gray-100 to-gray-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// No results state
|
||||||
|
<div className="relative h-[250px] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<svg className="w-16 h-16 mx-auto text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-lg">No results found</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">Try a different search term</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes infiniteScroll {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container {
|
||||||
|
animation: infiniteScroll 30s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-shimmer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-gradient {
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</HeaderProvider>
|
</HeaderProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+104
-17
@@ -8,6 +8,13 @@ interface HeroInputProps {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
showSearchFeatures?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isURL(str: string): boolean {
|
||||||
|
// Check if string contains a dot and looks like a URL
|
||||||
|
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/;
|
||||||
|
return urlPattern.test(str.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroInput({
|
export default function HeroInput({
|
||||||
@@ -15,10 +22,13 @@ export default function HeroInput({
|
|||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
placeholder = "Describe what you want to build...",
|
placeholder = "Describe what you want to build...",
|
||||||
className = ""
|
className = "",
|
||||||
|
showSearchFeatures = true
|
||||||
}: HeroInputProps) {
|
}: HeroInputProps) {
|
||||||
// const [isFocused, setIsFocused] = useState(false); // Reserved for future focus effects
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [showTiles, setShowTiles] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const isURLInput = showSearchFeatures ? isURL(value) : false;
|
||||||
|
|
||||||
// Reset textarea height when value changes (especially when cleared)
|
// Reset textarea height when value changes (especially when cleared)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,7 +36,14 @@ export default function HeroInput({
|
|||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||||
}
|
}
|
||||||
}, [value]);
|
|
||||||
|
// Show tiles animation for search terms (only if search features are enabled)
|
||||||
|
if (showSearchFeatures && value.trim() && !isURL(value) && isFocused) {
|
||||||
|
setShowTiles(true);
|
||||||
|
} else {
|
||||||
|
setShowTiles(false);
|
||||||
|
}
|
||||||
|
}, [value, isFocused]);
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
@@ -44,20 +61,52 @@ export default function HeroInput({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="p-16 flex gap-8 items-start w-full relative border-b border-black-alpha-5">
|
<label className="p-16 flex gap-8 items-start w-full relative border-b border-black-alpha-5">
|
||||||
<div className="mt-2 flex-shrink-0">
|
<div className="mt-2 flex-shrink-0">
|
||||||
<svg
|
{showSearchFeatures ? (
|
||||||
width="20"
|
isURLInput ? (
|
||||||
height="20"
|
// Link icon for URLs
|
||||||
viewBox="0 0 20 20"
|
<svg
|
||||||
fill="none"
|
width="20"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
height="20"
|
||||||
className="opacity-40"
|
viewBox="0 0 20 20"
|
||||||
>
|
fill="none"
|
||||||
<circle cx="10" cy="10" r="9.5" stroke="currentColor"/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path d="M10 2C10 5.5 10 14.5 10 18" stroke="currentColor" strokeLinecap="round"/>
|
className="opacity-40"
|
||||||
<path d="M2 10C5.5 10 14.5 10 18 10" stroke="currentColor" strokeLinecap="round"/>
|
>
|
||||||
<ellipse cx="10" cy="10" rx="3.5" ry="9.5" stroke="currentColor"/>
|
<path d="M9 11L11 9M11 9L15 5M11 9L5 15M15 5L13 3M15 5L17 7" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
<ellipse cx="10" cy="10" rx="6" ry="9.5" stroke="currentColor"/>
|
<path d="M7 13L5 15L3 13" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
</svg>
|
<path d="M13 7L15 5L17 7" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// Search icon for search terms
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="opacity-40"
|
||||||
|
>
|
||||||
|
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M12.5 12.5L16.5 16.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// Default globe icon for generation page
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="opacity-40"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="9.5" stroke="currentColor"/>
|
||||||
|
<path d="M10 2C10 5.5 10 14.5 10 18" stroke="currentColor" strokeLinecap="round"/>
|
||||||
|
<path d="M2 10C5.5 10 14.5 10 18 10" stroke="currentColor" strokeLinecap="round"/>
|
||||||
|
<ellipse cx="10" cy="10" rx="3.5" ry="9.5" stroke="currentColor"/>
|
||||||
|
<ellipse cx="10" cy="10" rx="6" ry="9.5" stroke="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -115,6 +164,44 @@ export default function HeroInput({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Animated tiles for search results */}
|
||||||
|
{showTiles && (
|
||||||
|
<div className="mt-16 grid grid-cols-3 gap-12 px-16">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="tile-animation relative aspect-[4/3] bg-black-alpha-4 rounded-12 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${index * 100}ms`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-black-alpha-4 to-transparent" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 bg-black-alpha-8 rounded-full animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes tileSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-animation {
|
||||||
|
animation: tileSlideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,20 @@ import Button from "@/components/shared/button/Button";
|
|||||||
|
|
||||||
export default function HeroInputSubmitButton({
|
export default function HeroInputSubmitButton({
|
||||||
dirty,
|
dirty,
|
||||||
|
buttonText = "Re-imagine Site",
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
|
buttonText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Button className="hero-input-button !p-0 bg-heat-100 hover:bg-heat-200" size="large" variant="primary">
|
<Button
|
||||||
|
className={`hero-input-button !p-0 ${disabled ? 'bg-gray-400 hover:bg-gray-400 cursor-wait' : 'bg-heat-100 hover:bg-heat-200'}`}
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<AnimatedWidth>
|
<AnimatedWidth>
|
||||||
<AnimatePresence initial={false} mode="popLayout">
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -20,7 +29,9 @@ export default function HeroInputSubmitButton({
|
|||||||
key={dirty ? "dirty" : "clean"}
|
key={dirty ? "dirty" : "clean"}
|
||||||
>
|
>
|
||||||
{dirty ? (
|
{dirty ? (
|
||||||
<div className="py-8 w-126 text-center text-white">Re-imagine Site</div>
|
<div className="py-8 w-126 text-center text-white">
|
||||||
|
{buttonText}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-60 py-8 flex-center">
|
<div className="w-60 py-8 flex-center">
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
|
|||||||
@@ -59,10 +59,20 @@ export default function SidebarInput({ onSubmit, disabled = false }: SidebarInpu
|
|||||||
<div className="p-4 border-b border-gray-100">
|
<div className="p-4 border-b border-gray-100">
|
||||||
{/* URL Input */}
|
{/* URL Input */}
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
<Globe />
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="opacity-40 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="14" height="14" rx="2" stroke="currentColor" strokeWidth="1.5"/>
|
||||||
|
<path d="M7 10L9 12L13 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none"
|
className="flex-1 bg-transparent text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none"
|
||||||
placeholder="example.com"
|
placeholder="Enter URL to scrape..."
|
||||||
type="text"
|
type="text"
|
||||||
value={url}
|
value={url}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -150,7 +160,7 @@ export default function SidebarInput({ onSubmit, disabled = false }: SidebarInpu
|
|||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{disabled ? 'Generating...' : 'Generate Website'}
|
{disabled ? 'Scraping...' : 'Scrape Site'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export class SandboxFactory {
|
|||||||
// Use environment variable if provider not specified
|
// Use environment variable if provider not specified
|
||||||
const selectedProvider = provider || process.env.SANDBOX_PROVIDER || 'e2b';
|
const selectedProvider = provider || process.env.SANDBOX_PROVIDER || 'e2b';
|
||||||
|
|
||||||
console.log(`[SandboxFactory] Creating ${selectedProvider} provider`);
|
|
||||||
|
|
||||||
switch (selectedProvider.toLowerCase()) {
|
switch (selectedProvider.toLowerCase()) {
|
||||||
case 'e2b':
|
case 'e2b':
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
*/
|
*/
|
||||||
async reconnect(sandboxId: string): Promise<boolean> {
|
async reconnect(sandboxId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log(`[E2BProvider] Attempting to reconnect to sandbox ${sandboxId}...`);
|
|
||||||
|
|
||||||
// Try to connect to existing sandbox
|
// Try to connect to existing sandbox
|
||||||
// Note: E2B SDK doesn't directly support reconnection, but we can try to recreate
|
// Note: E2B SDK doesn't directly support reconnection, but we can try to recreate
|
||||||
@@ -27,11 +26,9 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
|
|
||||||
async createSandbox(): Promise<SandboxInfo> {
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
try {
|
try {
|
||||||
console.log('[E2BProvider] Creating sandbox...');
|
|
||||||
|
|
||||||
// Kill existing sandbox if any
|
// Kill existing sandbox if any
|
||||||
if (this.sandbox) {
|
if (this.sandbox) {
|
||||||
console.log('[E2BProvider] Killing existing sandbox...');
|
|
||||||
try {
|
try {
|
||||||
await this.sandbox.kill();
|
await this.sandbox.kill();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -44,7 +41,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
this.existingFiles.clear();
|
this.existingFiles.clear();
|
||||||
|
|
||||||
// Create base sandbox
|
// Create base sandbox
|
||||||
console.log(`[E2BProvider] Creating E2B sandbox with ${appConfig.e2b.timeoutMinutes} minute timeout...`);
|
|
||||||
this.sandbox = await Sandbox.create({
|
this.sandbox = await Sandbox.create({
|
||||||
apiKey: this.config.e2b?.apiKey || process.env.E2B_API_KEY,
|
apiKey: this.config.e2b?.apiKey || process.env.E2B_API_KEY,
|
||||||
timeoutMs: this.config.e2b?.timeoutMs || appConfig.e2b.timeoutMs
|
timeoutMs: this.config.e2b?.timeoutMs || appConfig.e2b.timeoutMs
|
||||||
@@ -53,8 +49,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
const sandboxId = (this.sandbox as any).sandboxId || Date.now().toString();
|
const sandboxId = (this.sandbox as any).sandboxId || Date.now().toString();
|
||||||
const host = (this.sandbox as any).getHost(appConfig.e2b.vitePort);
|
const host = (this.sandbox as any).getHost(appConfig.e2b.vitePort);
|
||||||
|
|
||||||
console.log(`[E2BProvider] Sandbox created: ${sandboxId}`);
|
|
||||||
console.log(`[E2BProvider] Sandbox host: ${host}`);
|
|
||||||
|
|
||||||
this.sandboxInfo = {
|
this.sandboxInfo = {
|
||||||
sandboxId,
|
sandboxId,
|
||||||
@@ -66,7 +60,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
// Set extended timeout on the sandbox instance if method available
|
// Set extended timeout on the sandbox instance if method available
|
||||||
if (typeof this.sandbox.setTimeout === 'function') {
|
if (typeof this.sandbox.setTimeout === 'function') {
|
||||||
this.sandbox.setTimeout(appConfig.e2b.timeoutMs);
|
this.sandbox.setTimeout(appConfig.e2b.timeoutMs);
|
||||||
console.log(`[E2BProvider] Set sandbox timeout to ${appConfig.e2b.timeoutMinutes} minutes`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sandboxInfo;
|
return this.sandboxInfo;
|
||||||
@@ -82,7 +75,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[E2BProvider] Executing: ${command}`);
|
|
||||||
|
|
||||||
const result = await this.sandbox.runCode(`
|
const result = await this.sandbox.runCode(`
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -125,7 +117,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
if ((this.sandbox as any).files && typeof (this.sandbox as any).files.write === 'function') {
|
if ((this.sandbox as any).files && typeof (this.sandbox as any).files.write === 'function') {
|
||||||
// Use the files.write API if available
|
// Use the files.write API if available
|
||||||
await (this.sandbox as any).files.write(fullPath, Buffer.from(content));
|
await (this.sandbox as any).files.write(fullPath, Buffer.from(content));
|
||||||
console.log(`[E2BProvider] Written file using files.write: ${fullPath}`);
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to Python code execution
|
// Fallback to Python code execution
|
||||||
await this.sandbox.runCode(`
|
await this.sandbox.runCode(`
|
||||||
@@ -140,7 +131,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
f.write(${JSON.stringify(content)})
|
f.write(${JSON.stringify(content)})
|
||||||
print(f"✓ Written: ${fullPath}")
|
print(f"✓ Written: ${fullPath}")
|
||||||
`);
|
`);
|
||||||
console.log(`[E2BProvider] Written file using Python: ${fullPath}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.existingFiles.add(path);
|
this.existingFiles.add(path);
|
||||||
@@ -200,7 +190,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
const packageList = packages.join(' ');
|
const packageList = packages.join(' ');
|
||||||
const flags = appConfig.packages.useLegacyPeerDeps ? '--legacy-peer-deps' : '';
|
const flags = appConfig.packages.useLegacyPeerDeps ? '--legacy-peer-deps' : '';
|
||||||
|
|
||||||
console.log(`[E2BProvider] Installing packages: ${packageList}`);
|
|
||||||
|
|
||||||
const result = await this.sandbox.runCode(`
|
const result = await this.sandbox.runCode(`
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -244,7 +233,6 @@ export class E2BProvider extends SandboxProvider {
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[E2BProvider] Setting up Vite React app...');
|
|
||||||
|
|
||||||
// Write all files in a single Python script
|
// Write all files in a single Python script
|
||||||
const setupScript = `
|
const setupScript = `
|
||||||
@@ -405,7 +393,6 @@ print('\\nAll files created successfully!')
|
|||||||
await this.sandbox.runCode(setupScript);
|
await this.sandbox.runCode(setupScript);
|
||||||
|
|
||||||
// Install dependencies
|
// Install dependencies
|
||||||
console.log('[E2BProvider] Installing dependencies...');
|
|
||||||
await this.sandbox.runCode(`
|
await this.sandbox.runCode(`
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
@@ -424,7 +411,6 @@ else:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Start Vite dev server
|
// Start Vite dev server
|
||||||
console.log('[E2BProvider] Starting Vite dev server...');
|
|
||||||
await this.sandbox.runCode(`
|
await this.sandbox.runCode(`
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
@@ -470,7 +456,6 @@ print('Waiting for server to be ready...')
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[E2BProvider] Restarting Vite server...');
|
|
||||||
|
|
||||||
await this.sandbox.runCode(`
|
await this.sandbox.runCode(`
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -511,7 +496,6 @@ print(f'✓ Vite restarted with PID: {process.pid}')
|
|||||||
|
|
||||||
async terminate(): Promise<void> {
|
async terminate(): Promise<void> {
|
||||||
if (this.sandbox) {
|
if (this.sandbox) {
|
||||||
console.log('[E2BProvider] Terminating sandbox...');
|
|
||||||
try {
|
try {
|
||||||
await this.sandbox.kill();
|
await this.sandbox.kill();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
|
|
||||||
async createSandbox(): Promise<SandboxInfo> {
|
async createSandbox(): Promise<SandboxInfo> {
|
||||||
try {
|
try {
|
||||||
console.log('[VercelProvider] Creating sandbox...');
|
|
||||||
|
|
||||||
// Kill existing sandbox if any
|
// Kill existing sandbox if any
|
||||||
if (this.sandbox) {
|
if (this.sandbox) {
|
||||||
console.log('[VercelProvider] Stopping existing sandbox...');
|
|
||||||
try {
|
try {
|
||||||
await this.sandbox.stop();
|
await this.sandbox.stop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -24,7 +22,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
this.existingFiles.clear();
|
this.existingFiles.clear();
|
||||||
|
|
||||||
// Create Vercel sandbox
|
// Create Vercel sandbox
|
||||||
console.log('[VercelProvider] Creating Vercel sandbox...');
|
|
||||||
|
|
||||||
const sandboxConfig: any = {
|
const sandboxConfig: any = {
|
||||||
timeout: 300000, // 5 minutes in ms
|
timeout: 300000, // 5 minutes in ms
|
||||||
@@ -34,21 +31,13 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
|
|
||||||
// Add authentication based on environment variables
|
// Add authentication based on environment variables
|
||||||
if (process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID) {
|
if (process.env.VERCEL_TOKEN && process.env.VERCEL_TEAM_ID && process.env.VERCEL_PROJECT_ID) {
|
||||||
console.log('[VercelProvider] Using personal access token authentication');
|
|
||||||
console.log('[VercelProvider] Team ID:', process.env.VERCEL_TEAM_ID);
|
|
||||||
console.log('[VercelProvider] Project ID:', process.env.VERCEL_PROJECT_ID);
|
|
||||||
console.log('[VercelProvider] Token present:', !!process.env.VERCEL_TOKEN);
|
|
||||||
sandboxConfig.teamId = process.env.VERCEL_TEAM_ID;
|
sandboxConfig.teamId = process.env.VERCEL_TEAM_ID;
|
||||||
sandboxConfig.projectId = process.env.VERCEL_PROJECT_ID;
|
sandboxConfig.projectId = process.env.VERCEL_PROJECT_ID;
|
||||||
sandboxConfig.token = process.env.VERCEL_TOKEN;
|
sandboxConfig.token = process.env.VERCEL_TOKEN;
|
||||||
} else if (process.env.VERCEL_OIDC_TOKEN) {
|
} else if (process.env.VERCEL_OIDC_TOKEN) {
|
||||||
console.log('[VercelProvider] Using OIDC token authentication');
|
|
||||||
} else {
|
} else {
|
||||||
console.log('[VercelProvider] No authentication found - relying on default Vercel authentication');
|
|
||||||
console.log('[VercelProvider] Available env vars:', Object.keys(process.env).filter(k => k.startsWith('VERCEL')));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VercelProvider] Creating sandbox with config:', {
|
|
||||||
runtime: sandboxConfig.runtime,
|
runtime: sandboxConfig.runtime,
|
||||||
timeout: sandboxConfig.timeout,
|
timeout: sandboxConfig.timeout,
|
||||||
ports: sandboxConfig.ports,
|
ports: sandboxConfig.ports,
|
||||||
@@ -60,14 +49,12 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
this.sandbox = await Sandbox.create(sandboxConfig);
|
this.sandbox = await Sandbox.create(sandboxConfig);
|
||||||
|
|
||||||
const sandboxId = this.sandbox.sandboxId;
|
const sandboxId = this.sandbox.sandboxId;
|
||||||
console.log(`[VercelProvider] Sandbox created successfully:`, {
|
|
||||||
sandboxId: sandboxId,
|
sandboxId: sandboxId,
|
||||||
status: this.sandbox.status
|
status: this.sandbox.status
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the sandbox URL using the correct Vercel Sandbox API
|
// Get the sandbox URL using the correct Vercel Sandbox API
|
||||||
const sandboxUrl = this.sandbox.domain(5173);
|
const sandboxUrl = this.sandbox.domain(5173);
|
||||||
console.log(`[VercelProvider] Sandbox URL: ${sandboxUrl}`);
|
|
||||||
|
|
||||||
this.sandboxInfo = {
|
this.sandboxInfo = {
|
||||||
sandboxId,
|
sandboxId,
|
||||||
@@ -89,7 +76,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[VercelProvider] Executing: ${command}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse command into cmd and args (matching PR syntax)
|
// Parse command into cmd and args (matching PR syntax)
|
||||||
@@ -129,7 +115,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
// Vercel sandbox default working directory is /vercel/sandbox
|
// Vercel sandbox default working directory is /vercel/sandbox
|
||||||
const fullPath = path.startsWith('/') ? path : `/vercel/sandbox/${path}`;
|
const fullPath = path.startsWith('/') ? path : `/vercel/sandbox/${path}`;
|
||||||
|
|
||||||
console.log(`[VercelProvider] writeFile called:`, {
|
|
||||||
originalPath: path,
|
originalPath: path,
|
||||||
fullPath: fullPath,
|
fullPath: fullPath,
|
||||||
contentLength: content.length,
|
contentLength: content.length,
|
||||||
@@ -141,7 +126,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
// Based on Vercel SDK docs, writeFiles expects path and Buffer content
|
// Based on Vercel SDK docs, writeFiles expects path and Buffer content
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(content, 'utf-8');
|
const buffer = Buffer.from(content, 'utf-8');
|
||||||
console.log(`[VercelProvider] Calling sandbox.writeFiles with:`, {
|
|
||||||
path: fullPath,
|
path: fullPath,
|
||||||
bufferLength: buffer.length,
|
bufferLength: buffer.length,
|
||||||
isBuffer: Buffer.isBuffer(buffer)
|
isBuffer: Buffer.isBuffer(buffer)
|
||||||
@@ -152,7 +136,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
content: buffer
|
content: buffer
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
console.log(`[VercelProvider] Successfully written: ${fullPath}`);
|
|
||||||
this.existingFiles.add(path);
|
this.existingFiles.add(path);
|
||||||
} catch (writeError: any) {
|
} catch (writeError: any) {
|
||||||
// Log detailed error information
|
// Log detailed error information
|
||||||
@@ -165,17 +148,14 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fallback to command-based approach if writeFiles fails
|
// Fallback to command-based approach if writeFiles fails
|
||||||
console.log(`[VercelProvider] Attempting command fallback for ${fullPath}`);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
const dir = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
||||||
if (dir) {
|
if (dir) {
|
||||||
console.log(`[VercelProvider] Creating directory: ${dir}`);
|
|
||||||
const mkdirResult = await this.sandbox.runCommand({
|
const mkdirResult = await this.sandbox.runCommand({
|
||||||
cmd: 'mkdir',
|
cmd: 'mkdir',
|
||||||
args: ['-p', dir]
|
args: ['-p', dir]
|
||||||
});
|
});
|
||||||
console.log(`[VercelProvider] mkdir result:`, {
|
|
||||||
exitCode: mkdirResult.exitCode,
|
exitCode: mkdirResult.exitCode,
|
||||||
stdout: mkdirResult.stdout,
|
stdout: mkdirResult.stdout,
|
||||||
stderr: mkdirResult.stderr
|
stderr: mkdirResult.stderr
|
||||||
@@ -190,20 +170,17 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
.replace(/`/g, '\\`')
|
.replace(/`/g, '\\`')
|
||||||
.replace(/\n/g, '\\n');
|
.replace(/\n/g, '\\n');
|
||||||
|
|
||||||
console.log(`[VercelProvider] Writing file via echo command to: ${fullPath}`);
|
|
||||||
const writeResult = await this.sandbox.runCommand({
|
const writeResult = await this.sandbox.runCommand({
|
||||||
cmd: 'sh',
|
cmd: 'sh',
|
||||||
args: ['-c', `echo "${escapedContent}" > "${fullPath}"`]
|
args: ['-c', `echo "${escapedContent}" > "${fullPath}"`]
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[VercelProvider] Write command result:`, {
|
|
||||||
exitCode: writeResult.exitCode,
|
exitCode: writeResult.exitCode,
|
||||||
stdout: writeResult.stdout,
|
stdout: writeResult.stdout,
|
||||||
stderr: writeResult.stderr
|
stderr: writeResult.stderr
|
||||||
});
|
});
|
||||||
|
|
||||||
if (writeResult.exitCode === 0) {
|
if (writeResult.exitCode === 0) {
|
||||||
console.log(`[VercelProvider] Successfully written via command: ${fullPath}`);
|
|
||||||
this.existingFiles.add(path);
|
this.existingFiles.add(path);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Failed to write file via command: ${writeResult.stderr}`);
|
throw new Error(`Failed to write file via command: ${writeResult.stderr}`);
|
||||||
@@ -256,7 +233,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
|
|
||||||
const flags = process.env.NPM_FLAGS || '';
|
const flags = process.env.NPM_FLAGS || '';
|
||||||
|
|
||||||
console.log(`[VercelProvider] Installing packages: ${packages.join(' ')}`);
|
|
||||||
|
|
||||||
// Build args array
|
// Build args array
|
||||||
const args = ['install'];
|
const args = ['install'];
|
||||||
@@ -289,19 +265,15 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VercelProvider] Setting up Vite React app...');
|
|
||||||
console.log('[VercelProvider] Sandbox details:', {
|
|
||||||
sandboxId: this.sandbox.sandboxId,
|
sandboxId: this.sandbox.sandboxId,
|
||||||
status: this.sandbox.status
|
status: this.sandbox.status
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create directory structure
|
// Create directory structure
|
||||||
console.log('[VercelProvider] Creating directory structure...');
|
|
||||||
const mkdirResult = await this.sandbox.runCommand({
|
const mkdirResult = await this.sandbox.runCommand({
|
||||||
cmd: 'mkdir',
|
cmd: 'mkdir',
|
||||||
args: ['-p', '/vercel/sandbox/src']
|
args: ['-p', '/vercel/sandbox/src']
|
||||||
});
|
});
|
||||||
console.log('[VercelProvider] mkdir /vercel/sandbox/src result:', {
|
|
||||||
exitCode: mkdirResult.exitCode,
|
exitCode: mkdirResult.exitCode,
|
||||||
stdout: mkdirResult.stdout,
|
stdout: mkdirResult.stdout,
|
||||||
stderr: mkdirResult.stderr
|
stderr: mkdirResult.stderr
|
||||||
@@ -330,7 +302,6 @@ export class VercelProvider extends SandboxProvider {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[VercelProvider] Writing package.json...');
|
|
||||||
await this.writeFile('package.json', JSON.stringify(packageJson, null, 2));
|
await this.writeFile('package.json', JSON.stringify(packageJson, null, 2));
|
||||||
|
|
||||||
// Create vite.config.js
|
// Create vite.config.js
|
||||||
@@ -442,11 +413,8 @@ body {
|
|||||||
|
|
||||||
await this.writeFile('src/index.css', indexCss);
|
await this.writeFile('src/index.css', indexCss);
|
||||||
|
|
||||||
console.log('[VercelProvider] All files created successfully!');
|
|
||||||
|
|
||||||
// Install dependencies
|
// Install dependencies
|
||||||
console.log('[VercelProvider] Installing dependencies...');
|
|
||||||
console.log('[VercelProvider] Running npm install in /vercel/sandbox');
|
|
||||||
try {
|
try {
|
||||||
const installResult = await this.sandbox.runCommand({
|
const installResult = await this.sandbox.runCommand({
|
||||||
cmd: 'npm',
|
cmd: 'npm',
|
||||||
@@ -454,14 +422,12 @@ body {
|
|||||||
cwd: '/vercel/sandbox'
|
cwd: '/vercel/sandbox'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[VercelProvider] npm install result:', {
|
|
||||||
exitCode: installResult.exitCode,
|
exitCode: installResult.exitCode,
|
||||||
stdout: typeof installResult.stdout === 'function' ? 'function' : installResult.stdout,
|
stdout: typeof installResult.stdout === 'function' ? 'function' : installResult.stdout,
|
||||||
stderr: typeof installResult.stderr === 'function' ? 'function' : installResult.stderr
|
stderr: typeof installResult.stderr === 'function' ? 'function' : installResult.stderr
|
||||||
});
|
});
|
||||||
|
|
||||||
if (installResult.exitCode === 0) {
|
if (installResult.exitCode === 0) {
|
||||||
console.log('[VercelProvider] Dependencies installed successfully');
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('[VercelProvider] npm install had issues:', installResult.stderr);
|
console.warn('[VercelProvider] npm install had issues:', installResult.stderr);
|
||||||
}
|
}
|
||||||
@@ -472,7 +438,6 @@ body {
|
|||||||
responseText: error?.text
|
responseText: error?.text
|
||||||
});
|
});
|
||||||
// Try alternative approach - run as shell command
|
// Try alternative approach - run as shell command
|
||||||
console.log('[VercelProvider] Trying alternative npm install approach...');
|
|
||||||
try {
|
try {
|
||||||
const altResult = await this.sandbox.runCommand({
|
const altResult = await this.sandbox.runCommand({
|
||||||
cmd: 'sh',
|
cmd: 'sh',
|
||||||
@@ -480,7 +445,6 @@ body {
|
|||||||
cwd: '/vercel/sandbox'
|
cwd: '/vercel/sandbox'
|
||||||
});
|
});
|
||||||
if (altResult.exitCode === 0) {
|
if (altResult.exitCode === 0) {
|
||||||
console.log('[VercelProvider] Dependencies installed successfully (alternative method)');
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('[VercelProvider] Alternative npm install also had issues:', altResult.stderr);
|
console.warn('[VercelProvider] Alternative npm install also had issues:', altResult.stderr);
|
||||||
}
|
}
|
||||||
@@ -491,7 +455,6 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start Vite dev server
|
// Start Vite dev server
|
||||||
console.log('[VercelProvider] Starting Vite dev server...');
|
|
||||||
|
|
||||||
// Kill any existing Vite processes
|
// Kill any existing Vite processes
|
||||||
await this.sandbox.runCommand({
|
await this.sandbox.runCommand({
|
||||||
@@ -507,7 +470,6 @@ body {
|
|||||||
cwd: '/vercel/sandbox'
|
cwd: '/vercel/sandbox'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[VercelProvider] Vite dev server started');
|
|
||||||
|
|
||||||
// Wait for Vite to be ready
|
// Wait for Vite to be ready
|
||||||
await new Promise(resolve => setTimeout(resolve, 7000));
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
||||||
@@ -528,7 +490,6 @@ body {
|
|||||||
throw new Error('No active sandbox');
|
throw new Error('No active sandbox');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VercelProvider] Restarting Vite server...');
|
|
||||||
|
|
||||||
// Kill existing Vite process
|
// Kill existing Vite process
|
||||||
await this.sandbox.runCommand({
|
await this.sandbox.runCommand({
|
||||||
@@ -547,7 +508,6 @@ body {
|
|||||||
cwd: '/vercel/sandbox'
|
cwd: '/vercel/sandbox'
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[VercelProvider] Vite restarted');
|
|
||||||
|
|
||||||
// Wait for Vite to be ready
|
// Wait for Vite to be ready
|
||||||
await new Promise(resolve => setTimeout(resolve, 7000));
|
await new Promise(resolve => setTimeout(resolve, 7000));
|
||||||
@@ -563,7 +523,6 @@ body {
|
|||||||
|
|
||||||
async terminate(): Promise<void> {
|
async terminate(): Promise<void> {
|
||||||
if (this.sandbox) {
|
if (this.sandbox) {
|
||||||
console.log('[VercelProvider] Terminating sandbox...');
|
|
||||||
try {
|
try {
|
||||||
await this.sandbox.stop();
|
await this.sandbox.stop();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class SandboxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to reconnect to existing sandbox
|
// Try to reconnect to existing sandbox
|
||||||
console.log(`[SandboxManager] Attempting to reconnect to sandbox ${sandboxId}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const provider = SandboxFactory.create();
|
const provider = SandboxFactory.create();
|
||||||
@@ -41,14 +40,12 @@ class SandboxManager {
|
|||||||
lastAccessed: new Date()
|
lastAccessed: new Date()
|
||||||
});
|
});
|
||||||
this.activeSandboxId = sandboxId;
|
this.activeSandboxId = sandboxId;
|
||||||
console.log(`[SandboxManager] Successfully reconnected to sandbox ${sandboxId}`);
|
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Vercel or if reconnection failed, return the new provider
|
// For Vercel or if reconnection failed, return the new provider
|
||||||
// The caller will need to handle creating a new sandbox
|
// The caller will need to handle creating a new sandbox
|
||||||
console.log(`[SandboxManager] Could not reconnect to ${sandboxId}, returning new provider`);
|
|
||||||
return provider;
|
return provider;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SandboxManager] Error reconnecting to sandbox ${sandboxId}:`, error);
|
console.error(`[SandboxManager] Error reconnecting to sandbox ${sandboxId}:`, error);
|
||||||
@@ -67,7 +64,6 @@ class SandboxManager {
|
|||||||
lastAccessed: new Date()
|
lastAccessed: new Date()
|
||||||
});
|
});
|
||||||
this.activeSandboxId = sandboxId;
|
this.activeSandboxId = sandboxId;
|
||||||
console.log(`[SandboxManager] Registered sandbox ${sandboxId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,7 +156,6 @@ class SandboxManager {
|
|||||||
|
|
||||||
for (const id of toDelete) {
|
for (const id of toDelete) {
|
||||||
await this.terminateSandbox(id);
|
await this.terminateSandbox(id);
|
||||||
console.log(`[SandboxManager] Cleaned up old sandbox ${id}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user