| import React, { useEffect, useMemo, useRef, useState } from "react"; |
| import { Editor } from "@monaco-editor/react"; |
| import { motion } from "framer-motion"; |
| import { Download, Wand2, GitBranch, Eye, Layers, Loader2, Bug } from "lucide-react"; |
| import JSZip from "jszip"; |
| |
| import saveAs from "file-saver"; |
|
|
| |
| import { Button } from "@/components/ui/button"; |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; |
| import { Textarea } from "@/components/ui/textarea"; |
| import { Switch } from "@/components/ui/switch"; |
| import { Label } from "@/components/ui/label"; |
|
|
| |
| |
|
|
| const DEFAULT_HTML = `<!doctype html> |
| <html> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>My App</title> |
| <link rel="stylesheet" href="style.css" /> |
| </head> |
| <body> |
| <div id="app"></div> |
| <script src="script.js"></script> |
| </body> |
| </html>`; |
|
|
| const DEFAULT_CSS = `:root{--fg:#111827;--bg:#ffffff;--brand:#6366f1} |
| *{box-sizing:border-box} body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,\n Cantarell,Noto Sans,sans-serif;margin:0;background:var(--bg);color:var(--fg)} |
| .container{max-width:960px;margin:40px auto;padding:24px} |
| .button{background:var(--brand);color:#fff;border:none;border-radius:12px;padding:12px 16px;cursor:pointer} |
| .card{border:1px solid #e5e7eb;border-radius:20px;padding:20px;box-shadow:0 10px 30px rgba(0,0,0,.05)} |
| `; |
|
|
| const DEFAULT_JS = `const el = document.getElementById('app'); |
| el.innerHTML = \` |
| <div class="container"> |
| <div class="card"> |
| <h1>✨ Hello from your Lovable‑style sandbox</h1> |
| <p>Edit <code>index.html</code>, <code>style.css</code>, or <code>script.js</code> and see changes live.</p> |
| <button class="button" id="btn">Click me</button> |
| </div> |
| </div> |
| \`; |
| |
| document.getElementById('btn').addEventListener('click', ()=>{ |
| alert('It\\'s working!'); |
| }); |
| `; |
|
|
| |
| function composeHtml(html, css, js) { |
| const withCss = html.replace("</head>", `<style>${css}</style></head>`); |
| const withJs = withCss.replace("</body>", `<script type="module">\n${js}\n</script></body>`); |
| return withJs; |
| } |
|
|
| function useBlobPreview({ html, css, js }) { |
| const urlRef = useRef(null); |
| const srcDoc = useMemo(() => composeHtml(html, css, js), [html, css, js]); |
|
|
| useEffect(() => { |
| if (urlRef.current) URL.revokeObjectURL(urlRef.current); |
| const blob = new Blob([srcDoc], { type: 'text/html' }); |
| const url = URL.createObjectURL(blob); |
| urlRef.current = url; |
| return () => { if (urlRef.current) URL.revokeObjectURL(urlRef.current); }; |
| }, [srcDoc]); |
|
|
| return urlRef.current; |
| } |
|
|
| |
| |
| async function mockGenerateFromPrompt(prompt) { |
| await new Promise(r => setTimeout(r, 700)); |
| const p = prompt.toLowerCase(); |
| const wantsAuth = /auth|login|signup|sign up|account/.test(p); |
| const wantsTodo = /todo|task|kanban|list/.test(p); |
| const wantsChat = /chat|message|support|assistant/.test(p); |
|
|
| const html = `<!doctype html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(prompt)}</title><link rel="stylesheet" href="style.css"/></head><body><div class="container"><div class="card"><h1>${escapeHtml(prompt)}</h1><div id="root"></div></div></div><script src="script.js"></script></body></html>`; |
|
|
| let css = DEFAULT_CSS + (wantsKanban(p) ? `\n.board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}` : ''); |
|
|
| let js = `const root = document.getElementById('root');\n`; |
| if (wantsTodo) { |
| js += `let items = [];\nconst ui = () => {\n root.innerHTML = \`<div style="display:flex;gap:8px;margin-bottom:12px"><input id="in" placeholder="Add task"/><button class="button" id="add">Add</button></div><ul>\${items.map((t,i)=>\`<li>\${t} <button data-i=\${i} class=button style="padding:4px 8px;border-radius:8px">x</button></li>\`).join('')}</ul>\`;\n document.getElementById('add').onclick = ()=>{ const v = (document.getElementById('in')).value.trim(); if(v){ items.push(v); ui(); } };\n document.querySelectorAll('[data-i]').forEach(b=> b.onclick = ()=>{ items.splice(+b.dataset.i,1); ui(); });\n}; ui();\n`; |
| } else if (wantsChat) { |
| js += `let messages = [{role:'system',content:'Welcome!'}];\nconst ui = () => {\n root.innerHTML = \`<div class=card><div id=log style="min-height:160px">\${messages.map(m=>\`<p><strong>\${m.role}:</strong> \${m.content}</p>\`).join('')}</div><div style="display:flex;gap:8px;margin-top:12px"><input id=msg placeholder="Type..."/><button id=send class=button>Send</button></div></div>\`;\n document.getElementById('send').onclick = ()=>{ const v=(document.getElementById('msg')).value.trim(); if(!v) return; messages.push({role:'user',content:v}); messages.push({role:'assistant',content:'(demo reply) '+v}); ui(); };\n}; ui();\n`; |
| } else { |
| js += DEFAULT_JS; |
| } |
|
|
| if (wantsAuth) { |
| js += `\n// Fake auth UI\nconst banner = document.createElement('div');\nbanner.className='card'; banner.style.marginTop='16px';\nbanner.innerHTML = '<h2>Auth (demo)</h2><p>Replace with real auth provider.</p>';\nroot.after(banner);\n`; |
| } |
|
|
| return { html, css, js }; |
| } |
|
|
| function wantsKanban(p){ return /kanban|board|columns/.test(p); } |
| function escapeHtml(s){ return s.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c])); } |
|
|
| |
| function downloadBlob(blob, filename){ |
| try { |
| if (typeof saveAs === 'function') return saveAs(blob, filename); |
| } catch (_) {} |
| const a = document.createElement('a'); |
| const url = URL.createObjectURL(blob); |
| a.href = url; a.download = filename; a.style.display = 'none'; |
| document.body.appendChild(a); a.click(); |
| setTimeout(()=>{ document.body.removeChild(a); URL.revokeObjectURL(url); }, 0); |
| } |
|
|
| export default function App() { |
| const [prompt, setPrompt] = useState("Build a todo app with auth and live preview"); |
| const [files, setFiles] = useState({ |
| html: DEFAULT_HTML, |
| css: DEFAULT_CSS, |
| js: DEFAULT_JS, |
| }); |
| const [active, setActive] = useState("html"); |
| const [autoRun, setAutoRun] = useState(true); |
| const [isGenerating, setIsGenerating] = useState(false); |
| const [testResults, setTestResults] = useState([]); |
| const iframeUrl = useBlobPreview({ html: files.html, css: files.css, js: files.js }); |
|
|
| |
| useEffect(() => { if (!autoRun) {} }, [files, autoRun]); |
|
|
| const updateFile = (key, value) => setFiles(prev => ({ ...prev, [key]: value })); |
|
|
| const onGenerate = async () => { |
| setIsGenerating(true); |
| try { |
| const out = await mockGenerateFromPrompt(prompt); |
| setFiles({ html: out.html, css: out.css, js: out.js }); |
| setActive("html"); |
| } finally { |
| setIsGenerating(false); |
| } |
| }; |
|
|
| const onDownloadZip = async () => { |
| const zip = new JSZip(); |
| zip.file("index.html", files.html); |
| zip.file("style.css", files.css); |
| zip.file("script.js", files.js); |
| const blob = await zip.generateAsync({ type: "blob" }); |
| downloadBlob(blob, "lovable-clone-mvp.zip"); |
| }; |
|
|
| |
| const runTests = async () => { |
| const results = []; |
|
|
| const assert = (name, pass, details = "") => results.push({ name, pass, details }); |
|
|
| |
| assert("file-saver default import is a function", typeof saveAs === 'function'); |
|
|
| |
| const testDoc = composeHtml("<html><head></head><body></body></html>", "body{margin:0}", "console.log('ok')"); |
| assert("composeHtml injects <style> before </head>", /<style>[\s\S]*<\/style><\/head>/.test(testDoc)); |
| assert("composeHtml injects <script type=module> before </body>", /<script type=\"module\">[\s\S]*<\/script><\/body>/.test(testDoc)); |
|
|
| |
| const z = new JSZip(); z.file("a.txt", "hello"); |
| const zb = await z.generateAsync({ type: 'blob' }); |
| assert("JSZip generates a Blob", zb instanceof Blob); |
| assert("Generated ZIP size > 0", zb.size > 0, `size=${zb.size}`); |
|
|
| |
| const t1 = await mockGenerateFromPrompt("todo app"); |
| assert("Generator returns HTML", typeof t1.html === 'string' && t1.html.includes('<!doctype html>')); |
| const t2 = await mockGenerateFromPrompt("simple chat app"); |
| assert("Chat template contains demo reply hook", /\(demo reply\)/.test(t2.js)); |
| const t3 = await mockGenerateFromPrompt("include auth"); |
| assert("Auth banner injected", /Auth \(demo\)/.test(t3.js)); |
|
|
| setTestResults(results); |
| }; |
|
|
| return ( |
| <div className="min-h-screen bg-white"> |
| <div className="mx-auto max-w-7xl p-4 sm:p-6"> |
| <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}> |
| <header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> |
| <h1 className="text-2xl sm:text-3xl font-semibold tracking-tight">💜 Lovable‑style Builder (MVP)</h1> |
| <div className="flex items-center gap-3"> |
| <Button variant="outline" onClick={onDownloadZip}><Download className="mr-2 h-4 w-4"/>Export ZIP</Button> |
| <Button variant="default" onClick={onGenerate} disabled={isGenerating}> |
| {isGenerating ? <Loader2 className="mr-2 h-4 w-4 animate-spin"/> : <Wand2 className="mr-2 h-4 w-4"/>} |
| Generate |
| </Button> |
| </div> |
| </header> |
| </motion.div> |
| |
| <div className="mt-6 grid grid-cols-1 lg:grid-cols-5 gap-4"> |
| <Card className="lg:col-span-2"> |
| <CardHeader> |
| <CardTitle className="text-lg">Describe your app</CardTitle> |
| </CardHeader> |
| <CardContent className="space-y-3"> |
| <Textarea value={prompt} onChange={(e)=>setPrompt(e.target.value)} placeholder="e.g. Build a CRM dashboard with login, kanban board and chat" className="min-h-[120px]"/> |
| <div className="flex items-center gap-3"> |
| <Button onClick={onGenerate} disabled={isGenerating}> |
| {isGenerating ? <Loader2 className="mr-2 h-4 w-4 animate-spin"/> : <Wand2 className="mr-2 h-4 w-4"/>} |
| Generate App |
| </Button> |
| <div className="flex items-center gap-2 ml-auto"> |
| <Switch id="autorun" checked={autoRun} onCheckedChange={setAutoRun}/> |
| <Label htmlFor="autorun">Live preview</Label> |
| </div> |
| </div> |
| </CardContent> |
| </Card> |
| |
| <Card className="lg:col-span-3"> |
| <CardHeader> |
| <CardTitle className="text-lg flex items-center gap-2"><Layers className="h-5 w-5"/> Code Editor</CardTitle> |
| </CardHeader> |
| <CardContent> |
| <Tabs value={active} onValueChange={setActive} className="w-full"> |
| <TabsList className="grid w-full grid-cols-3"> |
| <TabsTrigger value="html">index.html</TabsTrigger> |
| <TabsTrigger value="css">style.css</TabsTrigger> |
| <TabsTrigger value="js">script.js</TabsTrigger> |
| </TabsList> |
| <TabsContent value="html"> |
| <Monaco filename="index.html" language="html" value={files.html} onChange={(v)=>updateFile('html', v ?? '')} /> |
| </TabsContent> |
| <TabsContent value="css"> |
| <Monaco filename="style.css" language="css" value={files.css} onChange={(v)=>updateFile('css', v ?? '')} /> |
| </TabsContent> |
| <TabsContent value="js"> |
| <Monaco filename="script.js" language="javascript" value={files.js} onChange={(v)=>updateFile('js', v ?? '')} /> |
| </TabsContent> |
| </Tabs> |
| </CardContent> |
| </Card> |
| </div> |
| |
| <div className="mt-4 grid grid-cols-1 lg:grid-cols-5 gap-4"> |
| <Card className="lg:col-span-2 order-2 lg:order-1"> |
| <CardHeader> |
| <CardTitle className="text-lg flex items-center gap-2"><GitBranch className="h-5 w-5"/> Next steps</CardTitle> |
| </CardHeader> |
| <CardContent className="space-y-3 text-sm"> |
| <ul className="list-disc ml-5 space-y-2"> |
| <li><strong>AI backend:</strong> Replace <code>mockGenerateFromPrompt</code> with an API route that calls your provider.</li> |
| <li><strong>Projects & auth:</strong> Persist files per user (Postgres/Supabase). Add OAuth with NextAuth.</li> |
| <li><strong>Realtime collaboration:</strong> Add <code>yjs</code> + <code>y-websocket</code> or Supabase Realtime to sync files across sessions.</li> |
| <li><strong>Deploy:</strong> Build adapters for Vercel/Netlify. Ship <code>vercel.json</code> on export.</li> |
| <li><strong>Billing:</strong> Stripe subscriptions; gate AI calls and deployments by plan.</li> |
| </ul> |
| </CardContent> |
| </Card> |
| |
| <Card className="lg:col-span-3 order-1 lg:order-2"> |
| <CardHeader> |
| <CardTitle className="text-lg flex items-center gap-2"><Eye className="h-5 w-5"/> Live Preview</CardTitle> |
| </CardHeader> |
| <CardContent> |
| <div className="rounded-2xl overflow-hidden border"> |
| {iframeUrl ? ( |
| <iframe title="preview" src={iframeUrl} className="w-full h-[540px]" /> |
| ) : ( |
| <div className="p-6 text-sm text-gray-500">Preparing preview…</div> |
| )} |
| </div> |
| </CardContent> |
| </Card> |
| </div> |
| |
| {/* Self-test panel */} |
| <div className="mt-4"> |
| <Card> |
| <CardHeader> |
| <CardTitle className="text-lg flex items-center gap-2"><Bug className="h-5 w-5"/> Self‑tests</CardTitle> |
| </CardHeader> |
| <CardContent> |
| <div className="flex items-center gap-3 mb-3"> |
| <Button variant="outline" onClick={runTests}>Run tests</Button> |
| </div> |
| {testResults.length > 0 && ( |
| <ul className="space-y-1 text-sm"> |
| {testResults.map((t, i) => ( |
| <li key={i} className={t.pass ? "text-green-700" : "text-red-700"}> |
| {t.pass ? "✓" : "✗"} {t.name}{t.details ? ` — ${t.details}` : ""} |
| </li> |
| ))} |
| </ul> |
| )} |
| </CardContent> |
| </Card> |
| </div> |
| |
| </div> |
| </div> |
| ); |
| } |
|
|
| function Monaco({ filename, language, value, onChange }){ |
| return ( |
| <div className="h-[420px] rounded-xl overflow-hidden border"> |
| <Editor |
| path={filename} |
| height="420px" |
| defaultLanguage={language} |
| language={language} |
| value={value} |
| onChange={onChange} |
| options={{ |
| fontSize: 14, |
| minimap: { enabled: false }, |
| roundedSelection: true, |
| scrollBeyondLastLine: false, |
| automaticLayout: true, |
| wordWrap: 'on', |
| tabSize: 2, |
| }} |
| /> |
| </div> |
| ); |
| } |
|
|