From 37dba202fd633950237cf8ad67f22c3115ed271d Mon Sep 17 00:00:00 2001 From: Mihovil Ilakovac Date: Wed, 21 Jun 2023 12:37:02 +0200 Subject: [PATCH] New design --- wasp-ai/main.wasp | 5 + wasp-ai/src/client/Main.css | 7 +- wasp-ai/src/client/RootComponent.jsx | 13 +- wasp-ai/src/client/components/StatusPill.jsx | 19 ++ wasp-ai/src/client/components/Title.jsx | 22 ++ wasp-ai/src/client/pages/MainPage.jsx | 274 +++++-------------- wasp-ai/src/client/pages/ResultPage.jsx | 259 ++++++++++++++++++ 7 files changed, 389 insertions(+), 210 deletions(-) create mode 100644 wasp-ai/src/client/components/StatusPill.jsx create mode 100644 wasp-ai/src/client/components/Title.jsx create mode 100644 wasp-ai/src/client/pages/ResultPage.jsx diff --git a/wasp-ai/main.wasp b/wasp-ai/main.wasp index 32339c844..b35a96850 100644 --- a/wasp-ai/main.wasp +++ b/wasp-ai/main.wasp @@ -20,6 +20,11 @@ page MainPage { component: import Main from "@client/pages/MainPage.jsx" } +route ResultRoute { path: "/result/:appId", to: ResultPage } +page ResultPage { + component: import { ResultPage } from "@client/pages/ResultPage.jsx" +} + action startGeneratingNewApp { fn: import { startGeneratingNewApp } from "@server/operations.js" } diff --git a/wasp-ai/src/client/Main.css b/wasp-ai/src/client/Main.css index 600cd8060..1c4f7d0de 100644 --- a/wasp-ai/src/client/Main.css +++ b/wasp-ai/src/client/Main.css @@ -3,15 +3,18 @@ @tailwind utilities; .button { - @apply bg-yellow-400 text-yellow-900 font-bold py-2 px-4 rounded; + @apply bg-sky-600 text-white font-bold py-3 px-6 rounded text-center; } .button.gray { @apply bg-slate-200 text-slate-600; } +.button.yellow { + @apply bg-yellow-400 text-yellow-900 +} .button:disabled { @apply opacity-50 cursor-not-allowed; } input, textarea { - @apply border border-gray-300 rounded py-2 px-4 bg-slate-50; + @apply border border-gray-300 rounded p-3 bg-white; } diff --git a/wasp-ai/src/client/RootComponent.jsx b/wasp-ai/src/client/RootComponent.jsx index 4e0298413..6a60b2c7c 100644 --- a/wasp-ai/src/client/RootComponent.jsx +++ b/wasp-ai/src/client/RootComponent.jsx @@ -1,5 +1,5 @@ import Prism from "prismjs"; -import 'prismjs/components/prism-json'; +import "prismjs/components/prism-json"; import addWaspLangauge from "./prism/wasp"; import addPrismaLanguage from "./prism/prisma"; @@ -12,6 +12,17 @@ export function RootComponent({ children }) { return (
{children} +
); } diff --git a/wasp-ai/src/client/components/StatusPill.jsx b/wasp-ai/src/client/components/StatusPill.jsx new file mode 100644 index 000000000..9f5663f7c --- /dev/null +++ b/wasp-ai/src/client/components/StatusPill.jsx @@ -0,0 +1,19 @@ +export function StatusPill({ children, status }) { + const statusToClassName = { + idle: "bg-gray-100 border-gray-300 text-gray-700", + inProgress: "bg-sky-100 border-sky-300 text-sky-700", + success: "bg-green-100 border-green-300 text-green-700", + error: "bg-red-100 border-red-300 text-red-700", + warning: "bg-yellow-100 border-yellow-300 text-yellow-700", + }; + return ( +
+ + + {children} + +
+ ); +} diff --git a/wasp-ai/src/client/components/Title.jsx b/wasp-ai/src/client/components/Title.jsx new file mode 100644 index 000000000..d7298c75f --- /dev/null +++ b/wasp-ai/src/client/components/Title.jsx @@ -0,0 +1,22 @@ +import waspLogo from "../waspLogo.png"; +import { Link } from "react-router-dom"; + +export function Title() { + return ( +
+ wasp +

+ Wasp AI App Generator +
+ +
+

+
+ ); +} diff --git a/wasp-ai/src/client/pages/MainPage.jsx b/wasp-ai/src/client/pages/MainPage.jsx index b5d339be3..cfbfc0f04 100644 --- a/wasp-ai/src/client/pages/MainPage.jsx +++ b/wasp-ai/src/client/pages/MainPage.jsx @@ -1,131 +1,67 @@ -import waspLogo from "../waspLogo.png"; -import { useState, useMemo } from "react"; +import { useState } from "react"; import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp"; -import getAppGenerationResult from "@wasp/queries/getAppGenerationResult"; -import { useQuery } from "@wasp/queries"; -import { CodeHighlight } from "../components/CodeHighlight"; -import { FileTree } from "../components/FileTree"; -import { Loader } from "../components/Loader"; -import { createFilesAndDownloadZip } from "../zip/zipHelpers"; +import { StatusPill } from "../components/StatusPill"; +import { useHistory } from "react-router-dom"; +import { Title } from "../components/Title"; const MainPage = () => { const [appName, setAppName] = useState(""); const [appDesc, setAppDesc] = useState(""); - const [appId, setAppId] = useState(""); - const [generationDone, setGenerationDone] = useState(false); - const { data: appGenerationResult } = useQuery( - getAppGenerationResult, - { appId }, - { enabled: !!appId && !generationDone, refetchInterval: 3000 } - ); - const [activeFilePath, setActiveFilePath] = useState(null); - - if ( - appGenerationResult?.status === "success" || - appGenerationResult?.status === "failure" - ) { - if (!generationDone) { - setGenerationDone(true); - } - } - - const logs = appGenerationResult?.messages - .filter((m) => m.type === "log") - .map((m) => m.text) - .reverse(); - - let files = {}; - { - appGenerationResult?.messages - .filter((m) => m.type === "write-file") - .map((m) => m.text.split("\n")) - .forEach(([path, ...contentLines]) => { - files[path] = contentLines.join("\n"); - }); - } - - function fillInExampleAppDetails() { - setAppName("TodoApp"); - setAppDesc( - "A simple todo app with one main page that lists all the tasks. I can create new tasks, or toggle existing ones." + - "User owns tasks. User can only see and edit their own tasks. Tasks are saved in the database." - ); - } + const [currentStatus, setCurrentStatus] = useState({ + status: "idle", + message: "Waiting for instructions", + }); + const history = useHistory(); async function startGenerating(event) { event.preventDefault(); if (!(appName && appDesc)) { return alert("Please enter an app name and description."); } - setAppId(await startGeneratingNewApp({ appName, appDesc })); + setCurrentStatus({ + status: "inProgress", + message: "Booting up AI", + }); + const appId = await startGeneratingNewApp({ appName, appDesc }); + history.push(`/result/${appId}`); } - const language = useMemo(() => { - if (activeFilePath) { - const ext = activeFilePath.split(".").pop(); - if (["jsx", "tsx", "js", "ts"].includes(ext)) { - return "javascript"; - } else if (["wasp"].includes(ext)) { - return "wasp"; - } else { - return ext; - } - } - }, [activeFilePath]); + const exampleIdeas = [ + { + name: "TodoApp", + description: + "A simple todo app with one main page that lists all the tasks. I can create new tasks, or toggle existing ones." + + "User owns tasks. User can only see and edit their own tasks. Tasks are saved in the database.", + }, + { + name: "Blog", + description: + "A blog with posts and comments. Posts can be created, edited and deleted. Comments can be created and deleted. Posts and comments are saved in the database.", + }, + { + name: "Flower Shop", + description: + "A flower shop with a main page that lists all the flowers. I can create new flowers, or toggle existing ones." + + "User owns flowers. User can only see and edit their own flowers. Flowers are saved in the database.", + }, + ]; - const interestingFilePaths = useMemo(() => { - if (files) { - return Object.keys(files) - .filter( - (path) => - path !== ".env.server" && - path !== ".env.client" && - path !== "src/client/vite-env.d.ts" && - path !== "src/client/tsconfig.json" && - path !== "src/server/tsconfig.json" && - path !== "src/shared/tsconfig.json" && - path !== ".gitignore" && - path !== "src/.waspignore" && - path !== ".wasproot" - ) - .sort( - (a, b) => - (a.endsWith(".wasp") ? 0 : 1) - (b.endsWith(".wasp") ? 0 : 1) - ); - } else { - return []; - } - }, [files]); - - function downloadZip() { - const safeAppName = appName.replace(/[^a-zA-Z0-9]/g, "_"); - createFilesAndDownloadZip(files, safeAppName); + function useIdea(idea) { + setAppName(idea.name); + setAppDesc(idea.description); + window.scrollTo(0, 0); } return (
-
- wasp -

