New design

This commit is contained in:
Mihovil Ilakovac 2023-06-21 12:37:02 +02:00
parent 122dc2a058
commit 37dba202fd
7 changed files with 389 additions and 210 deletions

View File

@ -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"
}

View File

@ -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;
}

View File

@ -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 (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
<footer className="text-center text-gray-500 text-sm mt-8">
This is an experiment by{" "}
<a
href="https://wasp-lang.dev/"
target="_blank"
rel="noopener noreferrer"
className="text-sky-500 hover:text-sky-600"
>
Wasp team
</a>
</footer>
</div>
);
}

View File

@ -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 (
<div className="flex items-center">
<span
className={`text-center inline-flex items-center pl-3 pr-4 py-1.5 rounded-lg border ${statusToClassName[status]}`}
>
<span className="w-1.5 h-1.5 rounded-full mr-2 bg-current"></span>
{children}
</span>
</div>
);
}

View File

@ -0,0 +1,22 @@
import waspLogo from "../waspLogo.png";
import { Link } from "react-router-dom";
export function Title() {
return (
<div className="flex justify-flex-start items-center">
<img src={waspLogo} alt="wasp" className="w-16" />
<h1 className="text-2xl font-bold text-slate-800 ml-4">
<Link to="/">Wasp AI App Generator</Link>
<div className="mt-2 flex justify-flex-start">
<iframe
src="https://ghbtns.com/github-btn.html?user=wasp-lang&repo=wasp&type=star&count=true"
frameborder="0"
width="100"
height="20"
title="GitHub"
></iframe>
</div>
</h1>
</div>
);
}

View File

@ -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 (
<div className="container">
<div
className="
flex
justify-flex-start
items-center
mb-8
"
>
<img src={waspLogo} alt="wasp" className="w-16" />
<h1
className="
text-3xl
font-bold
text-gray-800
ml-4
"
>
Wasp AI App Generator
</h1>
<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>
<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>
);
};

View File

@ -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}
</>
);
}