Spaces:
Running
Running
"use client"; | |
import CodeViewer from "@/components/code-viewer"; | |
import { useScrollTo } from "@/hooks/use-scroll-to"; | |
import { CheckIcon } from "@heroicons/react/16/solid"; | |
import { ArrowLongRightIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; | |
import { ArrowUpOnSquareIcon } from "@heroicons/react/24/outline"; | |
import * as Select from "@radix-ui/react-select"; | |
import * as Switch from "@radix-ui/react-switch"; | |
import { AnimatePresence, motion } from "framer-motion"; | |
import { FormEvent, useEffect, useState } from "react"; | |
import LoadingDots from "../../components/loading-dots"; | |
function removeCodeFormatting(code: string): string { | |
return code.replace(/```(?:typescript|javascript|tsx)?\n([\s\S]*?)```/g, '$1').trim(); | |
} | |
export default function Home() { | |
let [status, setStatus] = useState< | |
"initial" | "creating" | "created" | "updating" | "updated" | |
>("initial"); | |
let [prompt, setPrompt] = useState(""); | |
let models = [ | |
{ | |
label: "gemini-2.0-flash-exp", | |
value: "gemini-2.0-flash-exp", | |
}, | |
{ | |
label: "gemini-1.5-pro", | |
value: "gemini-1.5-pro", | |
}, | |
{ | |
label: "gemini-1.5-flash", | |
value: "gemini-1.5-flash", | |
} | |
]; | |
let [model, setModel] = useState(models[0].value); | |
let [shadcn, setShadcn] = useState(false); | |
let [modification, setModification] = useState(""); | |
let [generatedCode, setGeneratedCode] = useState(""); | |
let [initialAppConfig, setInitialAppConfig] = useState({ | |
model: "", | |
shadcn: true, | |
}); | |
let [ref, scrollTo] = useScrollTo(); | |
let [messages, setMessages] = useState<{ role: string; content: string }[]>( | |
[], | |
); | |
let loading = status === "creating" || status === "updating"; | |
async function createApp(e: FormEvent<HTMLFormElement>) { | |
e.preventDefault(); | |
if (status !== "initial") { | |
scrollTo({ delay: 0.5 }); | |
} | |
setStatus("creating"); | |
setGeneratedCode(""); | |
let res = await fetch("/api/generateCode", { | |
method: "POST", | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
body: JSON.stringify({ | |
model, | |
shadcn, | |
messages: [{ role: "user", content: prompt }], | |
}), | |
}); | |
if (!res.ok) { | |
throw new Error(res.statusText); | |
} | |
if (!res.body) { | |
throw new Error("No response body"); | |
} | |
const reader = res.body.getReader(); | |
let receivedData = ""; | |
while (true) { | |
const { done, value } = await reader.read(); | |
if (done) { | |
break; | |
} | |
receivedData += new TextDecoder().decode(value); | |
const cleanedData = removeCodeFormatting(receivedData); | |
setGeneratedCode(cleanedData); | |
} | |
setMessages([{ role: "user", content: prompt }]); | |
setInitialAppConfig({ model, shadcn }); | |
setStatus("created"); | |
} | |
useEffect(() => { | |
let el = document.querySelector(".cm-scroller"); | |
if (el && loading) { | |
let end = el.scrollHeight - el.clientHeight; | |
el.scrollTo({ top: end }); | |
} | |
}, [loading, generatedCode]); | |
return ( | |
<main className="mt-12 flex w-full flex-1 flex-col items-center px-4 text-center sm:mt-1"> | |
<a | |
className="mb-4 inline-flex h-7 shrink-0 items-center gap-[9px] rounded-[50px] border-[0.5px] border-solid border-[#E6E6E6] bg-[rgba(234,238,255,0.65)] bg-gray-100 px-7 py-5 shadow-[0px_1px_1px_0px_rgba(0,0,0,0.25)]" | |
href="https://ai.google.dev/gemini-api/docs" | |
target="_blank" | |
> | |
<span className="text-center"> | |
Powered by <span className="font-medium">Gemini API</span> | |
</span> | |
</a> | |
<h1 className="my-6 max-w-3xl text-4xl font-bold text-gray-800 sm:text-6xl"> | |
Turn your <span className="text-blue-600">idea</span> | |
<br /> into an <span className="text-blue-600">app</span> | |
</h1> | |
<form className="w-full max-w-xl" onSubmit={createApp}> | |
<fieldset disabled={loading} className="disabled:opacity-75"> | |
<div className="relative mt-5"> | |
<div className="absolute -inset-2 rounded-[32px] bg-gray-300/50" /> | |
<div className="relative flex rounded-3xl bg-white shadow-sm"> | |
<div className="relative flex flex-grow items-stretch focus-within:z-10"> | |
<textarea | |
rows={3} | |
required | |
value={prompt} | |
onChange={(e) => setPrompt(e.target.value)} | |
name="prompt" | |
className="w-full resize-none rounded-l-3xl bg-transparent px-6 py-5 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500" | |
placeholder="Build me a calculator app..." | |
/> | |
</div> | |
<button | |
type="submit" | |
disabled={loading} | |
className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-3xl px-3 py-2 text-sm font-semibold text-blue-500 hover:text-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 disabled:text-gray-900" | |
> | |
{status === "creating" ? ( | |
<LoadingDots color="black" style="large" /> | |
) : ( | |
<ArrowLongRightIcon className="-ml-0.5 size-6" /> | |
)} | |
</button> | |
</div> | |
</div> | |
<div className="mt-6 flex flex-col justify-center gap-4 sm:flex-row sm:items-center sm:gap-8"> | |
<div className="flex items-center justify-between gap-3 sm:justify-center"> | |
<p className="text-gray-500 sm:text-xs">Model:</p> | |
<Select.Root | |
name="model" | |
disabled={loading} | |
value={model} | |
onValueChange={(value) => setModel(value)} | |
> | |
<Select.Trigger className="group flex w-60 max-w-xs items-center rounded-2xl border-[6px] border-gray-300 bg-white px-4 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"> | |
<Select.Value /> | |
<Select.Icon className="ml-auto"> | |
<ChevronDownIcon className="size-6 text-gray-300 group-focus-visible:text-gray-500 group-enabled:group-hover:text-gray-500" /> | |
</Select.Icon> | |
</Select.Trigger> | |
<Select.Portal> | |
<Select.Content className="overflow-hidden rounded-md bg-white shadow-lg"> | |
<Select.Viewport className="p-2"> | |
{models.map((model) => ( | |
<Select.Item | |
key={model.value} | |
value={model.value} | |
className="flex cursor-pointer items-center rounded-md px-3 py-2 text-sm data-[highlighted]:bg-gray-100 data-[highlighted]:outline-none" | |
> | |
<Select.ItemText asChild> | |
<span className="inline-flex items-center gap-2 text-gray-500"> | |
<div className="size-2 rounded-full bg-green-500" /> | |
{model.label} | |
</span> | |
</Select.ItemText> | |
<Select.ItemIndicator className="ml-auto"> | |
<CheckIcon className="size-5 text-blue-600" /> | |
</Select.ItemIndicator> | |
</Select.Item> | |
))} | |
</Select.Viewport> | |
<Select.ScrollDownButton /> | |
<Select.Arrow /> | |
</Select.Content> | |
</Select.Portal> | |
</Select.Root> | |
</div> | |
<div className="flex h-full items-center justify-between gap-3 sm:justify-center"> | |
<label className="text-gray-500 sm:text-xs" htmlFor="shadcn"> | |
shadcn/ui: | |
</label> | |
<Switch.Root | |
className="group flex w-20 max-w-xs items-center rounded-2xl border-[6px] border-gray-300 bg-white p-1.5 text-sm shadow-inner transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500 data-[state=checked]:bg-blue-500" | |
id="shadcn" | |
name="shadcn" | |
checked={shadcn} | |
onCheckedChange={(value) => setShadcn(value)} | |
> | |
<Switch.Thumb className="size-7 rounded-lg bg-gray-200 shadow-[0_1px_2px] shadow-gray-400 transition data-[state=checked]:translate-x-7 data-[state=checked]:bg-white data-[state=checked]:shadow-gray-600" /> | |
</Switch.Root> | |
</div> | |
</div> | |
</fieldset> | |
</form> | |
<hr className="border-1 mb-20 h-px bg-gray-700 dark:bg-gray-700" /> | |
{status !== "initial" && ( | |
<motion.div | |
initial={{ height: 0 }} | |
animate={{ | |
height: "auto", | |
overflow: "hidden", | |
transitionEnd: { overflow: "visible" }, | |
}} | |
transition={{ type: "spring", bounce: 0, duration: 0.5 }} | |
className="w-full pb-[25vh] pt-1" | |
onAnimationComplete={() => scrollTo()} | |
ref={ref} | |
> | |
<div className="relative mt-8 w-full overflow-hidden"> | |
<div className="isolate"> | |
<CodeViewer code={generatedCode} showEditor /> | |
</div> | |
<AnimatePresence> | |
{loading && ( | |
<motion.div | |
initial={status === "updating" ? { x: "100%" } : undefined} | |
animate={status === "updating" ? { x: "0%" } : undefined} | |
exit={{ x: "100%" }} | |
transition={{ | |
type: "spring", | |
bounce: 0, | |
duration: 0.85, | |
delay: 0.5, | |
}} | |
className="absolute inset-x-0 bottom-0 top-1/2 flex items-center justify-center rounded-r border border-gray-400 bg-gradient-to-br from-gray-100 to-gray-300 md:inset-y-0 md:left-1/2 md:right-0" | |
> | |
<p className="animate-pulse text-3xl font-bold"> | |
{status === "creating" | |
? "Building your app..." | |
: "Updating your app..."} | |
</p> | |
</motion.div> | |
)} | |
</AnimatePresence> | |
</div> | |
</motion.div> | |
)} | |
</main> | |
); | |
} | |
async function minDelay<T>(promise: Promise<T>, ms: number) { | |
let delay = new Promise((resolve) => setTimeout(resolve, ms)); | |
let [p] = await Promise.all([promise, delay]); | |
return p; | |
} | |