- Wasp AI App Generator -

+
+ + <StatusPill status={currentStatus.status}> + {currentStatus.message} + </StatusPill> </div> - <form onSubmit={startGenerating}> + <form onSubmit={startGenerating} className="bg-slate-50 p-8 rounded-xl"> <div className="mb-4 flex flex-col gap-2"> <input required @@ -133,7 +69,7 @@ const MainPage = () => { placeholder="Your app name" value={appName} onChange={(e) => setAppName(e.target.value)} - disabled={appId} + disabled={currentStatus.status !== "idle"} /> <textarea required @@ -142,115 +78,39 @@ const MainPage = () => { rows="5" cols="50" onChange={(e) => setAppDesc(e.target.value)} - disabled={appId} + disabled={currentStatus.status !== "idle"} /> </div> - <button className="button mr-2" disabled={appId}> - Generate - </button> <button - type="button" - disabled={appId} - onClick={() => fillInExampleAppDetails()} - className="button gray" + className="button mr-2" + disabled={currentStatus.status !== "idle"} > - Fill in with example app details + Engage the AI </button> </form> - - {interestingFilePaths.length > 0 && ( - <> - <header - className=" - mt-8 - mb-2 - flex - justify-between - items-center - " + <div className="mt-8"> + <h3 className="text-xl font-semibold mb-4">Some example ideas</h3> + {exampleIdeas.map((idea) => ( + <div + key={idea.name} + className="bg-slate-50 p-8 rounded-xl mt-2 flex items-center" > - <div - className=" - flex - items-center - - " - > - <h2 - className=" - text-xl - font-bold - text-gray-800 - mr-2 - " - > - {appName} - </h2> - {appId && !generationDone && <Loader />} + <div className="idea"> + <h4 className="text-lg font-semibold text-slate-700 mb-1"> + {idea.name} + </h4> + <p className="text-base leading-relaxed text-slate-500"> + {idea.description} + </p> </div> - <div> - <button - className="button" - disabled={!generationDone} - onClick={downloadZip} - > - Download ZIP + <div className="flex-shrink-0 ml-12"> + <button className="button gray" onClick={() => useIdea(idea)}> + Use this idea </button> </div> - </header> - <div className="grid gap-4 grid-cols-[300px_minmax(900px,_1fr)_100px]"> - <aside> - <FileTree - paths={interestingFilePaths} - activeFilePath={activeFilePath} - onActivePathSelect={setActiveFilePath} - /> - </aside> - - {activeFilePath && ( - <main className="flex flex-col gap-2"> - <div className="font-bold">{activeFilePath}:</div> - <div key={activeFilePath} className="py-4 bg-slate-100 rounded"> - <CodeHighlight language={language}> - {files[activeFilePath].trim()} - </CodeHighlight> - </div> - </main> - )} - {!activeFilePath && ( - <main className="p-8 bg-slate-100 rounded grid place-content-center"> - <div className="text-center"> - <div className="font-bold">Select a file to view</div> - <div className="text-gray-500 text-sm"> - (click on a file in the file tree) - </div> - </div> - </main> - )} </div> - - {logs && logs.length > 0 && ( - <div className="flex flex-col gap-1 mt-8"> - {logs.map((log, i) => ( - /* - If log contains "generated" or "Generated" - make it green, otherwise make it gray. - */ - <pre - key={i} - className={`p-3 rounded text-sm ${ - log.toLowerCase().includes("generated") - ? "bg-green-100" - : "bg-slate-100" - }`} - > - {log} - </pre> - ))} - </div> - )} - </> - )} + ))} + </div> </div> ); }; diff --git a/wasp-ai/src/client/pages/ResultPage.jsx b/wasp-ai/src/client/pages/ResultPage.jsx new file mode 100644 index 000000000..c5c8e6f29 --- /dev/null +++ b/wasp-ai/src/client/pages/ResultPage.jsx @@ -0,0 +1,259 @@ +import { useState, useEffect, useMemo } from "react"; +import getAppGenerationResult from "@wasp/queries/getAppGenerationResult"; +import { useQuery } from "@wasp/queries"; +import { CodeHighlight } from "../components/CodeHighlight"; +import { FileTree } from "../components/FileTree"; +import { createFilesAndDownloadZip } from "../zip/zipHelpers"; +import { StatusPill } from "../components/StatusPill"; +import { useParams } from "react-router-dom"; +import { Link } from "react-router-dom"; + +export const ResultPage = () => { + const { appId } = useParams(); + const [generationDone, setGenerationDone] = useState(false); + const { data: appGenerationResult } = useQuery( + getAppGenerationResult, + { appId }, + { enabled: !!appId && !generationDone, refetchInterval: 3000 } + ); + const [activeFilePath, setActiveFilePath] = useState(null); + const [currentStatus, setCurrentStatus] = useState({ + status: "idle", + message: "Waiting for instructions", + }); + const [logsVisible, setLogsVisible] = useState(false); + + useEffect(() => { + if ( + appGenerationResult?.status === "success" || + appGenerationResult?.status === "failure" + ) { + setGenerationDone(true); + setCurrentStatus({ + status: appGenerationResult.status === "success" ? "success" : "error", + message: + appGenerationResult.status === "success" + ? "Finished" + : "There was an error", + }); + } else { + setCurrentStatus({ + status: "inProgress", + message: "Generating app", + }); + } + }, [appGenerationResult]); + + const logs = appGenerationResult?.messages + .filter((m) => m.type === "log") + .map((m) => m.text) + .reverse(); + + let files = {}; + { + appGenerationResult?.messages + .filter((m) => m.type === "write-file") + .map((m) => m.text.split("\n")) + .forEach(([path, ...contentLines]) => { + files[path] = contentLines.join("\n"); + }); + } + + const language = useMemo(() => { + if (activeFilePath) { + const ext = activeFilePath.split(".").pop(); + if (["jsx", "tsx", "js", "ts"].includes(ext)) { + return "javascript"; + } else if (["wasp"].includes(ext)) { + return "wasp"; + } else { + return ext; + } + } + }, [activeFilePath]); + + const interestingFilePaths = useMemo(() => { + if (files) { + return Object.keys(files) + .filter( + (path) => + path !== ".env.server" && + path !== ".env.client" && + path !== "src/client/vite-env.d.ts" && + path !== "src/client/tsconfig.json" && + path !== "src/server/tsconfig.json" && + path !== "src/shared/tsconfig.json" && + path !== ".gitignore" && + path !== "src/.waspignore" && + path !== ".wasproot" + ) + .sort( + (a, b) => + (a.endsWith(".wasp") ? 0 : 1) - (b.endsWith(".wasp") ? 0 : 1) + ); + } else { + return []; + } + }, [files]); + + function downloadZip() { + const safeAppName = appGenerationResult.appName.replace( + /[^a-zA-Z0-9]/g, + "_" + ); + createFilesAndDownloadZip(files, safeAppName); + } + + function toggleLogs() { + setLogsVisible(!logsVisible); + } + + return ( + <div className="container"> + <div className="mb-4 bg-slate-50 p-8 rounded-xl flex justify-between items-center"> + <Title /> + <StatusPill status={currentStatus.status}> + {currentStatus.message} + </StatusPill> + </div> + + <header className="mt-4 bg-slate-900 text-white p-8 rounded-xl flex justify-between items-center"> + <pre>{logs && logs.length > 0 ? logs[0] : "Waiting for logs..."}</pre> + <button onClick={toggleLogs}> + {logsVisible ? "Hide the logs" : "Expand the logs"} + </button> + </header> + + {interestingFilePaths.length > 0 && ( + <> + <div className="grid gap-4 grid-cols-[300px_minmax(900px,_1fr)_100px] mt-4"> + <aside> + <div className="mb-2"> + <h2 className="text-xl font-bold text-gray-800"> + {appGenerationResult.appName} + </h2> + </div> + <FileTree + paths={interestingFilePaths} + activeFilePath={activeFilePath} + onActivePathSelect={setActiveFilePath} + /> + <RunTheAppModal + onDownloadZip={downloadZip} + disabled={currentStatus.status !== "success"} + /> + {currentStatus.status === "success" && ( + <Link className="button gray w-full mt-2 block" to="/"> + Generate another one? + </Link> + )} + </aside> + + {activeFilePath && ( + <main> + <div className="font-bold text-sm bg-slate-200 text-slate-700 p-3 rounded rounded-b-none"> + {activeFilePath}: + </div> + <div + key={activeFilePath} + className="py-4 bg-slate-100 rounded rounded-t-none" + > + <CodeHighlight language={language}> + {files[activeFilePath].trim()} + </CodeHighlight> + </div> + </main> + )} + {!activeFilePath && ( + <main className="p-8 bg-slate-100 rounded grid place-content-center"> + <div className="text-center"> + <div className="font-bold">Select a file to view</div> + <div className="text-gray-500 text-sm"> + (click on a file in the file tree) + </div> + </div> + </main> + )} + </div> + </> + )} + </div> + ); +}; + +import React from "react"; +import { Title } from "../components/Title"; + +export default function RunTheAppModal({ disabled, onDownloadZip }) { + const [showModal, setShowModal] = React.useState(false); + return ( + <> + <button + className="button w-full mt-2" + disabled={disabled} + onClick={() => setShowModal(true)} + > + Run the app locally ⚡️ + </button> + {showModal ? ( + <> + <div className="justify-center items-center flex overflow-x-hidden overflow-y-auto fixed inset-0 z-50 outline-none focus:outline-none"> + <div className="relative w-auto my-6 mx-auto max-w-3xl"> + <div className="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white outline-none focus:outline-none"> + <div className="flex items-center justify-between p-5 border-b border-solid border-slate-200 rounded-t"> + <h3 className="text-xl font-semibold text-gray-900"> + Run the app locally ⚡️ + </h3> + <button + type="button" + class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center" + onClick={() => setShowModal(false)} + > + <svg + aria-hidden="true" + class="w-5 h-5" + fill="currentColor" + viewBox="0 0 20 20" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill-rule="evenodd" + d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" + clip-rule="evenodd" + ></path> + </svg> + <span class="sr-only">Close modal</span> + </button> + </div> + <div className="p-6 space-y-6"> + <p className="text-base leading-relaxed text-gray-500"> + First, you need to install Wasp locally. You can do that by + running this command in your terminal: + </p> + <pre className="bg-slate-50 p-4 rounded-lg text-sm">curl -sSL https://get.wasp-lang.dev/installer.sh | sh</pre> + <p className="text-base leading-relaxed text-gray-500"> + Then, you download the ZIP file with the generated app: + </p> + <button + className="button w-full" + onClick={onDownloadZip} + > + Download ZIP + </button> + <p className="text-base leading-relaxed text-gray-500"> + Unzip the file and run the app with: + </p> + <pre className="bg-slate-50 p-4 rounded-lg text-sm">wasp start</pre> + <p className="text-base leading-relaxed text-gray-500"> + Congratulations, you are now running your Wasp app locally! 🎉 + </p> + </div> + </div> + </div> + </div> + <div className="opacity-25 fixed inset-0 z-40 bg-black"></div> + </> + ) : null} + </> + ); +}