continue re-design
This commit is contained in:
@@ -59,10 +59,26 @@ export async function POST(request: NextRequest) {
|
||||
case 'clear-old':
|
||||
// Clear old conversation data but keep recent context
|
||||
if (!global.conversationState) {
|
||||
// Initialize conversation state if it doesn't exist
|
||||
global.conversationState = {
|
||||
conversationId: `conv-${Date.now()}`,
|
||||
startedAt: Date.now(),
|
||||
lastUpdated: Date.now(),
|
||||
context: {
|
||||
messages: [],
|
||||
edits: [],
|
||||
projectEvolution: { majorChanges: [] },
|
||||
userPreferences: {}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[conversation-state] Initialized new conversation state for clear-old');
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'No active conversation to clear'
|
||||
}, { status: 400 });
|
||||
success: true,
|
||||
message: 'New conversation state initialized',
|
||||
state: global.conversationState
|
||||
});
|
||||
}
|
||||
|
||||
// Keep only recent data
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { SandboxState } from '@/types/sandbox';
|
||||
|
||||
// Store active sandbox globally
|
||||
declare global {
|
||||
var activeSandboxProvider: SandboxProvider | null;
|
||||
var activeSandboxProvider: any;
|
||||
var sandboxData: any;
|
||||
var existingFiles: Set<string>;
|
||||
var sandboxState: SandboxState;
|
||||
|
||||
@@ -18,6 +18,12 @@ export const dynamic = 'force-dynamic';
|
||||
const isUsingAIGateway = !!process.env.AI_GATEWAY_API_KEY;
|
||||
const aiGatewayBaseURL = 'https://ai-gateway.vercel.sh/v1';
|
||||
|
||||
console.log('[generate-ai-code-stream] AI Gateway config:', {
|
||||
isUsingAIGateway,
|
||||
hasGroqKey: !!process.env.GROQ_API_KEY,
|
||||
hasAIGatewayKey: !!process.env.AI_GATEWAY_API_KEY
|
||||
});
|
||||
|
||||
const groq = createGroq({
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? process.env.GROQ_API_KEY,
|
||||
baseURL: isUsingAIGateway ? aiGatewayBaseURL : undefined,
|
||||
@@ -152,10 +158,18 @@ export async function POST(request: NextRequest) {
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
|
||||
// Function to send progress updates
|
||||
// Function to send progress updates with flushing
|
||||
const sendProgress = async (data: any) => {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
await writer.write(encoder.encode(message));
|
||||
try {
|
||||
await writer.write(encoder.encode(message));
|
||||
// Force flush by writing a keep-alive comment
|
||||
if (data.type === 'stream' || data.type === 'conversation') {
|
||||
await writer.write(encoder.encode(': keepalive\n\n'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[generate-ai-code-stream] Error writing to stream:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Start processing in background
|
||||
@@ -1169,15 +1183,22 @@ CRITICAL: When files are provided in the context:
|
||||
// Determine which provider to use based on model
|
||||
const isAnthropic = model.startsWith('anthropic/');
|
||||
const isGoogle = model.startsWith('google/');
|
||||
const isOpenAI = model.startsWith('openai/gpt-5');
|
||||
const modelProvider = isAnthropic ? anthropic : (isOpenAI ? openai : (isGoogle ? googleGenerativeAI : groq));
|
||||
const isOpenAI = model.startsWith('openai/');
|
||||
const isKimiGroq = model === 'moonshotai/kimi-k2-instruct-0905';
|
||||
const modelProvider = isAnthropic ? anthropic :
|
||||
(isOpenAI ? openai :
|
||||
(isGoogle ? googleGenerativeAI :
|
||||
(isKimiGroq ? groq : groq)));
|
||||
|
||||
// Fix model name transformation for different providers
|
||||
let actualModel: string;
|
||||
if (isAnthropic) {
|
||||
actualModel = model.replace('anthropic/', '');
|
||||
} else if (model === 'openai/gpt-5') {
|
||||
actualModel = 'gpt-5';
|
||||
} else if (isOpenAI) {
|
||||
actualModel = model.replace('openai/', '');
|
||||
} else if (isKimiGroq) {
|
||||
// Kimi on Groq - use full model string
|
||||
actualModel = 'moonshotai/kimi-k2-instruct-0905';
|
||||
} else if (isGoogle) {
|
||||
// Google uses specific model names - convert our naming to theirs
|
||||
actualModel = model.replace('google/', '');
|
||||
@@ -1186,6 +1207,8 @@ CRITICAL: When files are provided in the context:
|
||||
}
|
||||
|
||||
console.log(`[generate-ai-code-stream] Using provider: ${isAnthropic ? 'Anthropic' : isGoogle ? 'Google' : isOpenAI ? 'OpenAI' : 'Groq'}, model: ${actualModel}`);
|
||||
console.log(`[generate-ai-code-stream] AI Gateway enabled: ${isUsingAIGateway}`);
|
||||
console.log(`[generate-ai-code-stream] Model string: ${model}`);
|
||||
|
||||
// Make streaming API call with appropriate provider
|
||||
const streamOptions: any = {
|
||||
@@ -1349,6 +1372,11 @@ It's better to have 3 complete files than 10 incomplete files.`
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Debug: Log every 100 characters streamed
|
||||
if (generatedCode.length % 100 < text.length) {
|
||||
console.log(`[generate-ai-code-stream] Streamed ${generatedCode.length} chars`);
|
||||
}
|
||||
|
||||
// Check for package tags in buffered text (ONLY for edits, not initial generation)
|
||||
let lastIndex = 0;
|
||||
if (isEdit) {
|
||||
@@ -1638,12 +1666,28 @@ Provide the complete file content without any truncation. Include all necessary
|
||||
completionClient = openai;
|
||||
} else if (model.includes('claude')) {
|
||||
completionClient = anthropic;
|
||||
} else if (model === 'moonshotai/kimi-k2-instruct-0905') {
|
||||
completionClient = groq;
|
||||
} else {
|
||||
completionClient = groq;
|
||||
}
|
||||
|
||||
// Determine the correct model name for the completion
|
||||
let completionModelName: string;
|
||||
if (model === 'moonshotai/kimi-k2-instruct-0905') {
|
||||
completionModelName = 'moonshotai/kimi-k2-instruct-0905';
|
||||
} else if (model.includes('openai')) {
|
||||
completionModelName = model.replace('openai/', '');
|
||||
} else if (model.includes('anthropic')) {
|
||||
completionModelName = model.replace('anthropic/', '');
|
||||
} else if (model.includes('google')) {
|
||||
completionModelName = model.replace('google/', '');
|
||||
} else {
|
||||
completionModelName = model;
|
||||
}
|
||||
|
||||
const completionResult = await streamText({
|
||||
model: completionClient(modelMapping[model] || model),
|
||||
model: completionClient(completionModelName),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import FirecrawlApp from '@mendable/firecrawl-js';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -8,43 +9,44 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Use Firecrawl API to capture screenshot
|
||||
const firecrawlResponse = await fetch('https://api.firecrawl.dev/v1/scrape', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.FIRECRAWL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
formats: ['screenshot'], // Regular viewport screenshot, not full page
|
||||
waitFor: 3000, // Wait for page to fully load
|
||||
timeout: 30000,
|
||||
blockAds: true,
|
||||
actions: [
|
||||
{
|
||||
type: 'wait',
|
||||
milliseconds: 2000 // Additional wait for dynamic content
|
||||
}
|
||||
]
|
||||
})
|
||||
// Initialize Firecrawl with API key from environment
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("FIRECRAWL_API_KEY not configured");
|
||||
return NextResponse.json({
|
||||
error: 'Firecrawl API key not configured'
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
const app = new FirecrawlApp({ apiKey });
|
||||
|
||||
// Use Firecrawl SDK to capture screenshot with the latest API
|
||||
const scrapeResult = await app.scrapeUrl(url, {
|
||||
formats: ['screenshot'], // Request screenshot format
|
||||
waitFor: 3000, // Wait for page to fully load
|
||||
timeout: 30000,
|
||||
onlyMainContent: false, // Get full page for screenshot
|
||||
actions: [
|
||||
{
|
||||
type: 'wait',
|
||||
milliseconds: 2000 // Additional wait for dynamic content
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!firecrawlResponse.ok) {
|
||||
const error = await firecrawlResponse.text();
|
||||
throw new Error(`Firecrawl API error: ${error}`);
|
||||
if (!scrapeResult.success) {
|
||||
throw new Error(scrapeResult.error || 'Failed to capture screenshot');
|
||||
}
|
||||
|
||||
const data = await firecrawlResponse.json();
|
||||
|
||||
if (!data.success || !data.data?.screenshot) {
|
||||
throw new Error('Failed to capture screenshot');
|
||||
if (!scrapeResult.data?.screenshot) {
|
||||
throw new Error('Screenshot not available in response');
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
screenshot: data.data.screenshot,
|
||||
metadata: data.data.metadata
|
||||
screenshot: scrapeResult.data.screenshot,
|
||||
metadata: scrapeResult.data.metadata || {}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import FirecrawlApp from '@mendable/firecrawl-js';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { url, formats = ['markdown', 'html'], options = {} } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize Firecrawl with API key from environment
|
||||
const apiKey = process.env.FIRECRAWL_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("FIRECRAWL_API_KEY not configured");
|
||||
// For demo purposes, return mock data if API key is not set
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
title: "Example Website",
|
||||
content: `This is a mock response for ${url}. Configure FIRECRAWL_API_KEY to enable real scraping.`,
|
||||
description: "A sample website",
|
||||
markdown: `# Example Website\n\nThis is mock content for demonstration purposes.`,
|
||||
html: `<h1>Example Website</h1><p>This is mock content for demonstration purposes.</p>`,
|
||||
metadata: {
|
||||
title: "Example Website",
|
||||
description: "A sample website",
|
||||
sourceURL: url,
|
||||
statusCode: 200
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const app = new FirecrawlApp({ apiKey });
|
||||
|
||||
// Scrape the website using the latest SDK patterns
|
||||
// Include screenshot if requested in formats
|
||||
const scrapeResult = await app.scrapeUrl(url, {
|
||||
formats: formats,
|
||||
onlyMainContent: options.onlyMainContent !== false, // Default to true for cleaner content
|
||||
waitFor: options.waitFor || 2000, // Wait for dynamic content
|
||||
timeout: options.timeout || 30000,
|
||||
...options // Pass through any additional options
|
||||
});
|
||||
|
||||
// Handle the response according to the latest SDK structure
|
||||
if (!scrapeResult.success) {
|
||||
throw new Error(scrapeResult.error || "Failed to scrape website");
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
title: scrapeResult.data?.metadata?.title || "Untitled",
|
||||
content: scrapeResult.data?.markdown || scrapeResult.data?.html || "",
|
||||
description: scrapeResult.data?.metadata?.description || "",
|
||||
markdown: scrapeResult.data?.markdown || "",
|
||||
html: scrapeResult.data?.html || "",
|
||||
metadata: scrapeResult.data?.metadata || {},
|
||||
screenshot: scrapeResult.data?.screenshot || null,
|
||||
links: scrapeResult.data?.links || [],
|
||||
// Include raw data for flexibility
|
||||
raw: scrapeResult.data
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error scraping website:", error);
|
||||
|
||||
// Return a more detailed error response
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to scrape website",
|
||||
// Provide mock data as fallback for development
|
||||
data: {
|
||||
title: "Example Website",
|
||||
content: "This is fallback content due to an error. Please check your configuration.",
|
||||
description: "Error occurred while scraping",
|
||||
markdown: `# Error\n\n${error instanceof Error ? error.message : 'Unknown error occurred'}`,
|
||||
html: `<h1>Error</h1><p>${error instanceof Error ? error.message : 'Unknown error occurred'}</p>`,
|
||||
metadata: {
|
||||
title: "Error",
|
||||
description: "Failed to scrape website",
|
||||
statusCode: 500
|
||||
}
|
||||
}
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Add OPTIONS handler for CORS if needed
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function BuilderPage() {
|
||||
const [targetUrl, setTargetUrl] = useState<string>("");
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>("modern");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>("");
|
||||
const [progress, setProgress] = useState<string>("Initializing...");
|
||||
const [generatedCode, setGeneratedCode] = useState<string>("");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Get the URL and style from sessionStorage
|
||||
const url = sessionStorage.getItem('targetUrl');
|
||||
const style = sessionStorage.getItem('selectedStyle');
|
||||
|
||||
if (!url) {
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setTargetUrl(url);
|
||||
setSelectedStyle(style || "modern");
|
||||
|
||||
// Start the website generation process
|
||||
generateWebsite(url, style || "modern");
|
||||
}, [router]);
|
||||
|
||||
const generateWebsite = async (url: string, style: string) => {
|
||||
try {
|
||||
setProgress("Analyzing website...");
|
||||
|
||||
// For demo purposes, we'll generate a simple HTML template
|
||||
// In production, this would call the actual scraping and generation APIs
|
||||
const mockGeneratedCode = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${style} Website - Reimagined</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: ${style === 'modern' ? '#FA5D19' : style === 'playful' ? '#9061ff' : style === 'professional' ? '#2a6dfb' : '#eb3424'};
|
||||
--background: ${style === 'modern' ? '#ffffff' : style === 'playful' ? '#f9f9f9' : style === 'professional' ? '#f5f5f5' : '#fafafa'};
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--background);
|
||||
color: #262626;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #ededed;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 4rem auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 4rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), #262626);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.feature {
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #ededed;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.feature:hover {
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.feature h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<div class="logo">Reimagined</div>
|
||||
<div>
|
||||
<a href="#features" style="margin-right: 2rem; color: #666; text-decoration: none;">Features</a>
|
||||
<a href="#about" style="margin-right: 2rem; color: #666; text-decoration: none;">About</a>
|
||||
<a href="#contact" style="color: #666; text-decoration: none;">Contact</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="hero">
|
||||
<h1>Welcome to Your ${style === 'modern' ? 'Modern' : style === 'playful' ? 'Playful' : style === 'professional' ? 'Professional' : 'Artistic'} Website</h1>
|
||||
<p class="subtitle">Reimagined from ${url}</p>
|
||||
<a href="#" class="cta-button">Get Started</a>
|
||||
</div>
|
||||
|
||||
<div class="features" id="features">
|
||||
<div class="feature">
|
||||
<h3>Fast</h3>
|
||||
<p>Lightning-fast performance optimized for modern web standards.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Responsive</h3>
|
||||
<p>Looks great on all devices, from mobile to desktop.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Beautiful</h3>
|
||||
<p>Stunning design that captures attention and drives engagement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
setGeneratedCode(mockGeneratedCode);
|
||||
|
||||
// Create a blob URL for the preview
|
||||
const blob = new Blob([mockGeneratedCode], { type: 'text/html' });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setPreviewUrl(blobUrl);
|
||||
|
||||
setProgress("Website ready!");
|
||||
setIsLoading(false);
|
||||
|
||||
// Show success message
|
||||
toast.success("Website generated successfully!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating website:", error);
|
||||
toast.error("Failed to generate website. Please try again.");
|
||||
setProgress("Error occurred");
|
||||
setTimeout(() => router.push('/'), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCode = () => {
|
||||
const blob = new Blob([generatedCode], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'website.html';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Code downloaded!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background-base">
|
||||
<div className="flex h-screen">
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 bg-white border-r border-border-faint p-24 flex flex-col">
|
||||
<h2 className="text-title-small font-semibold mb-16">Building Your Website</h2>
|
||||
|
||||
<div className="space-y-12 flex-1">
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-56 mb-4">Target URL</div>
|
||||
<div className="text-body-medium text-accent-black truncate">{targetUrl}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-56 mb-4">Style</div>
|
||||
<div className="text-body-medium text-accent-black capitalize">{selectedStyle}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-56 mb-4">Status</div>
|
||||
<div className="text-body-medium text-heat-100">{progress}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{!isLoading && (
|
||||
<button
|
||||
onClick={downloadCode}
|
||||
className="w-full py-12 px-16 bg-heat-100 hover:bg-heat-200 text-white rounded-10 text-label-medium transition-all"
|
||||
>
|
||||
Download Code
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full py-12 px-16 bg-black-alpha-4 hover:bg-black-alpha-6 rounded-10 text-label-medium transition-all"
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="flex-1 bg-gray-50">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-48 h-48 border-4 border-heat-100 border-t-transparent rounded-full animate-spin mb-16 mx-auto"></div>
|
||||
<p className="text-body-large text-black-alpha-56">{progress}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
previewUrl && (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="w-full h-full border-0"
|
||||
title="Website Preview"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+1
-205
@@ -1,205 +1 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@keyframes slide {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(70px, 70px); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sunPulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orbShrink {
|
||||
0% {
|
||||
transform: translateX(-50%) translateY(45%) scale(1.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) translateY(45%) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes screenshot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes camera-float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-10px) rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(5px) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lens-rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pushUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInSmooth {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme configuration for Tailwind CSS v4 */
|
||||
@theme {
|
||||
--color-background: hsl(0 0% 100%);
|
||||
--color-foreground: hsl(240 10% 3.9%);
|
||||
--color-card: hsl(0 0% 100%);
|
||||
--color-card-foreground: hsl(240 10% 3.9%);
|
||||
--color-popover: hsl(0 0% 100%);
|
||||
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||
--color-primary: hsl(25 95% 53%);
|
||||
--color-primary-foreground: hsl(0 0% 98%);
|
||||
--color-secondary: hsl(240 4.8% 95.9%);
|
||||
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||
--color-muted: hsl(240 4.8% 95.9%);
|
||||
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--color-accent: hsl(240 4.8% 95.9%);
|
||||
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
--color-ring: hsl(25 95% 53%);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Radial gradient utilities */
|
||||
.bg-gradient-radial {
|
||||
background-image: radial-gradient(circle, var(--tw-gradient-stops));
|
||||
}
|
||||
|
||||
/* Conic gradient utilities */
|
||||
.bg-gradient-conic {
|
||||
background-image: conic-gradient(var(--tw-gradient-stops));
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: theme('colors.border');
|
||||
}
|
||||
body {
|
||||
background-color: theme('colors.background');
|
||||
color: theme('colors.foreground');
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-gradient-shift {
|
||||
background-size: 400% 400%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
|
||||
.animate-camera-float {
|
||||
animation: camera-float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-lens-rotate {
|
||||
animation: lens-rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.animation-delay-200 {
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
|
||||
.animate-push-up {
|
||||
animation: pushUp 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-smooth {
|
||||
opacity: 0;
|
||||
animation: fadeInSmooth 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
opacity: 0;
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
}
|
||||
}
|
||||
@import "../styles/main.css";
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
// Import shared components
|
||||
import { HeaderProvider } from "@/components/shared/header/HeaderContext";
|
||||
import HeaderBrandKit from "@/components/shared/header/BrandKit/BrandKit";
|
||||
import HeaderWrapper from "@/components/shared/header/Wrapper/Wrapper";
|
||||
import HeaderDropdownWrapper from "@/components/shared/header/Dropdown/Wrapper/Wrapper";
|
||||
import ButtonUI from "@/components/ui/shadcn/button";
|
||||
|
||||
// Import hero section components
|
||||
import HomeHeroBackground from "@/components/app/(home)/sections/hero/Background/Background";
|
||||
import { BackgroundOuterPiece } from "@/components/app/(home)/sections/hero/Background/BackgroundOuterPiece";
|
||||
import HomeHeroBadge from "@/components/app/(home)/sections/hero/Badge/Badge";
|
||||
import HomeHeroPixi from "@/components/app/(home)/sections/hero/Pixi/Pixi";
|
||||
import HomeHeroTitle from "@/components/app/(home)/sections/hero/Title/Title";
|
||||
import HeroInput from "@/components/app/(home)/sections/hero-input/HeroInput";
|
||||
import { Connector } from "@/components/shared/layout/curvy-rect";
|
||||
import HeroFlame from "@/components/shared/effects/flame/hero-flame";
|
||||
import FirecrawlIcon from "@/components/FirecrawlIcon";
|
||||
import FirecrawlLogo from "@/components/FirecrawlLogo";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<HeaderProvider>
|
||||
<div className="min-h-screen bg-background-base">
|
||||
{/* Header/Navigation Section */}
|
||||
<HeaderDropdownWrapper />
|
||||
|
||||
<div className="sticky top-0 left-0 w-full z-[101] bg-background-base header">
|
||||
<div className="absolute top-0 cmw-container border-x border-border-faint h-full pointer-events-none" />
|
||||
<div className="h-1 bg-border-faint w-full left-0 -bottom-1 absolute" />
|
||||
|
||||
<div className="cmw-container absolute h-full pointer-events-none top-0">
|
||||
<Connector className="absolute -left-[10.5px] -bottom-11" />
|
||||
<Connector className="absolute -right-[10.5px] -bottom-11" />
|
||||
</div>
|
||||
|
||||
<HeaderWrapper>
|
||||
<div className="max-w-[900px] mx-auto w-full flex justify-between items-center">
|
||||
<div className="flex gap-24 items-center">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<FirecrawlIcon className="w-7 h-7 text-accent-black" />
|
||||
<FirecrawlLogo />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<Link
|
||||
href="https://github.com/mendableai/open-lovable"
|
||||
target="_blank"
|
||||
className="contents"
|
||||
>
|
||||
<ButtonUI variant="tertiary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
Use this Template
|
||||
</ButtonUI>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderWrapper>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="overflow-x-clip" id="home-hero">
|
||||
<div className="pt-28 lg:pt-254 lg:-mt-100 pb-115 relative" id="hero-content">
|
||||
<HomeHeroPixi />
|
||||
<HeroFlame />
|
||||
<BackgroundOuterPiece />
|
||||
<HomeHeroBackground />
|
||||
|
||||
<div className="relative container px-16">
|
||||
<HomeHeroBadge />
|
||||
<HomeHeroTitle />
|
||||
|
||||
{/* Hero Input */}
|
||||
<div className="mt-24">
|
||||
<HeroInput />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</HeaderProvider>
|
||||
);
|
||||
}
|
||||
+25
-4
@@ -1,11 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Inter, Roboto_Mono } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter"
|
||||
});
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
const robotoMono = Roboto_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-roboto-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Open Lovable",
|
||||
title: "Open Lovable v2",
|
||||
description: "Re-imagine any website in seconds with AI-powered website builder.",
|
||||
};
|
||||
|
||||
@@ -16,7 +37,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<body className={`${inter.variable} ${geistSans.variable} ${geistMono.variable} ${robotoMono.variable} font-sans`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { appConfig } from '@/config/app.config';
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Import shared components
|
||||
import { Connector } from "@/components/shared/layout/curvy-rect";
|
||||
import HeroFlame from "@/components/shared/effects/flame/hero-flame";
|
||||
import AsciiExplosion from "@/components/shared/effects/flame/ascii-explosion";
|
||||
import { HeaderProvider } from "@/components/shared/header/HeaderContext";
|
||||
|
||||
// Import hero section components
|
||||
import HomeHeroBackground from "@/components/app/(home)/sections/hero/Background/Background";
|
||||
import { BackgroundOuterPiece } from "@/components/app/(home)/sections/hero/Background/BackgroundOuterPiece";
|
||||
import HomeHeroBadge from "@/components/app/(home)/sections/hero/Badge/Badge";
|
||||
import HomeHeroPixi from "@/components/app/(home)/sections/hero/Pixi/Pixi";
|
||||
import HomeHeroTitle from "@/components/app/(home)/sections/hero/Title/Title";
|
||||
import HeroInputSubmitButton from "@/components/app/(home)/sections/hero-input/Button/Button";
|
||||
import Globe from "@/components/app/(home)/sections/hero-input/_svg/Globe";
|
||||
|
||||
// Import header components
|
||||
import HeaderBrandKit from "@/components/shared/header/BrandKit/BrandKit";
|
||||
import HeaderWrapper from "@/components/shared/header/Wrapper/Wrapper";
|
||||
import HeaderDropdownWrapper from "@/components/shared/header/Dropdown/Wrapper/Wrapper";
|
||||
import GithubIcon from "@/components/shared/header/Github/_svg/GithubIcon";
|
||||
import ButtonUI from "@/components/ui/shadcn/button"
|
||||
|
||||
export default function HomePage() {
|
||||
const [url, setUrl] = useState<string>("");
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>("modern");
|
||||
const [selectedModel, setSelectedModel] = useState<string>(appConfig.ai.defaultModel);
|
||||
const router = useRouter();
|
||||
|
||||
const styles = [
|
||||
{ id: "modern", name: "Modern", description: "Clean and minimalist" },
|
||||
{ id: "playful", name: "Playful", description: "Fun and colorful" },
|
||||
{ id: "professional", name: "Professional", description: "Corporate and sleek" },
|
||||
{ id: "artistic", name: "Artistic", description: "Creative and unique" },
|
||||
];
|
||||
|
||||
const models = appConfig.ai.availableModels.map(model => ({
|
||||
id: model,
|
||||
name: model.includes('claude') ? `Claude ${model.split('-')[2]}` :
|
||||
model.includes('gpt') ? `GPT-${model.split('-')[1]}` : model,
|
||||
}));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!url.trim()) {
|
||||
toast.error("Please enter a URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the configuration in sessionStorage
|
||||
sessionStorage.setItem('targetUrl', url);
|
||||
sessionStorage.setItem('selectedStyle', selectedStyle);
|
||||
sessionStorage.setItem('selectedModel', selectedModel);
|
||||
|
||||
// Redirect to the generation interface
|
||||
router.push('/generation');
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderProvider>
|
||||
<div className="min-h-screen bg-background-base">
|
||||
{/* Header/Navigation Section */}
|
||||
<HeaderDropdownWrapper />
|
||||
|
||||
<div className="sticky top-0 left-0 w-full z-[101] bg-background-base header">
|
||||
<div className="absolute top-0 cmw-container border-x border-border-faint h-full pointer-events-none" />
|
||||
<div className="h-1 bg-border-faint w-full left-0 -bottom-1 absolute" />
|
||||
<div className="cmw-container absolute h-full pointer-events-none top-0">
|
||||
<Connector className="absolute -left-[10.5px] -bottom-11" />
|
||||
<Connector className="absolute -right-[10.5px] -bottom-11" />
|
||||
</div>
|
||||
|
||||
<HeaderWrapper>
|
||||
<div className="max-w-[900px] mx-auto w-full flex justify-between items-center">
|
||||
<div className="flex gap-24 items-center">
|
||||
<HeaderBrandKit />
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<a
|
||||
className="contents"
|
||||
href="https://github.com/mendableai/open-lovable"
|
||||
target="_blank"
|
||||
>
|
||||
<ButtonUI variant="tertiary">
|
||||
<GithubIcon />
|
||||
Use this Template
|
||||
</ButtonUI>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderWrapper>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="overflow-x-clip" id="home-hero">
|
||||
<div className="pt-28 lg:pt-254 lg:-mt-100 pb-115 relative" id="hero-content">
|
||||
<HomeHeroPixi />
|
||||
<HeroFlame />
|
||||
<BackgroundOuterPiece />
|
||||
<HomeHeroBackground />
|
||||
|
||||
<div className="relative container px-16">
|
||||
<HomeHeroBadge />
|
||||
<HomeHeroTitle />
|
||||
<p className="text-center text-body-large">
|
||||
Re-imagine any website, in seconds.
|
||||
</p>
|
||||
<Link
|
||||
className="bg-black-alpha-4 hover:bg-black-alpha-6 rounded-6 px-8 lg:px-6 text-label-large h-30 lg:h-24 block mt-8 mx-auto w-max gap-4 transition-all"
|
||||
href="#"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
Powered by Firecrawl.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini Playground Input */}
|
||||
<div className="container lg:contents !p-16 relative -mt-90">
|
||||
<div className="absolute top-0 left-[calc(50%-50vw)] w-screen h-1 bg-border-faint lg:hidden" />
|
||||
<div className="absolute bottom-0 left-[calc(50%-50vw)] w-screen h-1 bg-border-faint lg:hidden" />
|
||||
<Connector className="-top-10 -left-[10.5px] lg:hidden" />
|
||||
<Connector className="-top-10 -right-[10.5px] lg:hidden" />
|
||||
<Connector className="-bottom-10 -left-[10.5px] lg:hidden" />
|
||||
<Connector className="-bottom-10 -right-[10.5px] lg:hidden" />
|
||||
|
||||
{/* Hero Input Component */}
|
||||
<div className="max-w-552 mx-auto w-full z-[11] lg:z-[2]">
|
||||
<div className="rounded-20 -mt-30 lg:-mt-30">
|
||||
<div
|
||||
className="overlay bg-accent-white rounded-20"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 0px 44px 0px rgba(0, 0, 0, 0.02), 0px 88px 56px -20px rgba(0, 0, 0, 0.03), 0px 56px 56px -20px rgba(0, 0, 0, 0.02), 0px 32px 32px -20px rgba(0, 0, 0, 0.03), 0px 16px 24px -12px rgba(0, 0, 0, 0.03), 0px 0px 0px 1px rgba(0, 0, 0, 0.05), 0px 0px 0px 10px #F9F9F9",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="p-16 flex gap-12 items-center w-full relative">
|
||||
<Globe />
|
||||
<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"
|
||||
placeholder="example.com"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<HeroInputSubmitButton dirty={url.length > 0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options Section */}
|
||||
{url.length > 0 && (
|
||||
<div className="px-16 pb-16">
|
||||
{/* Model Selector */}
|
||||
<div className="mb-12">
|
||||
<label className="text-label-small text-black-alpha-56 mb-6 block">AI Model</label>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={`
|
||||
p-8 rounded-8 border transition-all text-left
|
||||
${selectedModel === model.id
|
||||
? 'border-heat-100 bg-heat-4'
|
||||
: 'border-border-faint hover:border-black-alpha-24 bg-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-label-small font-medium text-accent-black">
|
||||
{model.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style Selector */}
|
||||
<div>
|
||||
<label className="text-label-small text-black-alpha-56 mb-6 block">Style</label>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{styles.map((style) => (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => setSelectedStyle(style.id)}
|
||||
className={`
|
||||
p-12 rounded-10 border transition-all text-left
|
||||
${selectedStyle === style.id
|
||||
? 'border-heat-100 bg-heat-4'
|
||||
: 'border-border-faint hover:border-black-alpha-24 bg-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="text-label-medium font-medium text-accent-black">
|
||||
{style.name}
|
||||
</div>
|
||||
<div className="text-caption text-black-alpha-56 mt-2">
|
||||
{style.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-248 top-84 cw-768 pointer-events-none absolute overflow-clip -z-10">
|
||||
<AsciiExplosion className="-top-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</HeaderProvider>
|
||||
);
|
||||
}
|
||||
+3659
File diff suppressed because it is too large
Load Diff
+216
-3400
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user