From fb39f86ce90167be8027a908d4e6b4b2c393f68c Mon Sep 17 00:00:00 2001 From: vincanger <70215737+vincanger@users.noreply.github.com> Date: Tue, 3 Oct 2023 14:58:59 +0200 Subject: [PATCH] [MAGE] add modals (#1463) --- wasp-ai/main.wasp | 23 ++++- .../migration.sql | 2 + wasp-ai/src/client/Main.css | 3 + wasp-ai/src/client/components/Header.jsx | 18 +++- wasp-ai/src/client/components/Title.jsx | 2 +- wasp-ai/src/client/pages/MainPage.jsx | 79 ++++++++++++++- wasp-ai/src/client/pages/ResultPage.jsx | 98 ++++++++++++++++++- wasp-ai/src/server/auth.ts | 18 +++- wasp-ai/src/server/operations.ts | 7 ++ 9 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 wasp-ai/migrations/20231002090520_add_github_login/migration.sql diff --git a/wasp-ai/main.wasp b/wasp-ai/main.wasp index 2750dc73c..22f9e6f6d 100644 --- a/wasp-ai/main.wasp +++ b/wasp-ai/main.wasp @@ -2,9 +2,9 @@ app waspAi { wasp: { version: "^0.11.0" }, - title: "GPT Web App Generator ✨", + title: "MAGE - GPT Web App Generator ✨", head: [ - "", + "", "", "", "", @@ -30,6 +30,7 @@ app waspAi { ("@visx/responsive", "3.0.0"), ("@visx/gradient", "3.0.0"), ("@visx/axis", "3.2.0"), + ("js-confetti", "0.11.0") ], client: { rootComponent: import { RootComponent } from "@client/RootComponent.jsx", @@ -41,13 +42,17 @@ app waspAi { userEntity: User, externalAuthEntity: SocialLogin, methods: { + gitHub: { + configFn: import { getGitHubAuthConfig } from "@server/auth.js", + getUserFieldsFn: import { getGitHubUserFields } from "@server/auth.js", + }, google: { configFn: import { getGoogleAuthConfig } from "@server/auth.js", - getUserFieldsFn: import { getUserFields } from "@server/auth.js", - } + getUserFieldsFn: import { getGoogleUserFields } from "@server/auth.js", + }, }, onAuthFailedRedirectTo: "/login", - onAuthSucceededRedirectTo: "/stats" + onAuthSucceededRedirectTo: "/" } } @@ -115,6 +120,13 @@ query getStats { ] } +query getNumProjects { + fn: import { getNumProjects } from "@server/operations.js", + entities: [ + Project + ] +} + entity User {=psl id Int @id @default(autoincrement()) @@ -131,6 +143,7 @@ entity SocialLogin {=psl userId Int user User @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) psl=} entity Project {=psl diff --git a/wasp-ai/migrations/20231002090520_add_github_login/migration.sql b/wasp-ai/migrations/20231002090520_add_github_login/migration.sql new file mode 100644 index 000000000..6ec1d821b --- /dev/null +++ b/wasp-ai/migrations/20231002090520_add_github_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SocialLogin" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/wasp-ai/src/client/Main.css b/wasp-ai/src/client/Main.css index f2e435a76..a80e438bc 100644 --- a/wasp-ai/src/client/Main.css +++ b/wasp-ai/src/client/Main.css @@ -18,6 +18,9 @@ body { .button.light-blue { @apply bg-sky-200 text-sky-600 hover:bg-sky-100 } +.button.amber{ + @apply bg-amber-400 text-slate-900 hover:bg-amber-300 +} .button.sm { @apply text-sm py-2 px-4; } diff --git a/wasp-ai/src/client/components/Header.jsx b/wasp-ai/src/client/components/Header.jsx index 9f584b0e5..55e73ff53 100644 --- a/wasp-ai/src/client/components/Header.jsx +++ b/wasp-ai/src/client/components/Header.jsx @@ -1,15 +1,25 @@ -import { StatusPill } from "./StatusPill"; -import { Title } from "./Title"; +import { StatusPill } from './StatusPill'; +import { Title } from './Title'; +import { signInUrl as gitHubSignInUrl } from '@wasp/auth/helpers/GitHub'; +import { AiFillGithub } from 'react-icons/ai'; export function Header({ currentStatus, isStatusVisible }) { return ( -
+
{isStatusVisible && ( - <StatusPill status={currentStatus.status} className="hidden md:flex"> + <StatusPill status={currentStatus.status} className='hidden md:flex'> {currentStatus.message} </StatusPill> )} </div> ); } + +function GithubLoginButton() { + return ( + <button className='button gray flex !text-gray-800 hover:bg-slate-300 shadow-md' onClick={() => window.location.href = gitHubSignInUrl}> + <AiFillGithub className='w-6 h-6 mr-2' /> Sign in with GitHub + </button> + ) +} \ No newline at end of file diff --git a/wasp-ai/src/client/components/Title.jsx b/wasp-ai/src/client/components/Title.jsx index 3406853d5..f856ade70 100644 --- a/wasp-ai/src/client/components/Title.jsx +++ b/wasp-ai/src/client/components/Title.jsx @@ -10,7 +10,7 @@ export function Title() { <img src={magicLogo} alt="wasp" className="w-20" /> </Link> <div className="text-xl md:text-2xl font-bold text-slate-800 ml-4"> - <h1>GPT Web App Generator ✨</h1> + <h1>MAGE ✨ GPT Web App Generator</h1> <p className="md:text-base text-sm leading-relaxed text-gray-500"> Generate your full-stack web app in Wasp, React, Node.js and Prisma </p> diff --git a/wasp-ai/src/client/pages/MainPage.jsx b/wasp-ai/src/client/pages/MainPage.jsx index 7dae4d69b..de67c6314 100644 --- a/wasp-ai/src/client/pages/MainPage.jsx +++ b/wasp-ai/src/client/pages/MainPage.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp"; import { useHistory } from "react-router-dom"; import { MyDropdown } from "../components/Dropdown"; @@ -7,12 +7,14 @@ import { Header } from "../components/Header"; import { availableColors } from "../components/Color"; import { Faq } from "../components/Faq"; import { exampleIdeas } from "../examples"; -import { PiMagicWandDuotone } from "react-icons/pi"; +import { PiMagicWandDuotone, PiGithubLogoDuotone, PiStarDuotone } from "react-icons/pi"; import { readReferrerFromLocalStorage } from "../storage"; +import { MyDialog } from "../components/Dialog"; const MainPage = () => { const [appName, setAppName] = useState(""); const [appDesc, setAppDesc] = useState(""); + const [isGhModalOpen, setIsGhModalOpen] = useState(false); const [currentStatus, setCurrentStatus] = useState({ status: "idle", message: "Waiting for instructions", @@ -68,12 +70,51 @@ const MainPage = () => { const [appAuthMethod, setAppAuthMethod] = useState(availableAuthMethods[0]); + useEffect(() => { + try { + const appDetails = JSON.parse(localStorage.getItem("appDetails")); + const appNum = JSON.parse(localStorage.getItem("appNum")); + if (!appNum) { + localStorage.setItem("appNum", 0); + } + if (appNum === 2) { + setIsGhModalOpen(true); + } + if (appDetails) { + setAppName(appDetails.appName); + setAppDesc(appDetails.appDesc); + setAppPrimaryColor(availableColors.find((color) => color.name === appDetails.appPrimaryColor)); + setAppAuthMethod(availableAuthMethods.find((method) => method.value === appDetails.appAuthMethod)); + setCreativityLevel(availableCreativityLevels.find((level) => level.value === appDetails.appCreativityLevel)); + localStorage.removeItem("appDetails"); + } + } catch (error) { + console.error(error); + } + }, []); + async function startGenerating(event) { event.preventDefault(); + + try { + const appNum = JSON.parse(localStorage.getItem("appNum")) + localStorage.setItem("appNum", appNum + 1) + localStorage.setItem("appDetails", JSON.stringify({ + appName, + appDesc, + appPrimaryColor: appPrimaryColor.name, + appAuthMethod: appAuthMethod.value, + appCreativityLevel: creativityLevel.value, + })); + } catch (error) { + console.error(error) + } + setCurrentStatus({ status: "idle", message: "Starting...", }); + try { const referrer = readReferrerFromLocalStorage(); const appId = await startGeneratingNewApp({ @@ -110,6 +151,8 @@ const MainPage = () => { <div className="container"> <Header currentStatus={currentStatus} isStatusVisible={true} /> + <GhModal isGhModalOpen={isGhModalOpen} setIsGhModalOpen={setIsGhModalOpen} /> + <form onSubmit={startGenerating} className="bg-slate-50 p-8 rounded-xl"> <div className="mb-6 flex flex-col gap-3"> <div> @@ -208,4 +251,34 @@ The simpler and more specific the app is, the better the generated app will be." </div> ); }; -export default MainPage; \ No newline at end of file +export default MainPage; + +export function GhModal({ isGhModalOpen, setIsGhModalOpen }) { + return ( + <MyDialog isOpen={isGhModalOpen} onClose={() => setIsGhModalOpen(false)} title={<span>With Great Power Comes Great Responsibility! 🧙</span>}> + <div className="mt-6 space-y-5"> + <p className="text-base leading-relaxed text-gray-500"> + We've made this tool completely <span className="font-semibold">free</span> and cover all the costs 😇 + </p> + + <p className="text-base leading-relaxed text-gray-500"> + But you can still show your support by starring us on GitHub: + </p> + <a + href="https://github.com/wasp-lang/wasp" + target="_blank" + className="flex items-center justify-center underline text-pink-600 " + > + <div className="py-4 px-2 flex items-center justify-center bg-pink-50 text-pink-800 rounded-lg font-semibold tracking-wide w-full"> + <PiStarDuotone size="1.35rem" className="mr-3" /> Star Wasp on GitHub{" "} + <PiGithubLogoDuotone size="1.35rem" className="ml-3" /> + </div> + </a> + <p className="text-base leading-relaxed text-gray-500"> + This helps spread the word, so we can keep making Mage better. + </p> + <p className="text-base leading-relaxed text-gray-500">We'd very much appreciate it! 🧙</p> + </div> + </MyDialog> + ) +} \ No newline at end of file diff --git a/wasp-ai/src/client/pages/ResultPage.jsx b/wasp-ai/src/client/pages/ResultPage.jsx index e022b371e..04ddb69cb 100644 --- a/wasp-ai/src/client/pages/ResultPage.jsx +++ b/wasp-ai/src/client/pages/ResultPage.jsx @@ -22,13 +22,19 @@ import { PiLaptopDuotone, PiDownloadDuotone, PiCheckDuotone, - PiChatBold -} from "react-icons/pi"; + PiGithubLogoDuotone, + PiStarDuotone, +} from 'react-icons/pi'; import { RxQuestionMarkCircled } from "react-icons/rx"; +import JSConfetti from 'js-confetti'; +import getNumProjects from "@wasp/queries/getNumProjects"; + +const jsConfetti = new JSConfetti(); export const ResultPage = () => { const { appId } = useParams(); const [generationDone, setGenerationDone] = useState(false); + const [isStarRepoOpen, setIsStarRepoOpen] = useState(false); const { data: appGenerationResult, isError, @@ -66,6 +72,12 @@ export const ResultPage = () => { setGenerationDone(false); }, [appId]); + useEffect(() => { + if (currentStatus.status === "success") { + setIsStarRepoOpen(true); + } + }, [currentStatus]) + const logs = appGenerationResult?.project?.logs.map((log) => log.content); const files = useMemo(() => { @@ -205,6 +217,7 @@ export const ResultPage = () => { currentStatus={currentStatus} isStatusVisible={!!appGenerationResult?.project} /> + <StarOurRepoModal isStarRepoOpen={isStarRepoOpen} setIsStarRepoOpen={setIsStarRepoOpen} appGenerationResult={appGenerationResult}/> {isError && ( <div className="mb-4 bg-red-50 p-8 rounded-xl"> @@ -393,6 +406,87 @@ function getCardinalNumber(number) { } } +export function StarOurRepoModal({ isStarRepoOpen, setIsStarRepoOpen, appGenerationResult }) { + const [tokens, setTokens] = useState(0) + const { data: numTotalProjects } = useQuery(getNumProjects, {}, { enabled: isStarRepoOpen }) + + const tokenNumberStr = appGenerationResult?.project?.logs?.filter(log => log.content.toLowerCase().includes("tokens usage") === true)[0]?.content.split(':')[1]?.trim() + + useEffect(() => { + if (!!tokenNumberStr) { + const num = tokenNumberStr.slice(1, -1) * 1000 + setTokens(num) + } + }, [tokenNumberStr]) + + useEffect(() => { + if (isStarRepoOpen) { + jsConfetti.addConfetti({ + emojis: ['🐝'], + emojiSize: 120, + }) + } + }, [isStarRepoOpen]) + return ( + <MyDialog isOpen={isStarRepoOpen} onClose={() => setIsStarRepoOpen(false)} title={<span>Your App is Ready! 🎉</span>}> + <div className="mt-6 space-y-5"> + <p className="text-base leading-relaxed text-gray-500"> + We've made this tool completely <span className="font-semibold">free</span> and cover all the costs 😇 + </p> + <table className="bg-slate-50 rounded-lg divide-y divide-gray-100 w-full text-base leading-relaxed text-gray-500 text-sm"> + {/* <caption>Fun Stats</caption> */} + <tr> + <td className="p-2 text-gray-600"> Number of tokens your app used: </td> + <td className="p-2 text-gray-600"> + {" "} + <FormattedText>{tokens.toLocaleString(2) ?? "~22k"}</FormattedText>{" "} + </td> + </tr> + <tr> + <td className="p-2 text-gray-600"> Cost to generate your app: </td> + <td className="p-2 text-gray-600"> + {" "} + <FormattedText>{tokens ? `$${((tokens / 1000) * 0.004).toFixed(2)}` : "~ $0.50"}</FormattedText>{" "} + </td> + </tr> + {numTotalProjects && ( + <tr className="p-2 text-gray-600"> + <td className="p-2 text-gray-600"> Total number of apps generated with Mage: </td> + <td className="p-2 text-gray-600"> + {" "} + <FormattedText>{numTotalProjects.toLocaleString()}</FormattedText>{" "} + </td> + </tr> + )} + </table> + <p className="text-base leading-relaxed text-gray-500"> + But you can still show your support by starring us on GitHub: + </p> + <a + href="https://github.com/wasp-lang/wasp" + target="_blank" + className="flex items-center justify-center underline text-pink-600 " + > + <div className="py-4 px-2 flex items-center justify-center bg-pink-50 text-pink-800 rounded-lg font-semibold tracking-wide w-full"> + <PiStarDuotone size="1.35rem" className="mr-3" /> Star Wasp on GitHub{" "} + <PiGithubLogoDuotone size="1.35rem" className="ml-3" /> + </div> + </a> + <p className="text-base leading-relaxed text-gray-500"> + This helps spread the word, so we can keep making Mage better. + </p> + <p className="text-base leading-relaxed text-gray-500">We'd very much appreciate it! 🧙</p> + </div> + </MyDialog> + ) +} + +function FormattedText({ children }) { + return ( + <span className='py-1 px-2 font-semibold text-pink-800 rounded'>{children}</span> + ) +} + export default function RunTheAppModal({ disabled, onDownloadZip }) { const [showModal, setShowModal] = useState(false); return ( diff --git a/wasp-ai/src/server/auth.ts b/wasp-ai/src/server/auth.ts index 683b821bf..b54419310 100644 --- a/wasp-ai/src/server/auth.ts +++ b/wasp-ai/src/server/auth.ts @@ -1,6 +1,6 @@ import { GetUserFieldsFn } from "@wasp/types"; -export const getUserFields: GetUserFieldsFn = async (_context, args) => { +export const getGoogleUserFields: GetUserFieldsFn = async (_context, args) => { return { email: args.profile.emails[0].value, }; @@ -13,3 +13,19 @@ export const getGoogleAuthConfig = () => { scope: ["profile", "email"], }; }; + + +export const getGitHubUserFields: GetUserFieldsFn = async (_context, args) => { + // NOTE: if we don't want to access users' emails, we can use scope ["user:read"] + // instead of ["user"] and access args.profile.username instead + const email = args.profile.emails[0].value; + return { email }; +}; + +export function getGitHubAuthConfig() { + return { + clientID: process.env.GITHUB_CLIENT_ID, // look up from env or elsewhere + clientSecret: process.env.GITHUB_CLIENT_SECRET, // look up from env or elsewhere + scope: ["user"], + }; +} \ No newline at end of file diff --git a/wasp-ai/src/server/operations.ts b/wasp-ai/src/server/operations.ts index fa06c060e..7dcd91a43 100644 --- a/wasp-ai/src/server/operations.ts +++ b/wasp-ai/src/server/operations.ts @@ -8,6 +8,7 @@ import { GetAppGenerationResult, GetStats, GetFeedback, + GetNumProjects, } from "@wasp/queries/types"; import HttpError from "@wasp/core/HttpError.js"; import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.js"; @@ -223,6 +224,12 @@ export const getStats = (async (_args, context) => { }; }) satisfies GetStats<{}>; +export const getNumProjects = (async (_args, context) => { + const { Project } = context.entities; + const numProjects = await Project.count(); + return numProjects; +}) satisfies GetNumProjects<{}>; + function getDownloadStats(projects: Project[]) { const projectsAfterDownloadTracking = projects.filter( (project) =>