Text-to-Audio
AllenNLP
Safetensors
code
agent
loom / yiib_6_i_9.jsx.js
YIIB's picture
Upload 14 files
9168ade verified
raw
history blame
16 kB
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";
// NOTE: Use default import for file-saver to avoid ESM named-export errors in some bundlers/CDNs
import saveAs from "file-saver";
// --- shadcn/ui ---
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";
// --- Minimal in-browser sandbox that behaves like a tiny Lovable preview ---
// We keep a virtual FS with three files and render them into an iframe using a Blob URL.
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!');
});
`;
// Compose an HTML document with inline <style> and <script type="module"> blocks
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;
}
// --- Heuristic AI scaffolder (offline) ---
// In production, replace with your AI API call; for now we synthesize boilerplates deterministically from the prompt.
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[c])); }
// Fallback downloader if file-saver default import is unavailable in runtime
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 });
// Autorun toggle is intentionally passive: preview refreshes whenever files change
useEffect(() => { if (!autoRun) {/* reserved for future pause logic */} }, [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");
};
// --- Self tests ---
const runTests = async () => {
const results = [];
const assert = (name, pass, details = "") => results.push({ name, pass, details });
// Test 1: saveAs existence (without invoking downloads)
assert("file-saver default import is a function", typeof saveAs === 'function');
// Test 2: composeHtml injects style and module script
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));
// Test 3: JSZip produces non-empty blob
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}`);
// Test 4: mock generator variations
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>
);
}